Initial commit

This commit is contained in:
Gagan7900
2026-07-13 11:57:09 +05:30
commit ac629ef540
26616 changed files with 795848 additions and 0 deletions
@@ -0,0 +1,56 @@
<button
[type]="resolvedType()"
[class]="buttonClass()"
[disabled]="isDisabled()"
[attr.aria-label]="
ariaLabel() ||
resolvedLabel() ||
title() ||
'Button'
"
[attr.aria-busy]="loading() ? true : null"
(click)="onClick($event)"
>
<span class="inline-flex items-center justify-center gap-1">
@if (loading()) {
<span
class="inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
></span>
}
@if (
!loading() &&
showIcon() &&
resolvedIcon() &&
iconPosition() === 'left'
) {
<i
[class]="resolvedIconClass()"
aria-hidden="true"
></i>
}
@if (!iconOnly()) {
<span>
@if (loading() && loadingLabel()) {
{{ loadingLabel() }}
} @else {
{{ resolvedLabel() }}
}
</span>
}
@if (
!loading() &&
showIcon() &&
resolvedIcon() &&
iconPosition() === 'right'
) {
<i
[class]="resolvedIconClass()"
aria-hidden="true"
></i>
}
</span>
</button>
+349
View File
@@ -0,0 +1,349 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output
} from '@angular/core';
export type ButtonType = 'button' | 'submit' | 'reset';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
export type IconPosition = 'left' | 'right';
export type ButtonVariant =
| 'primary'
| 'primary-full'
| 'secondary'
| 'secondary-full'
| 'success'
| 'success-full'
| 'danger'
| 'danger-full'
| 'warning'
| 'warning-full'
| 'info'
| 'info-full'
| 'light'
| 'dark'
| 'transparent'
| 'custom';
export type ButtonAction =
| 'add'
| 'save'
| 'submit'
| 'update'
| 'edit'
| 'delete'
| 'cancel'
| 'close'
| 'upload'
| 'download'
| 'import'
| 'export'
| 'view'
| 'search'
| 'refresh'
| 'reset'
| 'approve'
| 'reject'
| 'login'
| 'next'
| 'previous'
| 'custom';
interface ButtonActionConfig {
label: string;
icon: string | null;
variant: ButtonVariant;
type: ButtonType;
}
const BUTTON_ACTION_CONFIG: Record<ButtonAction, ButtonActionConfig> = {
add: {
label: 'Add',
icon: 'ri-add-line',
variant: 'primary-full',
type: 'button'
},
save: {
label: 'Save',
icon: 'ri-save-line',
variant: 'primary-full',
type: 'submit'
},
submit: {
label: 'Submit',
icon: 'ri-send-plane-line',
variant: 'primary-full',
type: 'submit'
},
update: {
label: 'Update',
icon: 'ri-save-3-line',
variant: 'primary-full',
type: 'submit'
},
edit: {
label: 'Edit',
icon: 'ri-edit-line',
variant: 'primary-full',
type: 'button'
},
delete: {
label: 'Delete',
icon: 'ri-delete-bin-line',
variant: 'danger-full',
type: 'button'
},
cancel: {
label: 'Cancel',
icon: 'ri-close-line',
variant: 'light',
type: 'button'
},
close: {
label: 'Close',
icon: 'ri-close-line',
variant: 'light',
type: 'button'
},
upload: {
label: 'Upload',
icon: 'ri-upload-cloud-2-line',
variant: 'primary-full',
type: 'button'
},
download: {
label: 'Download',
icon: 'ri-download-cloud-2-line',
variant: 'success-full',
type: 'button'
},
import: {
label: 'Import',
icon: 'ri-file-upload-line',
variant: 'info-full',
type: 'button'
},
export: {
label: 'Export',
icon: 'ri-file-download-line',
variant: 'success-full',
type: 'button'
},
view: {
label: 'View',
icon: 'ri-eye-line',
variant: 'info-full',
type: 'button'
},
search: {
label: 'Search',
icon: 'ri-search-line',
variant: 'primary-full',
type: 'button'
},
refresh: {
label: 'Refresh',
icon: 'ri-refresh-line',
variant: 'light',
type: 'button'
},
reset: {
label: 'Reset',
icon: 'ri-restart-line',
variant: 'light',
type: 'reset'
},
approve: {
label: 'Approve',
icon: 'ri-check-line',
variant: 'success-full',
type: 'button'
},
reject: {
label: 'Reject',
icon: 'ri-close-circle-line',
variant: 'danger-full',
type: 'button'
},
login: {
label: 'Login',
icon: 'ri-login-box-line',
variant: 'primary-full',
type: 'submit'
},
next: {
label: 'Next',
icon: 'ri-arrow-right-line',
variant: 'primary-full',
type: 'button'
},
previous: {
label: 'Previous',
icon: 'ri-arrow-left-line',
variant: 'light',
type: 'button'
},
custom: {
label: '',
icon: null,
variant: 'custom',
type: 'button'
}
};
@Component({
selector: 'app-button',
standalone: true,
templateUrl: './button.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Button {
/*
* Action provides sensible defaults.
* Explicit inputs below override action configuration.
*/
readonly action = input<ButtonAction | null>(null);
readonly label = input<string | null>(null);
readonly icon = input<string | null>(null);
readonly type = input<ButtonType | null>(null);
readonly variant = input<ButtonVariant | null>(null);
readonly size = input<ButtonSize>('md');
readonly iconPosition = input<IconPosition>('left');
readonly showIcon = input(true);
readonly iconOnly = input(false);
readonly loading = input(false);
readonly loadingLabel = input<string | null>(null);
readonly disabled = input(false);
readonly fullWidth = input(false);
readonly rounded = input(false);
readonly className = input('');
readonly iconClass = input('');
readonly ariaLabel = input<string | null>(null);
readonly title = input<string | null>(null);
readonly buttonClicked = output<MouseEvent>();
private readonly actionConfig = computed<ButtonActionConfig>(() => {
const action = this.action();
return action
? BUTTON_ACTION_CONFIG[action]
: BUTTON_ACTION_CONFIG.custom;
});
readonly resolvedLabel = computed(() => {
return this.label() ?? this.actionConfig().label;
});
readonly resolvedIcon = computed(() => {
// Explicit icon overrides the action icon.
return this.icon() ?? this.actionConfig().icon;
});
readonly resolvedType = computed<ButtonType>(() => {
return this.type() ?? this.actionConfig().type ?? 'button';
});
readonly resolvedVariant = computed<ButtonVariant>(() => {
const explicitVariant = this.variant();
if (explicitVariant) {
return explicitVariant;
}
const action = this.action();
if (action) {
return BUTTON_ACTION_CONFIG[action].variant;
}
return 'primary-full';
});
readonly isDisabled = computed(() => {
return this.disabled() || this.loading();
});
readonly resolvedIconClass = computed(() => {
return [
this.resolvedIcon(),
'align-middle leading-none',
this.iconClass()
]
.filter(Boolean)
.join(' ');
});
readonly buttonClass = computed(() => {
const variants: Record<ButtonVariant, string> = {
primary: 'ti-btn-primary',
'primary-full': 'ti-btn-primary-full',
secondary: 'ti-btn-secondary',
'secondary-full': 'ti-btn-secondary-full',
success: 'ti-btn-success',
'success-full': 'ti-btn-success-full',
danger: 'ti-btn-danger',
'danger-full': 'ti-btn-danger-full',
warning: 'ti-btn-warning',
'warning-full': 'ti-btn-warning-full',
info: 'ti-btn-info',
'info-full': 'ti-btn-info-full',
light: 'ti-btn-light',
dark: 'ti-btn-dark',
transparent: 'bg-transparent',
custom: ''
};
const sizes: Record<ButtonSize, string> = {
xs: '!px-2 !py-1 !text-[0.6875rem]',
sm: '!px-3 !py-1.5 !text-[0.75rem]',
md: '',
lg: 'ti-btn-lg'
};
return [
'ti-btn',
'inline-flex items-center justify-center',
'cursor-pointer',
variants[this.resolvedVariant()],
sizes[this.size()],
this.fullWidth() ? 'w-full' : '',
this.iconOnly() ? '!p-0' : '',
this.rounded() ? '!rounded-full' : '',
this.isDisabled()
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer',
this.className()
]
.filter(Boolean)
.join(' ');
});
onClick(event: MouseEvent): void {
if (this.isDisabled()) {
event.preventDefault();
event.stopPropagation();
return;
}
this.buttonClicked.emit(event);
}
}
@@ -0,0 +1,60 @@
import { signal } from '@angular/core';
import {
DataTablePageEvent,
DataTableSortEvent
} from './data-table.types';
import { DataTableQuery } from './data-table.types';
export class DataTableQueryState {
pageIndex = signal(1);
pageSize = signal(10);
searchText = signal('');
sortColumn = signal('');
sortDirection = signal<'asc' | 'desc'>('asc');
private readonly draw = signal(1);
getQuery(): DataTableQuery {
return {
draw: this.draw(),
page: this.pageIndex(),
pageSize: this.pageSize(),
search: this.searchText(),
sortBy: this.sortColumn(),
sortDir: this.sortDirection(),
isActive: null
};
}
setSearch(value: string): DataTableQuery {
this.searchText.set(value);
this.pageIndex.set(1);
this.draw.update(v => v + 1);
return this.getQuery();
}
setPage(event: DataTablePageEvent): DataTableQuery {
this.pageIndex.set(event.pageIndex);
this.pageSize.set(event.pageSize);
this.draw.update(v => v + 1);
return this.getQuery();
}
setSort(event: DataTableSortEvent): DataTableQuery {
this.sortColumn.set(event.column);
this.sortDirection.set(event.direction);
this.pageIndex.set(1);
this.draw.update(v => v + 1);
return this.getQuery();
}
reset(): DataTableQuery {
this.pageIndex.set(1);
this.pageSize.set(10);
this.searchText.set('');
this.sortColumn.set('');
this.sortDirection.set('asc');
this.draw.set(1);
return this.getQuery();
}
}
@@ -0,0 +1,228 @@
<!-- Start::row-1 -->
<div class="grid grid-cols-12">
<div class="xl:col-span-12 col-span-12">
<div class="box overflow-visible">
<div class="box-header justify-between">
<div class="box-title">
{{ tableTitle () }}
</div>
<div class="flex flex-wrap gap-2">
@if(showAddButton()){
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
(buttonClicked)="onAddClick($event)" appTooltip="Add Country"/>
}
@if (showSearch()) {
<div>
<input #searchInput class="form-control form-control-sm" type="text" [placeholder]="searchPlaceholder()"
aria-label="table search" (input)="onSearch(searchInput.value)">
</div>
}
</div>
</div>
<div class="box-body !p-0">
<div class="table-responsive overflow-x-auto overflow-y-visible">
<table [class]="tableClass()">
<thead>
<tr [class]="trHeadClass()">
@for (column of columns(); track column.key) {
<th scope="col" [class]="getHeaderClass(column)" [style.width]="column.width || null"
(click)="onSort(column)">
<span class="inline-flex items-center gap-1" [class.cursor-pointer]="column.sortable">
{{ column.label }}
@if (column.sortable) {
<i [class]="getSortIconClass(column) + ' text-xs align-middle'"></i>
}
</span>
</th>
}
@if (actions().length > 0) {
<th scope="col" [class]="composeClass(actionHeaderClass(), 'relative overflow-visible')">Action</th>
}
</tr>
</thead>
<tbody>
@if (loading()) {
<tr class="border-b border-defaultborder">
<td [attr.colspan]="colspan()" class="py-10">
<div class="flex flex-col items-center justify-center gap-3 text-center">
<i class="ti ti-loader-2 animate-spin text-[1.5rem]"></i>
<span class="text-[0.875rem]">Loading data...</span>
</div>
</td>
</tr>
} @else if (rows().length === 0) {
<tr class="border-b border-defaultborder">
<td [attr.colspan]="colspan()" class="py-10 text-center">
<span class="text-[0.875rem]">{{ emptyMessage() }}</span>
</td>
</tr>
} @else
{
@for (row of rows(); track row['id']; let rowIndex = $index) {
<tr [class]="getRowClass(row, $index)" (click)="onRowClick(row)">
@for (column of columns(); track column.key) {
<td [class]="getCellClass(column)">
@if (getCellTemplate(column); as customTemplate) {
<ng-container [ngTemplateOutlet]="customTemplate.templateRef"
[ngTemplateOutletContext]="getCellTemplateContext(column, row)" />
} @else if (column.badge) {
<span [class]="getBadgeClass(column, row)">
{{ getDisplayValue(column, row) }}
</span>
} @else {
{{ getDisplayValue(column, row) }}
}
</td>
}
@if (actions().length > 0) {
<td [class]="composeClass(actionCellClass(), 'overflow-visible')" (click)="$event.stopPropagation()">
<div class="inline-flex w-full justify-center">
<button type="button" cdkOverlayOrigin #actionMenuOrigin="cdkOverlayOrigin" data-action-menu-trigger
class="
ti-btn ti-btn-icon ti-btn-sm ti-btn-primary
!m-0 !h-8 !w-8 !p-0
" aria-label="Open row actions" aria-haspopup="menu" [attr.aria-controls]="getActionMenuId(row)"
[attr.aria-expanded]="isActionMenuOpen(row)" (click)="toggleActionMenu($event, row)">
<i class="ri-more-2-fill text-lg"></i>
</button>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="actionMenuOrigin"
[cdkConnectedOverlayOpen]="isActionMenuOpen(row)"
[cdkConnectedOverlayPositions]="actionMenuPositions" [cdkConnectedOverlayViewportMargin]="8"
[cdkConnectedOverlayPush]="true" [cdkConnectedOverlayHasBackdrop]="false"
(overlayOutsideClick)="onActionMenuOutsideClick($event)">
<div [id]="getActionMenuId(row)" role="menu" class="
w-[170px]
max-w-[calc(100vw-16px)]
overflow-hidden
rounded-lg
border border-defaultborder
bg-white
p-1.5
shadow-xl
dark:border-defaultborder/10
dark:bg-bodybg
" (click)="$event.stopPropagation()">
@for (action of actions(); track $index) {
@if (isActionVisible(action, row)) {
<button type="button" role="menuitem" class="
flex w-full items-center gap-2
rounded-md px-3 py-2
text-start text-sm
transition-colors
hover:bg-light
dark:hover:bg-white/10
" [disabled]="isActionDisabled(action, row)" [class.opacity-50]="isActionDisabled(action, row)"
[class.cursor-not-allowed]="isActionDisabled(action, row)"
[class.!text-danger]="action.type === 'delete'" [attr.aria-label]="getActionLabel(action)"
(click)="onDropdownActionClick($event, action, row)">
@if (action.icon) {
<i [class]="action.icon"></i>
}
<span>{{ action.label }}</span>
</button>
}
}
</div>
</ng-template>
</div>
</td>
}
</tr>
}
}
</tbody>
</table>
</div>
</div>
@if (showPaginator()) {
<div class="box-footer border-t-0">
<div class="flex items-center justify-between flex-wrap gap-3">
<!-- Record details and page-size selection -->
<div class="flex items-center flex-wrap gap-3">
<div class="text-sm text-defaulttextcolor">
Showing
<b>{{ startRecord() }}</b>
to
<b>{{ endRecord() }}</b>
of
<b>{{ totalRecords() }}</b>
entries
</div>
@if (pageSizeOptions().length > 0) {
<div class="flex items-center gap-2">
<label for="dataTablePageSize" class="text-sm whitespace-nowrap">
Show
</label>
<select id="dataTablePageSize" class="ti-form-select form-select-sm !w-[75px] !py-1"
(change)="onPageSizeChange($event)">
@for (size of pageSizeOptions(); track size) {
<option [value]="size" [selected]="size === pageSize()">
{{ size }}
</option>
}
</select>
<span class="text-sm whitespace-nowrap">
entries
</span>
</div>
}
</div>
<!-- Pagination -->
<nav aria-label="Table page navigation">
<ul class="ti-pagination mb-0">
<!-- Previous -->
<li class="page-item" [class.disabled]="pageIndex() <= 1">
<button type="button" class="page-link px-3 py-[0.375rem]" [disabled]="pageIndex() <= 1"
(click)="goToPage(pageIndex() - 1)">
Previous
</button>
</li>
<!-- Page numbers -->
@for (page of visiblePages(); track page) {
<li class="page-item">
<button type="button" class="page-link px-3 py-[0.375rem]" [class.active]="page === pageIndex()"
[attr.aria-current]="
page === pageIndex() ? 'page' : null
" (click)="goToPage(page)">
{{ page }}
</button>
</li>
}
<!-- Next -->
<li class="page-item" [class.disabled]="pageIndex() >= totalPages()">
<button type="button" class="page-link px-3 py-[0.375rem]" [disabled]="pageIndex() >= totalPages()"
(click)="goToPage(pageIndex() + 1)">
Next
</button>
</li>
</ul>
</nav>
</div>
</div>
}
</div>
</div>
</div>
<!--End::row-1 -->
@@ -0,0 +1,66 @@
// :host {
// display: block;
// }
// :host .table tbody td,
// :host .table tbody th {
// line-height: 1.25;
// vertical-align: middle;
// padding: 0.1rem !important;
// }
// .table-responsive {
// width: 100%;
// overflow-x: auto;
// }
// .table th,
// .table td {
// vertical-align: middle;
// }
:host {
display: block;
}
:host .table {
thead {
background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
thead th {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
line-height: 1.2;
vertical-align: middle;
color: var(--color-defaulttextcolor);
font-weight: 600;
}
tbody td,
tbody th {
padding-top: 0.2rem;
padding-bottom: 0.2rem;
line-height: 1.2;
vertical-align: middle;
}
tbody tr {
line-height: 1.1;
}
}
[data-theme-mode='dark'] :host .table thead {
background-color: color-mix(in srgb, var(--color-primary) 14%, transparent);
}
.table-responsive {
width: 100%;
overflow-x: auto;
overflow-y: visible;
}
.table th,
.table td {
vertical-align: middle;
}
@@ -0,0 +1,554 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, DestroyRef, HostListener, computed, contentChildren, effect, inject, input, output, signal } from '@angular/core';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataTableCellDirective } from '../../directives/data-table-cell.directive';
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
import { TooltipDirective } from '../../directives/tooltip/tooltip.directive';
import { DataTableAction, DataTableActionEvent, DataTableCellContext, DataTableColumn, DataTablePageEvent, DataTableRecord, DataTableSortEvent } from './data-table.types';
import { Button } from '../button/button';
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet, MatPaginatorModule, CdkOverlayOrigin,
CdkConnectedOverlay, Button, TooltipDirective],
templateUrl: './data-table.html',
styleUrl: './data-table.scss',
standalone: true
})
export class DataTable<T extends DataTableRecord = DataTableRecord> {
private readonly destroyRef = inject(DestroyRef);
private readonly searchTerms$ = new Subject<string>();
readonly cellTemplates = contentChildren(DataTableCellDirective);
private readonly defaultRowClasses = [
'table-primary',
'table-secondary',
'table-success',
'table-danger',
'table-warning',
'table-info',
'table-light'
];
readonly actionMenuPositions: ConnectedPosition[] = [
// Open below and align right
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: 4
},
// Open above and align right
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -4
},
// Mobile fallback: open below towards the right
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 4
},
// Mobile fallback: open above towards the right
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -4
}
];
openActionMenuUpward = signal(false);
columns = input<DataTableColumn<T>[]>([]);
rows = input<T[]>([]);
actions = input<DataTableAction<T>[]>([]);
loading = input(false);
/* --------- pagination inputs ---- */
totalRecords = input(0);
pageIndex = input(1);
pageSize = input(10);
pageSizeOptions = input<number[]>([5, 10, 20, 50]);
showPaginator = input(true);
/*---------------------------*/
tableTitle = input<string>('');
buttonTitle = input<string>('');
/* --------- Search inputs ---- */
showSearch = input<boolean>(false);
showAddButton = input<boolean>(false);
searchPlaceholder = input('Search...');
searchDebounceTime = input(300);
emptyMessage = input('No records found');
/*---------------------------*/
/* --------- Permission inputs ---- */
allowedPermissions = input<string[]>([]);
/*------------outputs ---------------*/
searchChanged = output<string>();
pageChanged = output<DataTablePageEvent>();
sortChanged = output<DataTableSortEvent>();
actionClicked = output<DataTableActionEvent<T>>();
rowClicked = output<T>();
sortColumn = signal('');
sortDirection = signal<'asc' | 'desc'>('asc');
openActionRowId = signal<string | number | null>(null);
tableClass = input<string>('table table-hover whitespace-nowrap min-w-full');
tableHeadClass = input<string>('');
tableBodyClass = input<string>('');
trHeadClass = input<string>('border-b border-defaultborder bg-primary/10 dark:bg-primary/15 dark:border-defaultborder/10');
trBodyClass = input<string>('border-b border-defaultborder hover:bg-light cursor-pointer');
rowClass = input<string | ((row: T, index: number) => string)>('');
rowColorMode = input<'none' | 'status' | 'cycle'>('status');
defaultThClass = input<string>('text-defaulttextcolor dark:text-white font-semibold text-start');
defaultTdClass = input<string>('');
actionHeaderClass = input<string>('!text-center');
actionCellClass = input<string>('!text-center');
addClicked = output<void>();
colspan = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
allowedPermissionSet = computed(() => new Set(this.allowedPermissions()));
cellTemplateMap = computed(() => {
const templateMap = new Map<string, DataTableCellDirective>();
for (const template of this.cellTemplates() as DataTableCellDirective[]) {
templateMap.set(template.appDataTableCell(), template);
}
return templateMap;
});
totalPages = computed(() => {
return Math.ceil(this.totalRecords() / this.pageSize()) || 1;
});
startRecord = computed(() => {
if (!this.totalRecords()) return 0;
return (this.pageIndex() - 1) * this.pageSize() + 1;
});
endRecord = computed(() => {
return Math.min(this.pageIndex() * this.pageSize(), this.totalRecords());
});
visiblePages = computed(() => {
const currentPage = this.pageIndex();
const total = this.totalPages();
const maxVisiblePages = 5;
let startPage = Math.max(
1,
currentPage - Math.floor(maxVisiblePages / 2)
);
let endPage = Math.min(
total,
startPage + maxVisiblePages - 1
);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(
1,
endPage - maxVisiblePages + 1
);
}
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
});
goToPage(page: number): void {
if (
page < 1 ||
page > this.totalPages() ||
page === this.pageIndex()
) {
return;
}
this.onPageChange({
pageIndex: page - 1,
pageSize: this.pageSize(),
length: this.totalRecords()
});
}
onPageSizeChange(event: Event): void {
const pageSize = Number(
(event.target as HTMLSelectElement).value
);
if (!pageSize || pageSize === this.pageSize()) {
return;
}
this.onPageChange({
pageIndex: 0,
pageSize,
length: this.totalRecords()
});
}
constructor() {
effect((onCleanup) => {
const debounce = this.searchDebounceTime();
const subscription = this.searchTerms$
.pipe(debounceTime(debounce), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.searchChanged.emit(value);
});
onCleanup(() => subscription.unsubscribe());
});
}
getCellValue(row: T, key: string): unknown {
return row[key] ?? null;
}
onSearch(value: string): void {
this.searchTerms$.next(value.trim());
}
onSort(column: DataTableColumn<T>): void {
if (!column.sortable) return;
const key = String(column.key);
if (this.sortColumn() === key) {
this.sortDirection.update(value => value === 'asc' ? 'desc' : 'asc');
} else {
this.sortColumn.set(key);
this.sortDirection.set('asc');
}
this.sortChanged.emit({
column: this.sortColumn(),
direction: this.sortDirection()
});
this.closeActionMenu();
}
onPageChange(event: PageEvent): void {
this.pageChanged.emit({
pageIndex: event.pageIndex + 1,
pageSize: event.pageSize
});
this.closeActionMenu();
}
onActionClick(event: MouseEvent, action: DataTableAction<T>, row: T): void {
event.stopPropagation();
if (this.isActionDisabled(action, row)) {
return;
}
this.actionClicked.emit({
action,
row
});
}
onAddClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.closeActionMenu();
this.addClicked.emit();
}
toggleActionMenu(event: MouseEvent, row: T): void {
event.stopPropagation();
const rowId = this.getRowId(row);
if (rowId === null) {
return;
}
this.openActionRowId.update(currentRowId =>
currentRowId === rowId ? null : rowId
);
}
closeActionMenu(): void {
this.openActionRowId.set(null);
}
onDropdownActionClick(
event: MouseEvent,
action: DataTableAction<T>,
row: T
): void {
event.stopPropagation();
this.onActionClick(event, action, row);
this.closeActionMenu();
}
isActionMenuOpen(row: T): boolean {
const rowId = row['id'];
return (
(typeof rowId === 'string' ||
typeof rowId === 'number') &&
this.openActionRowId() === rowId
);
}
getActionMenuId(row: T): string {
const rowId = this.getRowId(row);
return rowId === null ? 'row-actions-menu' : `row-actions-menu-${String(rowId)}`;
}
onActionMenuOutsideClick(event: MouseEvent): void {
const target = event.target;
if (!(target instanceof HTMLElement)) {
this.closeActionMenu();
return;
}
if (target.closest('[data-action-menu-trigger]')) {
return;
}
this.closeActionMenu();
}
isActionVisible(action: DataTableAction<T>, row: T): boolean {
if (action.permission && !this.allowedPermissionSet().has(action.permission)) {
return false;
}
return action.visible ? action.visible(row) : true;
}
isActionDisabled(action: DataTableAction<T>, row: T): boolean {
return action.disabled ? action.disabled(row) : false;
}
getActionLabel(action: DataTableAction<T>): string {
return action.tooltip || action.label;
}
getColumnKey(column: DataTableColumn<T>): string {
return String(column.key);
}
getSortIconClass(column: DataTableColumn<T>): string {
if (!column.sortable) {
return '';
}
const key = this.getColumnKey(column);
if (this.sortColumn() !== key) {
return 'ri-arrow-up-down-line';
}
return this.sortDirection() === 'asc' ? 'ri-sort-asc' : 'ri-sort-desc';
}
getHeaderClass(column: DataTableColumn<T>): string {
return this.composeClass(this.defaultThClass(), this.getAlignClass(column.align), column.headerClass);
}
getCellClass(column: DataTableColumn<T>): string {
return this.composeClass(this.defaultTdClass(), this.getAlignClass(column.align), column.cellClass);
}
getBadgeClass(column: DataTableColumn<T>, row: T): string {
const value = this.getCellValue(row, this.getColumnKey(column));
return this.composeClass(column.badgeClass ? column.badgeClass(value, row) : this.getDefaultBadgeClass(value));
}
getDisplayValue(column: DataTableColumn<T>, row: T): string | number {
const value = this.getCellValue(row, this.getColumnKey(column));
if (column.formatter) {
return column.formatter(value, row);
}
if (value === null || value === undefined) {
return '-';
}
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
}
getCellTemplate(column: DataTableColumn<T>): DataTableCellDirective | undefined {
return this.cellTemplateMap().get(this.getColumnKey(column));
}
getCellTemplateContext(column: DataTableColumn<T>, row: T): DataTableCellContext<T> {
return {
$implicit: row,
row,
value: this.getCellValue(row, this.getColumnKey(column))
};
}
getActionButtonClass(action: DataTableAction<T>): string {
return this.composeClass(
'ti-btn ti-btn-icon ti-btn-sm',
action.className || this.getDefaultActionClass(action.type)
);
}
onRowClick(row: T): void {
this.rowClicked.emit(row);
}
@HostListener('document:click')
onDocumentClick(): void {
this.closeActionMenu();
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
this.closeActionMenu();
}
private getAlignClass(align?: DataTableColumn<T>['align']): string {
switch (align) {
case 'left':
return 'text-start';
case 'right':
return 'text-end';
default:
return '!text-center';
}
}
composeClass(...classes: Array<string | undefined>): string {
return classes.filter(Boolean).join(' ');
}
private getRowId(row: T): string | number | null {
const rowId = row['id'];
return typeof rowId === 'string' || typeof rowId === 'number'
? rowId
: null;
}
private getDefaultActionClass(actionType: string): string {
switch (actionType) {
case 'view':
return 'ti-btn-info-full';
case 'edit':
return 'ti-btn-primary-full';
case 'delete':
return 'ti-btn-danger-full';
default:
return 'ti-btn-light';
}
}
private getDefaultBadgeClass(value: unknown): string {
if (typeof value === 'string') {
if (value.toLowerCase() === 'active') {
return 'badge bg-success/10 text-success';
}
if (value.toLowerCase() === 'inactive') {
return 'badge bg-danger/10 text-danger';
}
}
return 'badge bg-light text-defaulttextcolor';
}
getRowClass(row: T, index: number): string {
const baseClass = this.trBodyClass();
// 1. Parent override
const rowClassValue = this.rowClass();
if (rowClassValue) {
const customClass =
typeof rowClassValue === 'function'
? rowClassValue(row, index)
: rowClassValue;
return this.composeClass(baseClass, customClass);
}
// 2. Automatic status detection
const status =
row['status'] ??
row['state'] ??
row['approvalStatus'] ??
row['workflowStatus'];
if (typeof status === 'string') {
switch (status.toLowerCase()) {
case 'active':
case 'approved':
case 'success':
return this.composeClass(baseClass, 'table-success');
case 'inactive':
case 'rejected':
case 'failed':
return this.composeClass(baseClass, 'table-danger');
case 'pending':
return this.composeClass(baseClass, 'table-warning');
case 'draft':
return this.composeClass(baseClass, 'table-info');
}
}
// 3. Boolean status
if (typeof row['isActive'] === 'boolean') {
return this.composeClass(
baseClass,
row['isActive'] ? '' : 'table-danger'
);
}
// 4. Cycle through Ynex colors
const colorClass =
this.defaultRowClasses[index % this.defaultRowClasses.length];
return this.composeClass(baseClass, '');
}
}
export { DataTableCellDirective } from '../../directives/data-table-cell.directive';
@@ -0,0 +1,68 @@
export type DataTableRecord = Record<string, unknown>; /* table row is an object with string keys */
export type DataTableActionType = 'view' | 'edit' | 'delete' | string;
export interface DataTableColumn<T extends DataTableRecord = DataTableRecord> {
key: Extract<keyof T, string> | string;
label: string;
header: string;
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
formatter?: (value: unknown, row: T) => string | number; /* for change the value of custom cell like date format */
badge?: boolean;
badgeClass?: (value: unknown, row: T) => string; /* this is to show the badge according to status of record */
headerClass?: string;
cellClass?: string;
}
export interface DataTableAction<T extends DataTableRecord = any> {
type: DataTableActionType;
label: string;
icon?: string;
className?: string;
tooltip?: string;
permission?: string;
disabled?: (row: T) => boolean;
visible?: (row: T) => boolean;
}
export interface DataTablePageEvent {
pageIndex: number;
pageSize: number;
}
export interface DataTableSortEvent {
column: string;
direction: 'asc' | 'desc';
}
export interface DataTableActionEvent<T extends DataTableRecord = any> {
action: DataTableAction<T>;
row: T;
}
/* for custom cell templates */
export interface DataTableCellContext<T extends DataTableRecord = DataTableRecord> {
$implicit: T;
row: T;
value: unknown;
}
export interface DataTableQuery {
draw: number;
page: number;
pageSize: number;
sortBy?: string | null;
sortDir: 'asc' | 'desc';
search?: string | null;
isActive?: boolean | null;
}
export interface DataTableResult<T> {
draw: number;
total: number;
filtered: number;
rows: T[];
}
@@ -0,0 +1 @@
<p>form-action works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-action',
imports: [],
templateUrl: './form-action.html',
styleUrl: './form-action.scss',
})
export class FormAction {
}
@@ -0,0 +1 @@
<p>form-checkbox works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-checkbox',
imports: [],
templateUrl: './form-checkbox.html',
styleUrl: './form-checkbox.scss',
})
export class FormCheckbox {
}
@@ -0,0 +1 @@
<p>form-date-picker works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-date-picker',
imports: [],
templateUrl: './form-date-picker.html',
styleUrl: './form-date-picker.scss',
})
export class FormDatePicker {
}
@@ -0,0 +1,45 @@
<div [class]="resolvedWrapperClass()">
@if (showLabel()) {
<label [for]="inputId()" [class]="resolvedLabelClass()" >
{{ label() }}
@if (required()) {
<span [class]="requiredClass()" aria-hidden="true" >
*
</span>
<span class="sr-only">
Required
</span>
}
</label>
}
<div [class]="resolvedContentClass()">
@if (description()) {
<p [id]="descriptionId()" [class]="descriptionClass()" >
{{ description() }}
</p>
}
<ng-content />
@if (showHint()) {
<p
[id]="hintId()"
[class]="hintClass()"
>
{{ hint() }}
</p>
}
@if (!hideValidation()) {
<app-form-validation-message
[id]="validationId()"
[fieldName]="label()"
[control]="control()"
[messages]="validationMessages()"
[showWhenDirty]="showValidationWhenDirty()"
/>
}
</div>
</div>
@@ -0,0 +1,141 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input
} from '@angular/core';
import { AbstractControl } from '@angular/forms';
import {
FormValidationMessage,
ValidationMessageMap
} from '../form-validation-message/form-validation-message';
export type FormLabelPosition = 'top' | 'left' | 'hidden';
@Component({
selector: 'app-form-field',
standalone: true,
imports: [FormValidationMessage],
templateUrl: './form-field.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FormField {
readonly label = input.required<string>();
readonly inputId = input.required<string>();
readonly control = input<AbstractControl | null>(null);
readonly required = input(false);
readonly disabled = input(false);
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top');
readonly hideLabel = input(false);
readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false);
readonly validationMessages =
input<ValidationMessageMap>({});
readonly wrapperClass = input('');
readonly labelClass = input('');
readonly contentClass = input('');
readonly descriptionClass = input(
'mb-2 text-[0.75rem] leading-4 text-textmuted'
);
readonly hintClass = input(
'mt-1 text-[0.75rem] leading-4 text-textmuted'
);
readonly requiredClass = input(
'ms-0.5 text-danger'
);
readonly validationId = computed(
() => `${this.inputId()}-validation`
);
readonly hintId = computed(
() => `${this.inputId()}-hint`
);
readonly descriptionId = computed(
() => `${this.inputId()}-description`
);
readonly hasVisibleError = computed(() => {
const control = this.control();
if (!control?.invalid) {
return false;
}
if (this.showValidationWhenDirty()) {
return control.touched || control.dirty;
}
return control.touched;
});
readonly resolvedWrapperClass = computed(() => {
return [
this.labelPosition() === 'left'
? 'grid grid-cols-12 items-start gap-x-4 gap-y-2'
: '',
this.wrapperClass()
]
.filter(Boolean)
.join(' ');
});
readonly resolvedLabelClass = computed(() => {
return [
'form-label',
'mb-1.5 block',
this.labelPosition() === 'left'
? 'col-span-12 md:col-span-4 md:mb-0 md:pt-2'
: '',
this.disabled()
? 'cursor-not-allowed opacity-60'
: '',
this.labelClass()
]
.filter(Boolean)
.join(' ');
});
readonly resolvedContentClass = computed(() => {
return [
this.labelPosition() === 'left'
? 'col-span-12 md:col-span-8'
: '',
this.contentClass()
]
.filter(Boolean)
.join(' ');
});
readonly showLabel = computed(() => {
return (
!this.hideLabel() &&
this.labelPosition() !== 'hidden'
);
});
readonly showHint = computed(() => {
return !!this.hint() && !this.hasVisibleError();
});
}
@@ -0,0 +1 @@
<p>form-file-upload works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-file-upload',
imports: [],
templateUrl: './form-file-upload.html',
styleUrl: './form-file-upload.scss',
})
export class FormFileUpload {
}
@@ -0,0 +1,115 @@
<app-form-field
[label]="label()"
[inputId]="inputId()"
[control]="control()"
[required]="required()"
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[validationMessages]="validationMessages()"
[wrapperClass]="wrapperClass()"
[labelClass]="labelClass()"
[contentClass]="fieldContentClass()"
>
<div class="relative">
@if (prefixIcon()) {
<span
class="pointer-events-none absolute inset-y-0 start-0 z-[1] flex w-10 items-center justify-center text-textmuted"
aria-hidden="true"
>
<i
[class]="
prefixIcon() +
' text-[1rem] leading-none ' +
prefixIconClass()
"
></i>
</span>
}
<input
[id]="inputId()"
[name]="name()"
[type]="resolvedType()"
[class]="resolvedInputClass()"
[value]="value() ?? ''"
[placeholder]="resolvedPlaceholder()"
[required]="required()"
[readOnly]="readonly()"
[disabled]="isDisabled()"
[attr.autocomplete]="autocomplete()"
[attr.inputmode]="inputMode()"
[attr.spellcheck]="spellcheck()"
[attr.minlength]="minLength()"
[attr.maxlength]="maxLength()"
[attr.pattern]="pattern()"
[attr.min]="min()"
[attr.max]="max()"
[attr.step]="step()"
[attr.aria-label]="ariaLabel() || label()"
[attr.aria-description]="ariaDescription()"
[attr.aria-describedby]="describedBy()"
[attr.aria-invalid]="control()?.invalid ? true : null"
[attr.aria-required]="required() ? true : null"
[attr.aria-busy]="loading() ? true : null"
(input)="onInput($event)"
(blur)="onBlur()"
/>
@if (loading()) {
<span
class="pointer-events-none absolute inset-y-0 end-0 flex w-10 items-center justify-center"
aria-hidden="true"
>
<span
class="inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent text-primary"
></span>
</span>
} @else if (
type() === 'password' &&
showPasswordToggle()
) {
<button
type="button"
class="absolute inset-y-0 end-0 flex w-10 cursor-pointer items-center justify-center text-textmuted transition-colors hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
[disabled]="isDisabled()"
[attr.aria-label]="
passwordVisible()
? 'Hide ' + label()
: 'Show ' + label()
"
(click)="togglePasswordVisibility()"
>
<i
[class]="
passwordVisible()
? 'ri-eye-off-line'
: 'ri-eye-line'
"
class="text-[1rem] leading-none"
aria-hidden="true"
></i>
</button>
} @else if (suffixIcon()) {
<span
class="pointer-events-none absolute inset-y-0 end-0 flex w-10 items-center justify-center text-textmuted"
aria-hidden="true"
>
<i
[class]="
suffixIcon() +
' text-[1rem] leading-none ' +
suffixIconClass()
"
></i>
</span>
}
</div>
</app-form-field>
@@ -0,0 +1,255 @@
import { ChangeDetectionStrategy, Component, Injector, computed, effect, forwardRef, inject, input, signal } from '@angular/core';
import { AbstractControl, ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
import { FormField } from '../form-field/form-field';
import { ValidationMessageMap } from '../form-validation-message/form-validation-message';
export type FormInputType = | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search';
export type FormInputMode = | 'none'| 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
export type FormInputIconPosition = 'left' | 'right';
@Component({
selector: 'app-form-input',
standalone: true,
imports: [FormField],
templateUrl: './form-input.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FormInput),
multi: true
}
]
})
export class FormInput implements ControlValueAccessor {
private readonly injector = inject(Injector);
readonly inputId = input.required<string>();
readonly label = input.required<string>();
readonly name = input<string | null>(null);
readonly type = input<FormInputType>('text');
readonly placeholder = input('');
readonly showPlaceholder = input(true);
readonly useLabelAsPlaceholder = input(true);
readonly required = input(false);
readonly readonly = input(false);
readonly disabled = input(false);
readonly autocomplete = input<string | null>(null);
readonly inputMode = input<FormInputMode | null>(null);
readonly spellcheck = input<boolean | null>(null);
readonly minLength = input<number | null>(null);
readonly maxLength = input<number | null>(null);
readonly pattern = input<string | null>(null);
readonly min = input<number | null>(null);
readonly max = input<number | null>(null);
readonly step = input<number | string | null>(null);
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false);
readonly validationMessages = input<ValidationMessageMap>({});
readonly prefixIcon = input<string | null>(null);
readonly suffixIcon = input<string | null>(null);
readonly prefixIconClass = input('');
readonly suffixIconClass = input('');
readonly showPasswordToggle = input(true);
readonly loading = input(false);
readonly wrapperClass = input('');
readonly fieldContentClass = input('');
readonly labelClass = input('');
readonly inputClass = input('');
readonly ariaLabel = input<string | null>(null);
readonly ariaDescription = input<string | null>(null);
readonly value = signal<string | number | null>(null);
readonly formDisabled = signal(false);
readonly passwordVisible = signal(false);
private onChange: (value: string | number | null) => void = () => {};
private onTouched: () => void = () => {};
constructor() {
effect(() => {
if (this.type() !== 'password') {
this.passwordVisible.set(false);
}
});
}
readonly control = computed<AbstractControl | null>(() => {
return this.injector.get(NgControl, null, {
self: true,
optional: true
})?.control ?? null;
});
readonly isDisabled = computed(() => {
return this.disabled() || this.formDisabled() || this.loading();
});
readonly resolvedPlaceholder = computed(() => {
if (!this.showPlaceholder()) {
return '';
}
const configuredPlaceholder = this.placeholder().trim();
if (configuredPlaceholder) {
return configuredPlaceholder;
}
return this.useLabelAsPlaceholder()
? this.label()
: null;
});
readonly resolvedType = computed<FormInputType>(() => {
if (
this.type() === 'password' &&
this.passwordVisible()
) {
return 'text';
}
return this.type();
});
readonly hasPrefixIcon = computed(() => {
return !!this.prefixIcon();
});
readonly hasSuffixContent = computed(() => {
return (
!!this.suffixIcon() ||
this.loading() ||
(
this.type() === 'password' &&
this.showPasswordToggle()
)
);
});
readonly resolvedInputClass = computed(() => {
return [
'form-control',
this.hasPrefixIcon() ? '!ps-10' : '',
this.hasSuffixContent() ? '!pe-10' : '',
this.control()?.invalid &&
(this.control()?.touched || this.control()?.dirty)
? 'is-invalid'
: '',
this.inputClass()
]
.filter(Boolean)
.join(' ');
});
readonly describedBy = computed(() => {
const ids: string[] = [];
if (this.description()) {
ids.push(`${this.inputId()}-description`);
}
if (this.hint()) {
ids.push(`${this.inputId()}-hint`);
}
if (
this.control()?.invalid &&
(this.control()?.touched || this.control()?.dirty)
) {
ids.push(`${this.inputId()}-validation`);
}
return ids.length ? ids.join(' ') : null;
});
writeValue(value: string | number | null): void {
this.value.set(value ?? null);
}
registerOnChange(
fn: (value: string | number | null) => void
): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.formDisabled.set(isDisabled);
}
onInput(event: Event): void {
const element = event.target as HTMLInputElement;
const nextValue = this.resolveValue(element.value);
this.value.set(nextValue);
this.onChange(nextValue);
}
onBlur(): void {
this.onTouched();
}
togglePasswordVisibility(): void {
if (
this.type() !== 'password' ||
this.isDisabled()
) {
return;
}
this.passwordVisible.update(value => !value);
}
private resolveValue(
rawValue: string
): string | number | null {
if (this.type() !== 'number') {
return rawValue;
}
if (rawValue === '') {
return null;
}
const numericValue = Number(rawValue);
return Number.isNaN(numericValue)
? null
: numericValue;
}
}
@@ -0,0 +1 @@
<p>form-radio works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-radio',
imports: [],
templateUrl: './form-radio.html',
styleUrl: './form-radio.scss',
})
export class FormRadio {
}
@@ -0,0 +1 @@
<p>form-select works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-select',
imports: [],
templateUrl: './form-select.html',
styleUrl: './form-select.scss',
})
export class FormSelect {
}
@@ -0,0 +1 @@
<p>form-textarea works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-textarea',
imports: [],
templateUrl: './form-textarea.html',
styleUrl: './form-textarea.scss',
})
export class FormTextarea {
}
@@ -0,0 +1,14 @@
@if (shouldShow() && message()) {
<p
[class]="customClass()"
role="alert"
aria-live="polite"
>
<i
class="ri-error-warning-line me-1 align-middle"
aria-hidden="true"
></i>
{{ message() }}
</p>
}
@@ -0,0 +1,127 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input
} from '@angular/core';
import { AbstractControl } from '@angular/forms';
export type ValidationMessageMap = Partial<Record<string, string>>;
@Component({
selector: 'app-form-validation-message',
standalone: true,
templateUrl: './form-validation-message.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FormValidationMessage {
readonly control = input<AbstractControl | null>(null);
readonly fieldName = input('Field');
readonly messages = input<ValidationMessageMap>({});
readonly showWhenDirty = input(false);
readonly customClass = input(
'mt-1 text-[0.75rem] text-danger'
);
readonly shouldShow = computed(() => {
const control = this.control();
if (!control || !control.invalid) {
return false;
}
return this.showWhenDirty()
? control.touched || control.dirty
: control.touched;
});
readonly message = computed(() => {
const control = this.control();
if (!control?.errors) {
return '';
}
const errorKey = Object.keys(control.errors)[0];
if (!errorKey) {
return '';
}
const customMessage = this.messages()[errorKey];
if (customMessage) {
return customMessage;
}
return this.resolveDefaultMessage(
errorKey,
control.errors[errorKey]
);
});
private resolveDefaultMessage(
errorKey: string,
errorValue: unknown
): string {
const fieldName = this.fieldName();
switch (errorKey) {
case 'required':
return `${fieldName} is required.`;
case 'email':
return `Enter a valid ${fieldName.toLowerCase()}.`;
case 'minlength': {
const error = errorValue as {
requiredLength?: number;
actualLength?: number;
};
return `${fieldName} must be at least ${
error.requiredLength ?? 0
} characters.`;
}
case 'maxlength': {
const error = errorValue as {
requiredLength?: number;
actualLength?: number;
};
return `${fieldName} cannot exceed ${
error.requiredLength ?? 0
} characters.`;
}
case 'min': {
const error = errorValue as {
min?: number;
actual?: number;
};
return `${fieldName} must be at least ${error.min}.`;
}
case 'max': {
const error = errorValue as {
max?: number;
actual?: number;
};
return `${fieldName} cannot exceed ${error.max}.`;
}
case 'pattern':
return `Enter a valid ${fieldName.toLowerCase()}.`;
default:
return `${fieldName} is invalid.`;
}
}
}
@@ -0,0 +1,57 @@
@if (open()) {
<div
class="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto bg-[#32325180] p-4 dark:bg-[#323251cc]"
(mousedown)="onBackdropClick($event)">
<div class="modal-panel pointer-events-auto relative my-6 w-full" [class.max-w-md]="size() === 'sm'"
[class.max-w-2xl]="size() === 'md'" [class.max-w-4xl]="size() === 'lg'" [class.max-w-6xl]="size() === 'xl'"
[class.max-w-[96vw]]="size() === 'full'" role="dialog" aria-modal="true" [attr.aria-label]="title()"
(mousedown)="$event.stopPropagation()">
<div
class="flex max-h-[90vh] flex-col overflow-hidden rounded-lg border border-defaultborder bg-white shadow-2xl dark:border-defaultborder dark:bg-bodybg">
@if (showHeader()) {
<div
class="flex shrink-0 items-start justify-between gap-4 border-b border-defaultborder px-6 py-4 dark:border-defaultborder">
<div class="min-w-0">
<h6 class="m-0 text-[1.125rem] font-semibold leading-6 text-defaulttextcolor">
{{ title() }}
</h6>
@if (subtitle()) {
<p class="mb-0 mt-1 text-[0.8125rem] leading-5 text-textmuted">
{{ subtitle() }}
</p>
}
</div>
@if (showCloseButton()) {
<button type="button"
class="inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-textmuted transition-colors duration-200 hover:bg-gray-100 hover:text-defaulttextcolor disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-black/20"
aria-label="Close modal" [disabled]="loading()" (click)="close()">
<i class="ri-close-line text-xl leading-none"></i>
</button>
}
</div>
}
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<ng-content />
</div>
@if (showFooter()) {
<div
class="flex shrink-0 items-center justify-end gap-2 border-t border-defaultborder bg-gray-50/50 px-6 py-4 dark:border-defaultborder dark:bg-black/10">
@if (showCancelButton()) {
<app-button action="cancel" [label]="cancelLabel()" [showIcon]="false" [disabled]="loading()"
(buttonClicked)="close()" />
}
@if (showSubmitButton()) {
<app-button [action]="submitAction()" [label]="submitLabel()" [loadingLabel]="loadingLabel()" [showIcon]="false"
[loading]="loading()" [disabled]="submitDisabled()" (buttonClicked)="submit()" />
}
</div>
}
</div>
</div>
</div>
}
@@ -0,0 +1,40 @@
:host {
display: contents;
}
.modal-backdrop {
animation: modalBackdropIn 180ms ease-out;
}
.modal-panel {
animation: modalPanelIn 220ms ease-out;
}
@keyframes modalBackdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalPanelIn {
from {
opacity: 0;
transform: translateY(-18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.modal-backdrop,
.modal-panel {
animation: none;
}
}
+114
View File
@@ -0,0 +1,114 @@
import {ChangeDetectionStrategy, Component, ElementRef, HostListener, OnDestroy, computed, effect,
input, output } from '@angular/core';
import { Button, ButtonAction } from "../button/button";
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
@Component({
selector: 'modal',
standalone: true,
templateUrl: './modal.html',
styleUrl: './modal.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Button]
})
export class Modal implements OnDestroy {
readonly open = input(false);
readonly title = input('');
readonly subtitle = input<string | null>(null);
readonly submitAction = input<ButtonAction>('save');
readonly size = input<ModalSize>('md');
readonly showHeader = input(true);
readonly showFooter = input(true);
readonly showCloseButton = input(true);
readonly showCancelButton = input(true);
readonly showSubmitButton = input(true);
readonly closeOnBackdrop = input(true);
readonly closeOnEscape = input(true);
readonly loading = input(false);
readonly submitDisabled = input(false);
readonly submitLabel = input('Save');
readonly loadingLabel = input('Saving...');
readonly cancelLabel = input('Cancel');
readonly closed = output<void>();
readonly submitted = output<void>();
readonly modalSizeClass = computed(() => {
const sizes: Record<ModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-xl',
lg: 'max-w-3xl',
xl: 'max-w-5xl',
full: 'max-w-[95vw]'
};
return sizes[this.size()];
});
constructor(private readonly elementRef: ElementRef<HTMLElement>) {
effect(() => {
if (this.open()) {
document.body.classList.add('overflow-hidden');
queueMicrotask(() => {
const dialog =
this.elementRef.nativeElement.querySelector<HTMLElement>(
'[role="dialog"]'
);
dialog?.focus();
});
} else {
document.body.classList.remove('overflow-hidden');
}
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (
this.open() &&
this.closeOnEscape() &&
!this.loading()
) {
this.close();
}
}
onBackdropClick(event: MouseEvent): void {
if (
event.target === event.currentTarget &&
this.closeOnBackdrop() &&
!this.loading()
) {
this.close();
}
}
close(): void {
if (this.loading()) {
return;
}
this.closed.emit();
}
submit(): void {
if (this.loading() || this.submitDisabled()) {
return;
}
this.submitted.emit();
}
ngOnDestroy(): void {
document.body.classList.remove('overflow-hidden');
}
}
@@ -0,0 +1,12 @@
import { Directive, TemplateRef, input } from '@angular/core';
import { DataTableCellContext, DataTableRecord } from '../components/data-table/data-table.types';
@Directive({
selector: '[appDataTableCell]',
standalone: true
})
export class DataTableCellDirective<T extends DataTableRecord = DataTableRecord> {
readonly appDataTableCell = input.required<string>();
constructor(public readonly templateRef: TemplateRef<DataTableCellContext<T>>) {}
}
@@ -0,0 +1,50 @@
import { Directive, ElementRef, HostListener, Inject, DOCUMENT } from '@angular/core';
@Directive({
selector: '[appFullscreen]'
})
export class FullscreenDirective {
// For simple code use below code
public fullScreen = false;
public elem: any;
constructor(@Inject(DOCUMENT) private document: any) {}
ngOnInit() {
this.elem = document.documentElement;
}
@HostListener('click')
onClick() {
this.fullScreen = !this.fullScreen;
if (this.fullScreen) {
if (this.elem.requestFullscreen) {
this.elem.requestFullscreen();
} else if (this.elem.mozRequestFullScreen) {
/* Firefox */
this.elem.mozRequestFullScreen();
} else if (this.elem.webkitRequestFullscreen) {
/* Chrome, Safari and Opera */
this.elem.webkitRequestFullscreen();
} else if (this.elem.msRequestFullscreen) {
/* IE/Edge */
this.elem.msRequestFullscreen();
}
} else {
if (!this.document.exitFullscreen) {
this.document.exitFullscreen();
} else if (this.document.mozCancelFullScreen) {
/* Firefox */
this.document.mozCancelFullScreen();
} else if (this.document.webkitExitFullscreen) {
/* Chrome, Safari and Opera */
this.document.webkitExitFullscreen();
} else if (this.document.msExitFullscreen) {
/* IE/Edge */
this.document.msExitFullscreen();
}
}
}
}
@@ -0,0 +1,17 @@
import { Directive, ElementRef, HostListener, Renderer2, inject } from '@angular/core';
@Directive({ selector: '[appHoverEffectSidebar]', })
export class HoverEffectSidebarDirective {
private elementRef = inject(ElementRef);
@HostListener('mouseover') onHover() {
if (window.innerWidth > 768) {
this.elementRef.nativeElement.ownerDocument.documentElement?.setAttribute('icon-overlay', 'open');
}
}
@HostListener('mouseleave') onLeave() {
if (window.innerWidth > 768) {
this.elementRef.nativeElement.ownerDocument.documentElement?.removeAttribute('icon-overlay');
}
}
}
@@ -0,0 +1,27 @@
import { Directive, input, HostBinding } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Directive({
selector: '[appSvgReplace]'
})
export class SvgReplaceDirective {
// Input signal for SVG content
appSvgReplace = input<string>('', { alias: 'appSvgReplace' });
// Bind sanitized SVG content to innerHTML
@HostBinding('innerHTML')
get svgContent(): SafeHtml | string {
const svgContent = this.appSvgReplace();
return svgContent ? this.getSanitizedSVG(svgContent) : '';
}
constructor(private sanitizer: DomSanitizer) {}
private getSanitizedSVG(svgContent: string): SafeHtml {
// Sanitize SVG content to prevent XSS
return this.sanitizer.bypassSecurityTrustHtml(svgContent);
}
}
@@ -0,0 +1,154 @@
import { Directive, ElementRef, HostListener, OnDestroy, inject, input } from '@angular/core';
import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { Tooltip } from './tooltip/tooltip';
export type TooltipPosition =
| 'top'
| 'bottom'
| 'left'
| 'right';
@Directive({
selector: '[appTooltip]',
standalone: true
})
export class TooltipDirective implements OnDestroy {
private readonly elementRef =
inject<ElementRef<HTMLElement>>(ElementRef);
private readonly overlay = inject(Overlay);
readonly appTooltip = input.required<string>();
readonly tooltipPosition =
input<TooltipPosition>('top');
readonly tooltipDisabled = input(false);
readonly tooltipDelay = input(200);
private overlayRef: OverlayRef | null = null;
private showTimeout: ReturnType<typeof setTimeout> | null = null;
@HostListener('mouseenter')
@HostListener('focusin')
show(): void {
if (
this.tooltipDisabled() ||
!this.appTooltip()
) {
return;
}
this.clearTimeout();
this.showTimeout = setTimeout(() => {
this.openTooltip();
}, this.tooltipDelay());
}
@HostListener('mouseleave')
@HostListener('focusout')
hide(): void {
this.clearTimeout();
this.closeTooltip();
}
private openTooltip(): void {
if (this.overlayRef) {
return;
}
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions(this.getPositions());
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.reposition()
});
const portal = new ComponentPortal(Tooltip);
const componentRef = this.overlayRef.attach(portal);
componentRef.setInput(
'text',
this.appTooltip()
);
}
private closeTooltip(): void {
this.overlayRef?.dispose();
this.overlayRef = null;
}
private clearTimeout(): void {
if (!this.showTimeout) {
return;
}
clearTimeout(this.showTimeout);
this.showTimeout = null;
}
private getPositions(): ConnectedPosition[] {
const positions: Record<
TooltipPosition,
ConnectedPosition[]
> = {
top: [
{
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
offsetY: -8
}
],
bottom: [
{
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
offsetY: 8
}
],
left: [
{
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -8
}
],
right: [
{
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: 8
}
]
};
return positions[this.tooltipPosition()];
}
ngOnDestroy(): void {
this.clearTimeout();
this.closeTooltip();
}
}
@@ -0,0 +1,20 @@
<div
class="
pointer-events-none
max-w-[250px]
rounded-md
bg-gray-900
px-2.5
py-1.5
text-[0.75rem]
font-medium
leading-4
text-white
shadow-lg
dark:bg-white
dark:text-gray-900
"
role="tooltip"
>
{{ text() }}
</div>
@@ -0,0 +1,15 @@
import {
ChangeDetectionStrategy,
Component,
input
} from '@angular/core';
@Component({
selector: 'app-tooltip',
standalone: true,
templateUrl: './tooltip.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Tooltip {
readonly text = input.required<string>();
}