add endpoint configuration for city, currency, language, and timezone
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user