add endpoint configuration for city, currency, language, and timezone

This commit is contained in:
Gagan7900
2026-07-15 20:19:46 +05:30
parent 095aed204c
commit be86ab3564
72 changed files with 7874 additions and 471 deletions
@@ -0,0 +1,10 @@
<span
class="inline-flex"
[class.pointer-events-none]="disabled()"
[class.opacity-60]="disabled()"
[attr.aria-disabled]="disabled() ? 'true' : null"
[attr.aria-label]="ariaLabel()"
(click)="open($event)"
>
<ng-content />
</span>
@@ -0,0 +1,62 @@
import {
ChangeDetectionStrategy,
Component,
input,
output
} from '@angular/core';
import Swal, { SweetAlertIcon } from 'sweetalert2';
@Component({
selector: 'app-confirm-dialog',
standalone: true,
templateUrl: './confirm-dialog.html',
styleUrl: './confirm-dialog.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmDialog {
readonly title = input('Are you sure?');
readonly text = input("You won't be able to revert this!");
readonly icon = input<SweetAlertIcon>('warning');
readonly confirmButtonText = input('Yes, delete it!');
readonly cancelButtonText = input('No, cancel!');
readonly confirmButtonColor = input('#1f3f81');
readonly cancelButtonColor = input('#ff007f');
readonly disabled = input(false);
readonly ariaLabel = input('Confirmation dialog trigger');
readonly confirmed = output<void>();
readonly cancelled = output<void>();
async open(event?: Event): Promise<void> {
event?.preventDefault();
if (this.disabled()) {
return;
}
const result = await Swal.fire({
title: this.title(),
text: this.text(),
icon: this.icon(),
showCancelButton: false,
showDenyButton: true,
confirmButtonText: this.confirmButtonText(),
denyButtonText: this.cancelButtonText(),
confirmButtonColor: this.confirmButtonColor(),
denyButtonColor: this.cancelButtonColor(),
customClass: {
confirmButton: 'app-confirm-dialog-btn',
denyButton: 'app-confirm-dialog-btn'
}
});
if (result.isConfirmed) {
this.confirmed.emit();
return;
}
if (result.isDenied) {
this.cancelled.emit();
}
}
}
@@ -8,10 +8,17 @@
</div>
<div class="flex flex-wrap gap-2">
@if(showAddButton()){
<div>
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
(buttonClicked)="onAddClick($event)" />
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
(buttonClicked)="onAddClick($event)" appTooltip="Add Country"/>
</div>
}
<!-- @if (toolbarTemplate(); as toolbar) {
<div>
<ng-container [ngTemplateOutlet]="toolbar.templateRef" />
</div>
} -->
@if (showSearch()) {
<div>
<input #searchInput class="form-control form-control-sm" type="text" [placeholder]="searchPlaceholder()"
@@ -21,6 +28,7 @@
</div>
</div>
<div class="box-body !p-0">
<div class="table-responsive overflow-x-auto overflow-y-visible">
<table [class]="tableClass()">
@@ -48,7 +56,7 @@
<tbody>
@if (loading()) {
<tr class="border-b border-defaultborder">
<td [attr.colspan]="colspan()" class="py-10">
<td [attr.colspan]="totalVisibleColumns()" 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>
@@ -56,9 +64,23 @@
</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>
<tr class="border-b border-defaultborder bg-light/30 dark:bg-black/10">
<td [attr.colspan]="totalVisibleColumns()" class="p-0">
<div class="flex min-h-[140px] flex-col items-center justify-center px-4 py-8 text-center">
<div class="mb-3 inline-flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary dark:bg-primary/15">
<i class="ti ti-database-off text-[1.25rem]" aria-hidden="true"></i>
</div>
<p class="text-sm font-semibold text-defaulttextcolor dark:text-white/80">
{{ emptyMessage() }}
</p>
@if (emptyDescription()) {
<p class="mt-1 max-w-md text-sm text-textmuted">
{{ emptyDescription() }}
</p>
}
</div>
</td>
</tr>
} @else
@@ -6,16 +6,21 @@ 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';
import { contentChild } from '@angular/core';
import { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive';
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet, MatPaginatorModule, CdkOverlayOrigin,
CdkConnectedOverlay, Button, TooltipDirective],
imports: [NgTemplateOutlet,
MatPaginatorModule,
CdkOverlayOrigin,
CdkConnectedOverlay,
Button],
templateUrl: './data-table.html',
styleUrl: './data-table.scss',
standalone: true
@@ -27,6 +32,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
private readonly searchTerms$ = new Subject<string>();
readonly cellTemplates = contentChildren(DataTableCellDirective);
readonly toolbarTemplate = contentChild(DataTableToolbarDirective);
private readonly defaultRowClasses = [
'table-primary',
@@ -92,15 +98,18 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
/*---------------------------*/
tableTitle = input<string>('');
tableTitle = input<string>('');
toolTip = input<string>('');
buttonTitle = input<string>('');
/* --------- Search inputs ---- */
showSearch = input<boolean>(false);
showformSelect = input<boolean>(false);
showAddButton = input<boolean>(false);
searchPlaceholder = input('Search...');
searchDebounceTime = input(300);
emptyMessage = input('No records found');
emptyDescription = input('There is currently no data to display.');
/*---------------------------*/
/* --------- Permission inputs ---- */
@@ -119,7 +128,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
sortDirection = signal<'asc' | 'desc'>('asc');
openActionRowId = signal<string | number | null>(null);
tableClass = input<string>('table table-hover whitespace-nowrap min-w-full');
tableClass = input<string>('table table-bordered 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');
@@ -133,7 +142,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
actionCellClass = input<string>('!text-center');
addClicked = output<void>();
colspan = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
totalVisibleColumns = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
allowedPermissionSet = computed(() => new Set(this.allowedPermissions()));
@@ -383,7 +392,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
}
getHeaderClass(column: DataTableColumn<T>): string {
return this.composeClass(this.defaultThClass(), this.getAlignClass(column.align), column.headerClass);
return this.composeClass(this.defaultThClass(), this.getAlignClass(column.headerAlign), column.headerClass);
}
getCellClass(column: DataTableColumn<T>): string {
@@ -1,4 +1,8 @@
export type DataTableRecord = Record<string, unknown>; /* table row is an object with string keys */
export type DataTableRecord = Record<string, unknown> & {
readonly id?: string | number;
readonly status?: unknown;
readonly isActive?: boolean;
}; /* table row is an object with string keys */
export type DataTableActionType = 'view' | 'edit' | 'delete' | string;
@@ -9,6 +13,7 @@ export interface DataTableColumn<T extends DataTableRecord = DataTableRecord> {
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
headerAlign?: '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 */
@@ -17,7 +22,7 @@ export interface DataTableColumn<T extends DataTableRecord = DataTableRecord> {
cellClass?: string;
}
export interface DataTableAction<T extends DataTableRecord = any> {
export interface DataTableAction<T extends DataTableRecord = DataTableRecord> {
type: DataTableActionType;
label: string;
icon?: string;
@@ -38,7 +43,7 @@ export interface DataTableSortEvent {
direction: 'asc' | 'desc';
}
export interface DataTableActionEvent<T extends DataTableRecord = any> {
export interface DataTableActionEvent<T extends DataTableRecord = DataTableRecord> {
action: DataTableAction<T>;
row: T;
}
@@ -65,4 +70,4 @@ export interface DataTableResult<T> {
total: number;
filtered: number;
rows: T[];
}
}
@@ -0,0 +1,105 @@
<app-form-field
[label]="label()"
[inputId]="resolvedInputId()"
[control]="control()"
[required]="required()"
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[labelPosition]="labelPosition()"
[hideLabel]="hideLabel()"
[hideValidation]="hideValidation()"
[submitAttempted]="submitAttempted()"
[validationMessages]="validationMessages()"
[wrapperClass]="wrapperClass()"
>
<div cdkOverlayOrigin #origin="cdkOverlayOrigin" class="relative w-full">
<input
#textInput
type="text"
role="combobox"
aria-autocomplete="list"
[id]="resolvedInputId()"
[class]="resolvedInputClass()"
[value]="searchText()"
[placeholder]="placeholder()"
[autocomplete]="autocomplete()"
[disabled]="isDisabled()"
[readOnly]="readonly()"
[attr.aria-label]="ariaLabel() || label() || placeholder()"
[attr.aria-expanded]="isOpen()"
[attr.aria-controls]="panelId()"
[attr.aria-activedescendant]="activeDescendant()"
[attr.aria-describedby]="statusId()"
[attr.aria-required]="required()"
[attr.aria-readonly]="readonly()"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
(keydown)="onKeydown($event)"
/>
@if (loading()) {
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2" aria-hidden="true">
<span class="ti-spinner h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></span>
</span>
} @else if (clearable() && (hasSelectedItem() || searchText())) {
<button
type="button"
class="absolute end-3 top-1/2 -translate-y-1/2 text-textmuted hover:text-danger"
aria-label="Clear selection"
[disabled]="isDisabled() || readonly()"
(mousedown)="$event.preventDefault()"
(click)="clear($event)"
>
<i class="ri-close-line" aria-hidden="true"></i>
</button>
}
</div>
<span [id]="statusId()" class="sr-only" aria-live="polite">{{ message() }}</span>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayWidth]="panelWidth()"
[cdkConnectedOverlayViewportMargin]="8"
[cdkConnectedOverlayPush]="true"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
(backdropClick)="close()"
(detach)="close()"
>
<div
[id]="panelId()"
role="listbox"
class="max-h-64 overflow-y-auto rounded-sm border border-defaultborder bg-white py-1 text-defaulttextcolor shadow-lg dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70"
[class]="panelClass()"
>
@if (message()) {
<div class="px-3 py-2 text-[0.8125rem] text-textmuted" [class.text-danger]="error()">
{{ message() }}
</div>
} @else {
@for (item of options(); track optionKey(item, $index); let index = $index) {
<button
type="button"
role="option"
[id]="optionId(index)"
[class]="activeIndex() === index
? 'block w-full px-3 py-2 text-start text-[0.8125rem] bg-light text-primary dark:bg-black/20 dark:text-white/70'
: 'block w-full px-3 py-2 text-start text-[0.8125rem] hover:bg-light dark:hover:bg-black/20'"
[attr.aria-selected]="activeIndex() === index"
(mousedown)="$event.preventDefault()"
(mouseenter)="activeIndex.set(index)"
(click)="select(item)"
>
{{ displayWith()(item) }}
</button>
}
}
</div>
</ng-template>
</app-form-field>
@@ -0,0 +1,289 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { Observable, Subject, of, throwError } from 'rxjs';
import { Autocomplete } from './autocomplete';
import { AutocompleteSearchFn } from './autocomplete.types';
interface LookupItem {
readonly code: string;
readonly title: string;
}
const INDIA: LookupItem = { code: 'IN', title: 'India' };
const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
@Component({
standalone: true,
imports: [ReactiveFormsModule, Autocomplete],
template: `
<app-autocomplete
inputId="country"
label="Country"
[formControl]="control"
[searchFn]="searchFn"
[displayWith]="displayWith"
[valueWith]="valueWith"
[selectedItem]="selectedItem()"
[minSearchLength]="minLength"
[debounceTime]="delay()"
[readonly]="readonly()"
[showDropdownOnFocus]="openOnFocus"
/>
`
})
class HostComponent {
readonly control = new FormControl<string | null>(null);
readonly selectedItem = signal<LookupItem | null>(null);
searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
readonly displayWith = (item: LookupItem): string => item.title;
readonly valueWith = (item: LookupItem): string => item.code;
minLength = 2;
readonly delay = signal(300);
readonly readonly = signal(false);
openOnFocus = false;
}
describe('Autocomplete', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
let component: Autocomplete<LookupItem, string>;
let overlayContainer: OverlayContainer;
const input = (): HTMLInputElement => fixture.nativeElement.querySelector('input');
const type = (value: string): void => {
input().value = value;
input().dispatchEvent(new Event('input'));
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
overlayContainer = TestBed.inject(OverlayContainer);
fixture.detectChanges();
component = fixture.debugElement.children[0].componentInstance;
});
afterEach(() => overlayContainer.ngOnDestroy());
it('initializes with an empty selection', () => {
expect(component.searchText()).toBe('');
expect(component.options()).toEqual([]);
});
it('integrates with a reactive form control', () => {
component.select(INDIA);
expect(host.control.value).toBe('IN');
});
it('does not emit onChange from writeValue', () => {
const change = vi.fn();
component.registerOnChange(change);
component.writeValue('IN');
expect(change).not.toHaveBeenCalled();
});
it('applies a disabled form state', () => {
host.control.disable();
fixture.detectChanges();
expect(input().disabled).toBe(true);
});
it('waits for the configured debounce', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA]));
host.searchFn = search;
fixture.detectChanges();
type('in');
vi.advanceTimersByTime(299);
expect(search).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(search).toHaveBeenCalledWith('in', 10);
vi.useRealTimers();
});
it('does not search below the minimum length', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA]));
host.searchFn = search;
fixture.detectChanges();
type('i');
vi.advanceTimersByTime(300);
expect(search).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('cancels stale search requests', () => {
vi.useFakeTimers();
const first = new Subject<readonly LookupItem[]>();
const second = new Subject<readonly LookupItem[]>();
host.delay.set(0);
host.searchFn = term => term === 'in' ? first : second;
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
type('ind'); vi.advanceTimersByTime(0);
first.next([INDONESIA]);
second.next([INDIA]);
expect(component.options()).toEqual([INDIA]);
vi.useRealTimers();
});
it('resets loading after a successful search', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
expect(component.loading()).toBe(false);
vi.useRealTimers();
});
it('resets loading after an error', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => throwError(() => new Error('server'));
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
expect(component.loading()).toBe(false);
expect(component.error()).toBe('Unable to load results');
vi.useRealTimers();
});
it('recovers on a later search after an error', () => {
vi.useFakeTimers();
let attempts = 0;
host.delay.set(0);
host.searchFn = (): Observable<readonly LookupItem[]> =>
++attempts === 1 ? throwError(() => new Error('server')) : of([INDIA]);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0);
type('ind'); vi.advanceTimersByTime(0);
expect(component.options()).toEqual([INDIA]);
expect(component.error()).toBeNull();
vi.useRealTimers();
});
it('renders returned options', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('India');
vi.useRealTimers();
});
it('selects an option with the mouse', () => {
vi.useFakeTimers();
host.delay.set(0);
fixture.detectChanges();
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
overlayContainer.getContainerElement().querySelector<HTMLButtonElement>('[role="option"]')?.click();
expect(host.control.value).toBe('IN');
vi.useRealTimers();
});
it('selects the highlighted option with Enter', () => {
component.options.set([INDIA]);
component.open();
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(host.control.value).toBe('IN');
});
it('only highlights on arrow navigation', () => {
component.options.set([INDIA]);
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(component.activeIndex()).toBe(0);
expect(host.control.value).toBeNull();
});
it('closes on Escape', () => {
component.open();
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(component.isOpen()).toBe(false);
});
it('clears the form value to null', () => {
component.select(INDIA);
component.clear();
expect(host.control.value).toBeNull();
});
it('clears visible state when the form resets', () => {
component.select(INDIA);
host.control.reset();
fixture.detectChanges();
expect(component.searchText()).toBe('');
expect(component.activeItem()).toBeNull();
});
it('displays an existing selected item', () => {
host.selectedItem.set(INDIA);
host.control.setValue('IN');
fixture.detectChanges();
expect(component.searchText()).toBe('India');
});
it('does not retain a stale label when edit values change', () => {
host.selectedItem.set(INDIA);
host.control.setValue('IN');
fixture.detectChanges();
host.control.setValue('ID');
fixture.detectChanges();
expect(component.searchText()).toBe('');
});
it('closes when the overlay backdrop is clicked', () => {
component.open();
fixture.detectChanges();
overlayContainer.getContainerElement().querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
expect(component.isOpen()).toBe(false);
});
it('prevents modification in read-only mode', () => {
host.readonly.set(true);
fixture.detectChanges();
component.select(INDIA);
expect(host.control.value).toBeNull();
});
it('prevents interaction while disabled', () => {
host.control.disable();
fixture.detectChanges();
component.open();
expect(component.isOpen()).toBe(false);
});
it('updates combobox ARIA state', () => {
component.open();
fixture.detectChanges();
expect(input().getAttribute('role')).toBe('combobox');
expect(input().getAttribute('aria-expanded')).toBe('true');
expect(input().getAttribute('aria-controls')).toBe('country-listbox');
});
it('renders the empty state', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => of([]);
fixture.detectChanges();
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('No results found');
vi.useRealTimers();
});
it('renders the friendly error state', () => {
vi.useFakeTimers();
host.delay.set(0);
host.searchFn = () => throwError(() => new Error('raw error'));
fixture.detectChanges();
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
const text = overlayContainer.getContainerElement().textContent ?? '';
expect(text).toContain('Unable to load results');
expect(text).not.toContain('raw error');
vi.useRealTimers();
});
});
@@ -0,0 +1,338 @@
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Injector,
ViewChild,
computed,
effect,
forwardRef,
inject,
input,
output,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AbstractControl, ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
import {
Subject,
catchError,
debounce,
distinctUntilChanged,
map,
of,
switchMap,
timer
} from 'rxjs';
import { FormField, FormLabelPosition } from '../form-field/form-field';
import { ValidationMessageMap } from '../form-validation-message/form-validation-message';
import {
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteTrackFn,
AutocompleteValueFn
} from './autocomplete.types';
interface SearchResult<TItem> {
readonly term: string;
readonly options: readonly TItem[];
readonly failed: boolean;
}
@Component({
selector: 'app-autocomplete',
standalone: true,
imports: [CdkConnectedOverlay, CdkOverlayOrigin, FormField],
templateUrl: './autocomplete.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Autocomplete),
multi: true
}]
})
export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
private static nextId = 0;
private readonly injector = inject(Injector);
private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`;
private readonly inputTerms$ = new Subject<string>();
private readonly valuesToResolve$ = new Subject<TValue>();
private formValue: TValue | null = null;
private labelEdited = false;
private onChange: (value: TValue | null) => void = () => {};
private onTouched: () => void = () => {};
@ViewChild('textInput') private textInput?: ElementRef<HTMLInputElement>;
@ViewChild(CdkOverlayOrigin, { read: ElementRef }) private origin?: ElementRef<HTMLElement>;
readonly searchFn = input.required<AutocompleteSearchFn<TItem>>();
readonly displayWith = input.required<AutocompleteDisplayFn<TItem>>();
readonly valueWith = input.required<AutocompleteValueFn<TItem, TValue>>();
readonly trackBy = input<AutocompleteTrackFn<TItem> | null>(null);
readonly selectedItem = input<TItem | null>(null);
readonly resolveValueFn = input<AutocompleteResolveValueFn<TItem, TValue> | null>(null);
readonly label = input('');
readonly inputId = input<string | null>(null);
readonly placeholder = input('Search...');
readonly minSearchLength = input(1);
readonly debounceTime = input(300);
readonly limit = input(10);
readonly disabled = input(false);
readonly readonly = input(false);
readonly clearable = input(true);
readonly required = input(false);
readonly loadingText = input('Loading...');
readonly emptyText = input('No results found');
readonly typeToSearchText = input('Type to search');
readonly errorText = input('Unable to load results');
readonly showDropdownOnFocus = input(false);
readonly closeOnSelect = input(true);
readonly autocomplete = input('off');
readonly ariaLabel = input<string | null>(null);
readonly panelClass = input('');
readonly inputClass = input('');
readonly wrapperClass = input('');
readonly hideLabel = input(false);
readonly hideValidation = input(false);
readonly submitAttempted = input(false);
readonly validationMessages = input<ValidationMessageMap>({});
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top');
readonly itemSelected = output<TItem>();
readonly cleared = output<void>();
readonly searchChanged = output<string>();
readonly opened = output<void>();
readonly closed = output<void>();
readonly loadError = output<void>();
readonly isOpen = signal(false);
readonly loading = signal(false);
readonly options = signal<readonly TItem[]>([]);
readonly activeIndex = signal(-1);
readonly activeItem = signal<TItem | null>(null);
readonly searchText = signal('');
readonly error = signal<string | null>(null);
readonly formDisabled = signal(false);
readonly panelWidth = signal(0);
readonly resolvedInputId = computed(() => this.inputId()?.trim() || this.generatedId);
readonly panelId = computed(() => `${this.resolvedInputId()}-listbox`);
readonly statusId = computed(() => `${this.resolvedInputId()}-status`);
readonly control = computed<AbstractControl | null>(() =>
this.injector.get(NgControl, null, { self: true, optional: true })?.control ?? null
);
readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
readonly hasSelectedItem = computed(() => this.activeItem() !== null);
readonly activeDescendant = computed(() => {
const index = this.activeIndex();
return this.isOpen() && index >= 0 ? `${this.resolvedInputId()}-option-${index}` : null;
});
readonly message = computed(() => {
if (this.loading()) return this.loadingText();
if (this.error()) return this.errorText();
if (this.searchText().trim().length < this.minSearchLength()) {
return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`;
}
return this.options().length ? '' : this.emptyText();
});
readonly resolvedInputClass = computed(() => {
const control = this.control();
const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted()));
return [
'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor',
'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70',
'focus:border-primary focus:ring-1 focus:ring-primary',
'pe-16',
invalid ? 'is-invalid border-danger' : '',
this.isDisabled() ? 'cursor-not-allowed opacity-60' : '',
this.inputClass()
].filter(Boolean).join(' ');
});
readonly overlayPositions: ConnectedPosition[] = [
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }
];
constructor() {
this.inputTerms$.pipe(
map(term => term.trim()),
debounce(() => timer(Math.max(0, this.debounceTime()))),
distinctUntilChanged(),
switchMap(term => {
if (term.length < this.minSearchLength()) {
return of<SearchResult<TItem>>({ term, options: [], failed: false });
}
this.loading.set(true);
this.error.set(null);
return this.searchFn()(term, this.limit()).pipe(
map(options => ({ term, options, failed: false })),
catchError(() => of<SearchResult<TItem>>({ term, options: [], failed: true }))
);
}),
takeUntilDestroyed()
).subscribe(result => {
this.loading.set(false);
this.options.set(result.options);
this.activeIndex.set(-1);
this.error.set(result.failed ? this.errorText() : null);
if (result.failed) this.loadError.emit();
});
this.valuesToResolve$.pipe(
switchMap(value => this.resolveValueFn()?.(value) ?? of(null)),
takeUntilDestroyed()
).subscribe(item => this.applyResolvedItem(item));
effect(() => {
const item = this.selectedItem();
if (item === null) {
if (this.formValue === null) this.applyResolvedItem(null);
return;
}
if (this.valuesEqual(this.valueWith()(item), this.formValue)) this.applyResolvedItem(item);
});
}
writeValue(value: TValue | null): void {
this.formValue = value ?? null;
this.labelEdited = false;
if (this.formValue === null) {
this.applyResolvedItem(null);
return;
}
const supplied = this.selectedItem();
if (supplied !== null && this.valuesEqual(this.valueWith()(supplied), this.formValue)) {
this.applyResolvedItem(supplied);
} else {
this.applyResolvedItem(null);
if (this.resolveValueFn()) this.valuesToResolve$.next(this.formValue);
}
}
registerOnChange(fn: (value: TValue | null) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(disabled: boolean): void {
this.formDisabled.set(disabled);
if (disabled) this.close();
}
onInput(event: Event): void {
if (!(event.target instanceof HTMLInputElement) || this.isDisabled() || this.readonly()) return;
const text = event.target.value;
const previousText = this.searchText();
this.searchText.set(text);
this.labelEdited = this.formValue !== null && text !== previousText;
this.searchChanged.emit(text.trim());
this.open();
this.inputTerms$.next(text);
}
onFocus(): void {
if (this.showDropdownOnFocus()) {
this.open();
this.inputTerms$.next(this.searchText());
}
}
onBlur(): void {
queueMicrotask(() => {
this.onTouched();
if (this.labelEdited) this.clearValue(false, false);
});
}
onKeydown(event: KeyboardEvent): void {
if (this.isDisabled() || this.readonly()) return;
const options = this.options();
switch (event.key) {
case 'ArrowDown':
event.preventDefault(); this.open(); this.setActive(Math.min(this.activeIndex() + 1, options.length - 1)); break;
case 'ArrowUp':
event.preventDefault(); this.open(); this.setActive(Math.max(this.activeIndex() - 1, 0)); break;
case 'Home':
if (this.isOpen() && options.length) { event.preventDefault(); this.setActive(0); } break;
case 'End':
if (this.isOpen() && options.length) { event.preventDefault(); this.setActive(options.length - 1); } break;
case 'Enter': {
const item = options[this.activeIndex()];
if (this.isOpen() && item !== undefined) { event.preventDefault(); this.select(item); }
break;
}
case 'Escape':
if (this.isOpen()) { event.preventDefault(); this.close(); } break;
}
}
select(item: TItem): void {
if (this.isDisabled() || this.readonly()) return;
this.activeItem.set(item);
this.formValue = this.valueWith()(item);
this.searchText.set(this.displayWith()(item));
this.labelEdited = false;
this.onChange(this.formValue);
this.onTouched();
this.itemSelected.emit(item);
if (this.closeOnSelect()) this.close();
}
clear(event?: MouseEvent): void {
event?.preventDefault();
event?.stopPropagation();
if (this.isDisabled() || this.readonly()) return;
this.clearValue(true, true);
}
open(): void {
if (this.isDisabled() || this.readonly() || this.isOpen()) return;
this.panelWidth.set(this.origin?.nativeElement.getBoundingClientRect().width ?? 0);
this.isOpen.set(true);
this.opened.emit();
}
close(): void {
if (!this.isOpen()) return;
this.isOpen.set(false);
this.activeIndex.set(-1);
this.closed.emit();
}
optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; }
optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; }
private setActive(index: number): void {
if (index < 0 || index >= this.options().length) return;
this.activeIndex.set(index);
queueMicrotask(() => document.getElementById(this.optionId(index))?.scrollIntoView({ block: 'nearest' }));
}
private clearValue(emitCleared: boolean, restoreFocus: boolean): void {
this.formValue = null;
this.activeItem.set(null);
this.searchText.set('');
this.options.set([]);
this.error.set(null);
this.loading.set(false);
this.labelEdited = false;
this.onChange(null);
this.onTouched();
if (emitCleared) this.cleared.emit();
this.close();
if (restoreFocus) queueMicrotask(() => this.textInput?.nativeElement.focus());
}
private applyResolvedItem(item: TItem | null, clearText = true): void {
this.activeItem.set(item);
if (item !== null) this.searchText.set(this.displayWith()(item));
else if (clearText) this.searchText.set('');
this.labelEdited = false;
}
private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); }
}
@@ -0,0 +1,16 @@
import { Observable } from 'rxjs';
export type AutocompleteSearchFn<TItem> = (
term: string,
limit: number
) => Observable<readonly TItem[]>;
export type AutocompleteDisplayFn<TItem> = (item: TItem) => string;
export type AutocompleteValueFn<TItem, TValue> = (item: TItem) => TValue;
export type AutocompleteTrackFn<TItem> = (item: TItem) => string | number;
export type AutocompleteResolveValueFn<TItem, TValue> = (
value: TValue
) => Observable<TItem | null>;
@@ -42,4 +42,4 @@
/>
}
</div>
</div>
</div>
@@ -2,7 +2,9 @@ import {
ChangeDetectionStrategy,
Component,
computed,
input
effect,
input,
signal
} from '@angular/core';
import { AbstractControl } from '@angular/forms';
@@ -43,6 +45,8 @@ export class FormField {
readonly showValidationWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly validationMessages =
input<ValidationMessageMap>({});
@@ -64,6 +68,36 @@ export class FormField {
'ms-0.5 text-danger'
);
private readonly controlStateVersion = signal(0);
constructor() {
effect((onCleanup) => {
const control = this.control();
if (!control) {
return;
}
const statusSubscription = control.statusChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
const valueSubscription = control.valueChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
const eventsSubscription = control.events?.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
onCleanup(() => {
statusSubscription.unsubscribe();
valueSubscription.unsubscribe();
eventsSubscription?.unsubscribe();
});
});
}
readonly validationId = computed(
() => `${this.inputId()}-validation`
);
@@ -77,17 +111,19 @@ export class FormField {
);
readonly hasVisibleError = computed(() => {
this.controlStateVersion();
const control = this.control();
if (!control?.invalid) {
return false;
}
if (this.showValidationWhenDirty()) {
return control.touched || control.dirty;
}
return control.touched;
return (
control.touched ||
control.dirty ||
this.submitAttempted()
);
});
readonly resolvedWrapperClass = computed(() => {
@@ -138,4 +174,4 @@ export class FormField {
readonly showHint = computed(() => {
return !!this.hint() && !this.hasVisibleError();
});
}
}
@@ -8,6 +8,7 @@
[hint]="hint()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()"
[validationMessages]="validationMessages()"
[wrapperClass]="wrapperClass()"
[labelClass]="labelClass()"
@@ -112,4 +113,4 @@
</span>
}
</div>
</app-form-field>
</app-form-field>
@@ -65,6 +65,7 @@ export class FormInput implements ControlValueAccessor {
readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly validationMessages = input<ValidationMessageMap>({});
@@ -91,6 +92,7 @@ export class FormInput implements ControlValueAccessor {
readonly value = signal<string | number | null>(null);
readonly formDisabled = signal(false);
readonly passwordVisible = signal(false);
private readonly controlStateVersion = signal(0);
private onChange: (value: string | number | null) => void = () => {};
@@ -102,6 +104,32 @@ export class FormInput implements ControlValueAccessor {
this.passwordVisible.set(false);
}
});
effect((onCleanup) => {
const control = this.control();
if (!control) {
return;
}
const statusSubscription = control.statusChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
const valueSubscription = control.valueChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
const eventsSubscription = control.events?.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
onCleanup(() => {
statusSubscription.unsubscribe();
valueSubscription.unsubscribe();
eventsSubscription?.unsubscribe();
});
});
}
readonly control = computed<AbstractControl | null>(() => {
@@ -158,12 +186,23 @@ export class FormInput implements ControlValueAccessor {
});
readonly resolvedInputClass = computed(() => {
this.controlStateVersion();
const control = this.control();
const showInvalidState = !!(
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
);
return [
'form-control',
this.hasPrefixIcon() ? '!ps-10' : '',
this.hasSuffixContent() ? '!pe-10' : '',
this.control()?.invalid &&
(this.control()?.touched || this.control()?.dirty)
showInvalidState
? 'is-invalid'
: '',
this.inputClass()
@@ -173,7 +212,10 @@ export class FormInput implements ControlValueAccessor {
});
readonly describedBy = computed(() => {
this.controlStateVersion();
const ids: string[] = [];
const control = this.control();
if (this.description()) {
ids.push(`${this.inputId()}-description`);
@@ -184,8 +226,12 @@ export class FormInput implements ControlValueAccessor {
}
if (
this.control()?.invalid &&
(this.control()?.touched || this.control()?.dirty)
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
) {
ids.push(`${this.inputId()}-validation`);
}
@@ -252,4 +298,4 @@ export class FormInput implements ControlValueAccessor {
? null
: numericValue;
}
}
}
@@ -1 +1,223 @@
<p>form-select works!</p>
<app-form-field
[label]="label()"
[inputId]="resolvedInputId()"
[control]="control()"
[required]="required()"
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[labelPosition]="labelPosition()"
[hideLabel]="hideLabel()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()"
[validationMessages]="validationMessages()"
[wrapperClass]="wrapperClass()"
[labelClass]="labelClass()"
[contentClass]="fieldContentClass()"
>
<ng-select
#select
bindLabel="label"
bindValue="value"
appearance="outline"
[items]="selectItems()"
[ngModel]="modelValue()"
[multiple]="isMultiple()"
[searchable]="false"
[editableSearchTerm]="resolvedEditableSearchTerm()"
[clearable]="clearable()"
[hideSelected]="hideSelected()"
[closeOnSelect]="resolvedCloseOnSelect()"
[maxSelectedItems]="maxSelectedItems()"
[placeholder]="resolvedPlaceholder()"
[loading]="loading()"
[loadingText]="loadingText()"
[notFoundText]="notFoundText()"
[typeToSearchText]="typeToSearchText()"
[clearAllText]="clearAllText()"
[appendTo]="appendTo()"
[dropdownPosition]="dropdownPosition()"
[virtualScroll]="virtualScroll()"
[bufferAmount]="bufferAmount()"
[groupBy]="groupBy()"
[selectableGroup]="selectableGroup()"
[selectableGroupAsModel]="selectableGroupAsModel()"
[selectOnTab]="selectOnTab()"
[clearOnBackspace]="clearOnBackspace()"
[readonly]="resolvedReadonly()"
[labelForId]="resolvedInputId()"
[inputAttrs]="inputAttrs()"
[ariaLabel]="ariaLabel() || label() || resolvedPlaceholder()"
[ngClass]="resolvedSelectClass()"
(ngModelChange)="onValueChange($event)"
(open)="onOpen()"
(close)="onClose()"
(focus)="onFocus()"
(blur)="onBlur()"
(clear)="onClear()"
(scroll)="onScroll($event)"
(scrollToEnd)="onScrollToEnd()"
>
@if (showDropdownHeader()) {
<ng-template ng-header-tmp>
@if (searchable()) {
<div class="w-full">
<input
type="search"
class="form-control form-control-sm w-full rounded-sm border-defaultborder text-defaulttextcolor dark:border-defaultborder/10 dark:text-white/70"
[value]="searchTerm()"
[placeholder]="resolvedSearchPlaceholder()"
[disabled]="isDisabled()"
autocomplete="off"
(input)="onSearchInput($event)"
(keydown)="$event.stopPropagation()"
/>
</div>
}
@if (isMultiple() && showSelectAll()) {
<button
type="button"
class="flex w-full items-center gap-2 border-b border-defaultborder px-3 py-2 text-start text-[0.8125rem] text-defaulttextcolor hover:bg-light dark:border-defaultborder/10 dark:text-white/70 dark:hover:bg-black/20"
[class.text-primary]="allSelected() || partiallySelected()"
[disabled]="isDisabled()"
(click)="toggleSelectAll()"
>
@if (showCheckboxes()) {
<span
class="flex size-4 shrink-0 items-center justify-center rounded-sm border border-defaultborder bg-white text-[0.625rem] dark:border-defaultborder/10 dark:bg-bodybg"
[class.bg-primary]="allSelected() || partiallySelected()"
[class.text-white]="allSelected() || partiallySelected()"
aria-hidden="true"
>
@if (allSelected()) {
<i class="ri-check-line leading-none"></i>
} @else if (partiallySelected()) {
<i class="ri-subtract-line leading-none"></i>
}
</span>
}
<span class="min-w-0 flex-1 truncate">
{{ selectAllLabel() }}
</span>
</button>
}
</ng-template>
}
<ng-template ng-option-tmp let-item="item" let-item$="item$">
<div [class]="getOptionContainerClass(item)">
@if (isMultiple() && showCheckboxes()) {
<span
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-sm border border-defaultborder bg-white text-[0.625rem] dark:border-defaultborder/10 dark:bg-bodybg"
[class.bg-primary]="isOptionSelected(item)"
[class.text-white]="isOptionSelected(item)"
aria-hidden="true"
>
@if (isOptionSelected(item)) {
<i class="ri-check-line leading-none"></i>
}
</span>
}
@if (item.prefixText) {
<span [class]="getOptionPrefixClass(item)">
{{ item.prefixText }}
</span>
}
<span class="min-w-0 flex-1">
<span [class]="getOptionLabelClass(item)">
{{ getOptionDisplayLabel(item, item$.label) }}
</span>
@if (item.description) {
<span class="mt-0.5 block truncate text-[0.75rem] text-inherit opacity-80">
{{ item.description }}
</span>
}
</span>
</div>
</ng-template>
<ng-template ng-label-tmp let-item="item" let-clear="clear" let-label="label">
<span class="inline-flex min-w-0 items-center gap-1">
@if (getSelectionPrefixText(item)) {
<span class="shrink-0 text-primary">
{{ getSelectionPrefixText(item) }}
</span>
}
<span class="truncate">
{{ getSelectionDisplayLabel(item, label) }}
</span>
@if (isMultiple() && clearable() && !resolvedReadonly()) {
<button
type="button"
class="ms-1 inline-flex text-white/80 hover:text-white"
aria-label="Remove selected option"
(click)="clear(item)"
>
<i class="ri-close-line leading-none"></i>
</button>
}
</span>
</ng-template>
@if (isMultiple()) {
<ng-template ng-multi-label-tmp let-items="items" let-clear="clear">
<div class="flex min-w-0 items-center gap-1">
@if (items.length > 0) {
<span class="inline-flex max-w-full items-center gap-1 rounded-sm bg-primary px-2 py-0.5 text-[0.75rem] text-white">
<span class="truncate">
{{ getMultiLabelText(items) }}
</span>
@if (clearable() && !resolvedReadonly()) {
<button
type="button"
class="inline-flex text-white/80 hover:text-white"
aria-label="Remove selected option"
(click)="clear(items[0])"
>
<i class="ri-close-line leading-none"></i>
</button>
}
</span>
}
</div>
</ng-template>
}
@if (isMultiple() && showMultiSelectFooter()) {
<ng-template ng-footer-tmp>
<div class="flex items-center justify-between gap-2 border-t border-defaultborder px-3 py-2 dark:border-defaultborder/10">
<span class="text-[0.75rem] text-textmuted">
{{ pendingValue().length }} selected
</span>
<span class="inline-flex items-center gap-2">
<button
type="button"
class="ti-btn ti-btn-light !px-3 !py-1.5 !text-[0.75rem]"
(click)="cancelSelection(select)"
>
{{ cancelLabel() }}
</button>
<button
type="button"
class="ti-btn ti-btn-primary-full !px-3 !py-1.5 !text-[0.75rem]"
(click)="confirmSelection(select)"
>
{{ confirmLabel() }}
</button>
</span>
</div>
</ng-template>
}
</ng-select>
</app-form-field>
@@ -1,11 +1,723 @@
import { Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
Injector,
computed,
forwardRef,
inject,
input,
output,
signal
} from '@angular/core';
import { AbstractControl, ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, NgControl } from '@angular/forms';
import {
NgFooterTemplateDirective,
NgHeaderTemplateDirective,
NgLabelTemplateDirective,
NgMultiLabelTemplateDirective,
NgOptionTemplateDirective,
NgSelectComponent
} from '@ng-select/ng-select';
import { FormField, FormLabelPosition } from '../form-field/form-field';
import { ValidationMessageMap } from '../form-validation-message/form-validation-message';
import {
FormSelectDropdownPosition,
FormSelectMode,
FormSelectOption,
FormSelectPrimitive,
FormSelectScrollEvent,
FormSelectSearchEvent,
FormSelectSearchMode,
FormSelectValue
} from '../models/form-select.models';
@Component({
selector: 'form-select',
imports: [],
selector: 'app-form-select',
standalone: true,
imports: [
FormsModule,
FormField,
NgFooterTemplateDirective,
NgHeaderTemplateDirective,
NgLabelTemplateDirective,
NgMultiLabelTemplateDirective,
NgOptionTemplateDirective,
NgSelectComponent
],
templateUrl: './form-select.html',
styleUrl: './form-select.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FormSelect),
multi: true
}
]
})
export class FormSelect {
export class FormSelect<TValue extends FormSelectPrimitive = string> implements ControlValueAccessor {
private static nextId = 0;
private readonly injector = inject(Injector);
private readonly generatedInputId = `form-select-${FormSelect.nextId++}`;
readonly inputId = input<string | null>(null);
readonly label = input('');
readonly name = input<string | null>(null);
readonly options = input<readonly FormSelectOption<TValue>[]>([]);
readonly mode = input<FormSelectMode>('single');
readonly searchable = input(true);
readonly searchMode = input<FormSelectSearchMode>('client');
readonly editableSearchTerm = input<boolean | null>(null);
readonly clearable = input(true);
readonly hideSelected = input(false);
readonly closeOnSelect = input<boolean | null>(null);
readonly maxSelectedItems = input<number | null>(null);
readonly placeholder = input('');
readonly required = input(false);
readonly disabled = input(false);
readonly readonly = input(false);
readonly loading = input(false);
readonly loadingText = input('Loading...');
readonly notFoundText = input('No options found');
readonly typeToSearchText = input('Type to search');
readonly clearAllText = input('Clear all');
readonly appendTo = input('');
readonly dropdownPosition = input<FormSelectDropdownPosition>('auto');
readonly virtualScroll = input(false);
readonly bufferAmount = input(4);
readonly groupBy = input('');
readonly selectableGroup = input(false);
readonly selectableGroupAsModel = input(false);
readonly selectOnTab = input(true);
readonly clearOnBackspace = input(true);
readonly showCheckboxes = input(true);
readonly showSelectAll = input(true);
readonly showMultiSelectFooter = input(false);
readonly confirmLabel = input('OK');
readonly cancelLabel = input('Cancel');
readonly selectAllLabel = input('Select All');
readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly validationMessages = input<ValidationMessageMap>({});
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top');
readonly hideLabel = input(false);
readonly wrapperClass = input('');
readonly labelClass = input('');
readonly fieldContentClass = input('');
readonly selectClass = input('');
readonly ariaLabel = input<string | null>(null);
readonly ariaDescription = input<string | null>(null);
readonly selectionChanged = output<FormSelectValue<TValue>>();
readonly searchChanged = output<FormSelectSearchEvent>();
readonly opened = output<void>();
readonly closed = output<void>();
readonly cleared = output<void>();
readonly focused = output<void>();
readonly blurred = output<void>();
readonly scrolled = output<FormSelectScrollEvent>();
readonly scrolledToEnd = output<void>();
readonly value = signal<FormSelectValue<TValue>>(null);
readonly pendingValue = signal<readonly TValue[]>([]);
readonly formDisabled = signal(false);
readonly dropdownOpen = signal(false);
readonly searchTerm = signal('');
private onChange: (value: FormSelectValue<TValue>) => void = () => {};
private onTouched: () => void = () => {};
readonly control = computed<AbstractControl | null>(() => {
return this.injector.get(NgControl, null, {
self: true,
optional: true
})?.control ?? null;
});
readonly resolvedInputId = computed(() =>
this.inputId()?.trim() || this.generatedInputId
);
readonly resolvedName = computed(() =>
this.name()?.trim() || this.resolvedInputId()
);
readonly isMultiple = computed(() => this.mode() === 'multiple');
readonly isDisabled = computed(() =>
this.disabled() ||
this.formDisabled() ||
this.loading()
);
readonly resolvedReadonly = computed(() =>
this.readonly() ||
this.isDisabled()
);
readonly resolvedCloseOnSelect = computed(() =>
this.closeOnSelect() ?? !this.isMultiple()
);
readonly resolvedEditableSearchTerm = computed(() =>
this.editableSearchTerm() ?? false
);
readonly resolvedOptions = computed<readonly FormSelectOption<TValue>[]>(() => {
const options = this.options();
const term = this.searchTerm().trim().toLocaleLowerCase();
if (
!this.searchable() ||
this.searchMode() === 'server' ||
!term
) {
return options;
}
return options.filter(option =>
this.optionMatchesSearchTerm(option, term)
);
});
readonly selectItems = computed<readonly FormSelectOption<TValue>[]>(() => {
const selected = new Set(this.activeSelectedValues());
const selectedOptions = this.options()
.filter(option => selected.has(option.value));
const optionValues = new Set(selectedOptions.map(option => option.value));
const visibleOptions = this.resolvedOptions()
.filter(option => !optionValues.has(option.value));
return [
...selectedOptions,
...visibleOptions
];
});
readonly showDropdownHeader = computed(() =>
this.searchable() ||
(
this.isMultiple() &&
this.showSelectAll()
)
);
readonly selectedValues = computed<readonly TValue[]>(() => {
const currentValue = this.value();
return Array.isArray(currentValue)
? currentValue
: [];
});
readonly modelValue = computed<FormSelectValue<TValue>>(() => {
if (
this.isMultiple() &&
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
return this.pendingValue();
}
return this.value();
});
readonly allEnabledValues = computed<readonly TValue[]>(() =>
this.options()
.filter(option => !option.disabled)
.map(option => option.value)
);
readonly allSelected = computed(() => {
const selected = new Set(
this.showMultiSelectFooter()
? this.pendingValue()
: this.selectedValues()
);
const enabledValues = this.allEnabledValues();
return (
enabledValues.length > 0 &&
enabledValues.every(value => selected.has(value))
);
});
readonly partiallySelected = computed(() => {
const selected = new Set(
this.showMultiSelectFooter()
? this.pendingValue()
: this.selectedValues()
);
const selectedCount = this.allEnabledValues()
.filter(value => selected.has(value))
.length;
return (
selectedCount > 0 &&
selectedCount < this.allEnabledValues().length
);
});
readonly selectedCount = computed(() =>
this.selectedValues().length
);
readonly resolvedPlaceholder = computed(() =>
this.placeholder().trim() ||
(
this.label().trim()
? `Select ${this.label().trim()}`
: 'Select'
)
);
readonly resolvedSearchPlaceholder = computed(() =>
this.placeholder().trim() ||
(
this.label().trim()
? `Search ${this.label().trim()}`
: 'Search'
)
);
readonly describedBy = computed(() => {
const ids: string[] = [];
const control = this.control();
if (this.description()) {
ids.push(`${this.resolvedInputId()}-description`);
}
if (this.hint()) {
ids.push(`${this.resolvedInputId()}-hint`);
}
if (
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
) {
ids.push(`${this.resolvedInputId()}-validation`);
}
return ids.length ? ids.join(' ') : null;
});
readonly inputAttrs = computed<Record<string, string>>(() => {
const attrs: Record<string, string> = {
name: this.resolvedName()
};
const describedBy = this.describedBy();
const ariaDescription = this.ariaDescription();
if (describedBy) {
attrs['aria-describedby'] = describedBy;
}
if (ariaDescription) {
attrs['aria-description'] = ariaDescription;
}
if (this.required()) {
attrs['aria-required'] = 'true';
}
return attrs;
});
readonly resolvedSelectClass = computed(() => {
const control = this.control();
const showInvalidState = !!(
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
)
);
return [
'ti-form-select',
'rounded-sm',
'border-defaultborder',
'text-defaulttextcolor',
'dark:border-defaultborder/10',
'dark:text-white/70',
'w-full',
'app-form-select-control',
showInvalidState ? 'is-invalid' : '',
this.isDisabled() ? 'opacity-60 pointer-events-none' : '',
this.selectClass()
]
.filter(Boolean)
.join(' ');
});
writeValue(value: FormSelectValue<TValue>): void {
this.value.set(this.normalizeValue(value));
}
registerOnChange(fn: (value: FormSelectValue<TValue>) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(disabled: boolean): void {
this.formDisabled.set(disabled);
}
onValueChange(incomingValue: FormSelectValue<TValue>): void {
const normalizedValue = this.normalizeValue(incomingValue);
if (
this.isMultiple() &&
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
this.pendingValue.set(
Array.isArray(normalizedValue)
? normalizedValue
: []
);
return;
}
this.commitValue(normalizedValue);
}
onSearchInput(event: Event): void {
const inputElement = event.target instanceof HTMLInputElement
? event.target
: null;
const term = inputElement?.value ?? '';
this.searchTerm.set(term);
this.searchChanged.emit({
term: term.trim()
});
}
toggleSelectAll(): void {
if (!this.isMultiple() || this.isDisabled()) {
return;
}
const nextValue = this.allSelected()
? []
: [...this.allEnabledValues()];
if (
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
this.pendingValue.set(nextValue);
return;
}
this.commitValue(nextValue);
}
onOpen(): void {
this.dropdownOpen.set(true);
this.searchTerm.set('');
if (
this.isMultiple() &&
this.showMultiSelectFooter()
) {
this.pendingValue.set([
...this.selectedValues()
]);
}
this.opened.emit();
}
onClose(): void {
this.dropdownOpen.set(false);
this.searchTerm.set('');
this.onTouched();
this.closed.emit();
}
onFocus(): void {
this.focused.emit();
}
onBlur(): void {
this.onTouched();
this.blurred.emit();
}
onClear(): void {
this.searchTerm.set('');
this.cleared.emit();
}
onScroll(event: FormSelectScrollEvent): void {
this.scrolled.emit(event);
}
onScrollToEnd(): void {
this.scrolledToEnd.emit();
}
confirmSelection(select: NgSelectComponent): void {
if (!this.isMultiple()) {
return;
}
this.commitValue([
...this.pendingValue()
]);
select.close();
}
cancelSelection(select: NgSelectComponent): void {
this.pendingValue.set([
...this.selectedValues()
]);
select.close();
}
isOptionSelected(option: FormSelectOption<TValue>): boolean {
const value = option.value;
const source = this.activeSelectedValues();
return source.includes(value);
}
getMultiLabelText(items: readonly FormSelectOption<TValue>[]): string {
const firstLabel = items[0]?.label ?? '';
const remainingCount = items.length - 1;
return remainingCount > 0
? `${firstLabel} +${remainingCount}`
: firstLabel;
}
trackOption(option: FormSelectOption<TValue>): TValue {
return option.value;
}
getOptionContainerClass(option: FormSelectOption<TValue>): string {
return [
'flex min-w-0 w-full items-start gap-2 text-inherit',
option.disabled ? 'text-textmuted opacity-60' : '',
this.isOptionSelected(option) && !option.disabled ? 'text-white' : ''
]
.filter(Boolean)
.join(' ');
}
getOptionLabelClass(option: FormSelectOption<TValue>): string {
return [
'block truncate text-inherit',
option.disabled ? 'text-textmuted' : ''
]
.filter(Boolean)
.join(' ');
}
getOptionDisplayLabel(
option: FormSelectOption<TValue>,
resolvedLabel: string | null | undefined
): string {
return this.cleanText(option.label) || this.cleanText(resolvedLabel);
}
getSelectionPrefixText(item: FormSelectOption<TValue> | TValue): string {
return this.isOption(item)
? item.prefixText?.trim() ?? ''
: '';
}
getSelectionDisplayLabel(
item: FormSelectOption<TValue> | TValue,
resolvedLabel: string | null | undefined
): string {
if (this.isOption(item)) {
return this.cleanText(item.label) || this.cleanText(resolvedLabel);
}
return this.cleanText(resolvedLabel) || this.findOptionLabel(item);
}
getOptionPrefixClass(option: FormSelectOption<TValue>): string {
return [
'shrink-0 rounded-sm px-1.5 py-0.5 text-[0.6875rem] font-medium',
option.disabled
? 'bg-light text-textmuted dark:bg-black/20'
: 'bg-light text-primary dark:bg-black/20'
]
.filter(Boolean)
.join(' ');
}
private commitValue(value: FormSelectValue<TValue>): void {
this.value.set(value);
this.onChange(value);
this.selectionChanged.emit(value);
}
private activeSelectedValues(): readonly TValue[] {
if (
this.isMultiple() &&
this.showMultiSelectFooter() &&
this.dropdownOpen()
) {
return this.pendingValue();
}
const currentValue = this.value();
if (this.isValueArray(currentValue)) {
return currentValue;
}
return currentValue === null
? []
: [currentValue];
}
private normalizeValue(value: FormSelectValue<TValue>): FormSelectValue<TValue> {
if (this.isMultiple()) {
return this.isValueArray(value)
? value.filter(item => !this.isEmptyStringValue(item))
: [];
}
return this.isValueArray(value)
? this.normalizeSingleValue(value[0] ?? null)
: this.normalizeSingleValue(value);
}
private normalizeSingleValue(value: TValue | null): TValue | null {
return this.isEmptyStringValue(value)
? null
: value ?? null;
}
private isValueArray(value: FormSelectValue<TValue>): value is readonly TValue[] {
return Array.isArray(value);
}
private isEmptyStringValue(value: TValue | null): boolean {
return typeof value === 'string' && value.trim() === '';
}
private isOption(value: FormSelectOption<TValue> | TValue): value is FormSelectOption<TValue> {
return (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'label' in value
);
}
private findOptionLabel(value: TValue): string {
return this.options()
.find(option => option.value === value)
?.label
?.trim() ?? '';
}
private optionMatchesSearchTerm(
option: FormSelectOption<TValue>,
term: string
): boolean {
return [
option.label,
option.prefixText ?? '',
option.description ?? ''
].some(value =>
this.cleanText(value).toLocaleLowerCase().includes(term)
);
}
private cleanText(value: string | null | undefined): string {
return value?.trim() ?? '';
}
}
@@ -2,7 +2,9 @@ import {
ChangeDetectionStrategy,
Component,
computed,
input
effect,
input,
signal
} from '@angular/core';
import { AbstractControl } from '@angular/forms';
@@ -23,23 +25,62 @@ export class FormValidationMessage {
readonly showWhenDirty = input(false);
readonly submitAttempted = input(false);
readonly customClass = input(
'mt-1 text-[0.75rem] text-danger'
);
private readonly controlStateVersion = signal(0);
constructor() {
effect((onCleanup) => {
const control = this.control();
if (!control) {
return;
}
const statusSubscription = control.statusChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
const valueSubscription = control.valueChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
const eventsSubscription = control.events?.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
});
onCleanup(() => {
statusSubscription.unsubscribe();
valueSubscription.unsubscribe();
eventsSubscription?.unsubscribe();
});
});
}
readonly shouldShow = computed(() => {
this.controlStateVersion();
const control = this.control();
if (!control || !control.invalid) {
return false;
}
return this.showWhenDirty()
? control.touched || control.dirty
: control.touched;
return (
control.touched ||
control.dirty ||
this.submitAttempted()
);
});
readonly message = computed(() => {
this.controlStateVersion();
this.submitAttempted();
const control = this.control();
if (!control?.errors) {
@@ -124,4 +165,4 @@ export class FormValidationMessage {
return `${fieldName} is invalid.`;
}
}
}
}
@@ -0,0 +1,37 @@
export type FormSelectPrimitive = string | number;
export type FormSelectMode = 'single' | 'multiple';
export type FormSelectSearchMode = 'client' | 'server';
export type FormSelectDropdownPosition =
| 'auto'
| 'bottom'
| 'top'
| 'left'
| 'right';
export type FormSelectValue<
TValue extends FormSelectPrimitive = FormSelectPrimitive
> = TValue | readonly TValue[] | null;
export interface FormSelectOption<
TValue extends FormSelectPrimitive = FormSelectPrimitive
> {
readonly value: TValue;
readonly label: string;
readonly prefixText?: string | null;
readonly description?: string | null;
readonly group?: string | null;
readonly disabled?: boolean;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface FormSelectSearchEvent {
readonly term: string;
}
export interface FormSelectScrollEvent {
readonly start: number;
readonly end: number;
}
+21 -24
View File
@@ -1,52 +1,49 @@
@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]"
class="hs-overlay open hs-overlay-backdrop-open:!bg-[#32325180] dark:hs-overlay-backdrop-open:!bg-[#323251cc] ti-modal pointer-events-none"
(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()"
<div class="fixed inset-0 pointer-events-auto bg-[#32325180] dark:bg-[#323251cc]" aria-hidden="true"
(mousedown)="onBackdropClick($event)"></div>
<div [class]="modalBoxClass()" role="dialog" aria-modal="true" [attr.aria-label]="title()" tabindex="-1"
(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">
<div class="ti-modal-content border-defaultborder dark:border-defaultborder max-h-[90vh] overflow-hidden">
@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="ti-modal-header border-defaultborder dark:border-defaultborder">
<div class="min-w-0">
<h6 class="m-0 text-[1.125rem] font-semibold leading-6 text-defaulttextcolor">
<h6 class="ti-modal-title m-0">
{{ 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"
<button type="button" class="ti-modal-close-btn cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close modal" [disabled]="loading()" (click)="close()">
<i class="ri-close-line text-xl leading-none"></i>
<i class="ri-close-line leading-none"></i>
</button>
}
</div>
}
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div class="ti-modal-body px-4 min-h-0 overflow-y-auto">
@if (subtitle()) {
<p class="mb-4 text-[0.8125rem] leading-5 text-textmuted">
{{ subtitle() }}
</p>
}
<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">
<div class="ti-modal-footer border-defaultborder dark:border-defaultborder">
@if (showCancelButton()) {
<app-button action="cancel" [label]="cancelLabel()" [showIcon]="false" [disabled]="loading()"
<app-button action="cancel" [label]="cancelLabel()" [showIcon]="true" [disabled]="loading()"
(buttonClicked)="close()" />
}
@if (showSubmitButton()) {
<app-button [action]="submitAction()" [label]="submitLabel()" [loadingLabel]="loadingLabel()" [showIcon]="false"
<app-button [action]="submitAction()" [label]="submitLabel()" [loadingLabel]="loadingLabel()" [showIcon]="true"
[loading]="loading()" [disabled]="submitDisabled()" (buttonClicked)="submit()" />
}
</div>
@@ -54,4 +51,4 @@
</div>
</div>
</div>
}
}
@@ -1,40 +1,3 @@
: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;
}
}
+18 -5
View File
@@ -43,15 +43,28 @@ export class Modal implements OnDestroy {
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]'
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
full: 'max-w-[96vw]'
};
return sizes[this.size()];
});
readonly modalBoxClass = computed(() => {
return [
'hs-overlay-open:mt-7',
'ti-modal-box',
'mt-0',
'ease-out',
'relative',
'z-[1]',
'pointer-events-auto',
this.modalSizeClass()
].join(' ');
});
constructor(private readonly elementRef: ElementRef<HTMLElement>) {
effect(() => {
if (this.open()) {
@@ -111,4 +124,4 @@ export class Modal implements OnDestroy {
ngOnDestroy(): void {
document.body.classList.remove('overflow-hidden');
}
}
}
@@ -0,0 +1,8 @@
import { DataTableToolbarDirective } from './data-table-toolbar.directive';
describe('DataTableToolbarDirective', () => {
it('should create an instance', () => {
const directive = new DataTableToolbarDirective();
expect(directive).toBeTruthy();
});
});
@@ -0,0 +1,15 @@
import {
Directive,
TemplateRef,
inject
} from '@angular/core';
@Directive({
selector: '[appDataTableToolbar]',
standalone: true
})
export class DataTableToolbarDirective {
readonly templateRef = inject(
TemplateRef<unknown>
);
}
@@ -2,17 +2,16 @@
class="
pointer-events-none
max-w-[250px]
rounded-md
bg-gray-900
px-2.5
py-1.5
text-[0.75rem]
whitespace-normal
rounded-sm
bg-primary
px-2
py-1
text-xs
font-medium
leading-4
text-white
shadow-lg
dark:bg-white
dark:text-gray-900
shadow-sm
"
role="tooltip"
>