add tenant and user endpoints, services, models and guards

This commit is contained in:
Gagan7900
2026-07-18 16:38:41 +05:30
parent be86ab3564
commit 7c4e34e478
50 changed files with 3064 additions and 451 deletions
@@ -0,0 +1,41 @@
<div class="box custom-box" [class]="'box custom-box ' + cardClass()">
<div class="box-header justify-between" [class]="'box-header justify-between ' + headerClass()">
<div class="box-title">{{ title() }}</div>
<div class="flex items-center gap-2">
<ng-content select="[filterCardActions]" />
<button
type="button"
class="ti-btn ti-btn-sm ti-btn-light"
[class]="'ti-btn ti-btn-sm ti-btn-light ' + toggleButtonClass()"
[class.hidden]="!showToggleButton()"
[disabled]="!collapsible()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-controls]="resolvedContentId()"
[attr.aria-label]="currentTooltip()"
[appTooltip]="currentTooltip()"
(click)="toggleCollapse()"
>
<i [class]="currentIcon()" aria-hidden="true"></i>
</button>
</div>
</div>
<div
class="filter-card-body-grid"
[class.is-collapsed]="collapsed()"
[attr.aria-hidden]="collapsed()"
[attr.inert]="collapsed() ? '' : null"
>
<div class="filter-card-body-content">
<div
class="box-body"
[class]="'box-body ' + bodyClass()"
[id]="resolvedContentId()"
>
<ng-content />
</div>
</div>
</div>
</div>
@@ -0,0 +1,142 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterCard } from './filter-card';
@Component({
standalone: true,
imports: [FilterCard],
template: `
<app-filter-card
title="Country Selection"
[defaultCollapsed]="defaultCollapsed()"
[collapsible]="collapsible()"
[collapsedIcon]="collapsedIcon"
[expandedIcon]="expandedIcon"
[contentId]="contentId()"
(collapseChanged)="changes.push($event)"
>
<button filterCardActions type="button">Reset</button>
<input data-testid="projected-input" value="preserved" />
</app-filter-card>
`
})
class HostComponent {
readonly defaultCollapsed = signal(false);
readonly collapsible = signal(true);
readonly contentId = signal('');
readonly changes: boolean[] = [];
collapsedIcon = 'custom-collapsed';
expandedIcon = 'custom-expanded';
}
describe('FilterCard', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
let overlayContainer: OverlayContainer;
const card = (): FilterCard => fixture.debugElement.children[0].componentInstance;
const toggle = (): HTMLButtonElement => fixture.nativeElement.querySelector('button[aria-controls]');
const icon = (): HTMLElement => toggle().querySelector('i') as HTMLElement;
const bodyGrid = (): HTMLElement => fixture.nativeElement.querySelector('.filter-card-body-grid');
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
overlayContainer = TestBed.inject(OverlayContainer);
fixture.detectChanges();
});
afterEach(() => {
vi.useRealTimers();
overlayContainer.ngOnDestroy();
});
it('starts expanded by default', () => {
expect(card().collapsed()).toBe(false);
expect(bodyGrid().classList.contains('is-collapsed')).toBe(false);
});
it('reacts to a collapsed default state', () => {
host.defaultCollapsed.set(true);
fixture.detectChanges();
expect(card().collapsed()).toBe(true);
expect(bodyGrid().classList.contains('is-collapsed')).toBe(true);
});
it('switches icons and tooltips when toggled', () => {
expect(icon().className).toBe('custom-expanded');
expect(toggle().getAttribute('aria-label')).toBe('Hide Filters');
toggle().click();
fixture.detectChanges();
expect(icon().className).toBe('custom-collapsed');
expect(toggle().getAttribute('aria-label')).toBe('Show Filters');
});
it('updates an open tooltip when toggled without requiring another hover', () => {
vi.useFakeTimers();
toggle().dispatchEvent(new MouseEvent('mouseenter'));
vi.advanceTimersByTime(200);
fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('Hide Filters');
toggle().click();
fixture.detectChanges();
expect(overlayContainer.getContainerElement().textContent).toContain('Show Filters');
expect(overlayContainer.getContainerElement().textContent).not.toContain('Hide Filters');
});
it('emits the new collapsed state', () => {
toggle().click();
toggle().click();
expect(host.changes).toEqual([true, false]);
});
it('sets accessible expanded and controls attributes', () => {
const id = card().resolvedContentId();
expect(toggle().getAttribute('aria-expanded')).toBe('true');
expect(toggle().getAttribute('aria-controls')).toBe(id);
expect(fixture.nativeElement.querySelector(`#${id}`)).not.toBeNull();
});
it('keeps projected content mounted while collapsed', () => {
const input = fixture.nativeElement.querySelector('[data-testid="projected-input"]');
toggle().click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="projected-input"]')).toBe(input);
expect(fixture.nativeElement.textContent).toContain('Reset');
});
it('uses custom icons', () => {
expect(icon().classList.contains('custom-expanded')).toBe(true);
toggle().click();
fixture.detectChanges();
expect(icon().classList.contains('custom-collapsed')).toBe(true);
});
it('uses a custom content id', () => {
host.contentId.set('state-filter-content');
fixture.detectChanges();
expect(toggle().getAttribute('aria-controls')).toBe('state-filter-content');
expect(fixture.nativeElement.querySelector('#state-filter-content')).not.toBeNull();
});
it('generates a stable content id', () => {
const generatedId = card().resolvedContentId();
fixture.detectChanges();
expect(generatedId).toMatch(/^filter-card-content-\d+$/);
expect(card().resolvedContentId()).toBe(generatedId);
});
it('does not collapse or emit when non-collapsible', () => {
host.collapsible.set(false);
fixture.detectChanges();
toggle().click();
expect(card().collapsed()).toBe(false);
expect(host.changes).toEqual([]);
expect(toggle().disabled).toBe(true);
});
});
@@ -0,0 +1,71 @@
import { Component, computed, effect, input, output, signal } from '@angular/core';
import { TooltipDirective } from '../../directives/tooltip/tooltip.directive';
let nextFilterCardId = 0;
@Component({
selector: 'app-filter-card',
standalone: true,
imports: [TooltipDirective],
templateUrl: './filter-card.html',
styles: `
:host { display: block; }
.filter-card-body-grid {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 200ms ease, visibility 200ms ease;
visibility: visible;
}
.filter-card-body-grid.is-collapsed {
grid-template-rows: 0fr;
visibility: hidden;
}
.filter-card-body-content { min-height: 0; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.filter-card-body-grid { transition: none; }
}
`
})
export class FilterCard {
readonly title = input<string>('Filters');
readonly defaultCollapsed = input<boolean>(false);
readonly collapsible = input<boolean>(true);
readonly showToggleButton = input<boolean>(true);
readonly collapsedIcon = input<string>('ri-filter-3-line');
readonly expandedIcon = input<string>('ri-arrow-up-s-line');
readonly collapsedTooltip = input<string>('Show Filters');
readonly expandedTooltip = input<string>('Hide Filters');
readonly cardClass = input<string>('');
readonly headerClass = input<string>('');
readonly bodyClass = input<string>('');
readonly toggleButtonClass = input<string>('');
readonly contentId = input<string>('');
readonly collapseChanged = output<boolean>();
readonly collapsed = signal(false);
private readonly generatedContentId = `filter-card-content-${++nextFilterCardId}`;
readonly resolvedContentId = computed(() => this.contentId().trim() || this.generatedContentId);
readonly currentIcon = computed(() =>
this.collapsed() ? this.collapsedIcon() : this.expandedIcon()
);
readonly currentTooltip = computed(() =>
this.collapsed() ? this.collapsedTooltip() : this.expandedTooltip()
);
constructor() {
effect(() => this.collapsed.set(this.defaultCollapsed()));
}
toggleCollapse(): void {
if (!this.collapsible()) {
return;
}
const collapsed = !this.collapsed();
this.collapsed.set(collapsed);
this.collapseChanged.emit(collapsed);
}
}
@@ -6,6 +6,7 @@
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[help]="help()"
[labelPosition]="labelPosition()"
[hideLabel]="hideLabel()"
[hideValidation]="hideValidation()"
@@ -35,18 +36,19 @@
[attr.aria-readonly]="readonly()"
(input)="onInput($event)"
(focus)="onFocus()"
(click)="onClick()"
(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 class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-primary" aria-hidden="true">
<span class="ti-spinner block h-4 w-4 animate-spin rounded-full border-2 border-current 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"
class="absolute end-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center rounded-sm text-textmuted transition-colors hover:bg-light hover:text-danger focus:outline-none focus:ring-1 focus:ring-primary dark:hover:bg-black/20"
aria-label="Clear selection"
[disabled]="isDisabled() || readonly()"
(mousedown)="$event.preventDefault()"
@@ -75,11 +77,11 @@
<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="w-full 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()">
<div class="bg-white px-3 py-2 text-[0.8125rem] text-textmuted dark:bg-bodybg dark:text-white/50" [class.text-danger]="error()">
{{ message() }}
</div>
} @else {
@@ -88,10 +90,8 @@
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"
[class]="optionClass(item, index)"
[attr.aria-selected]="isOptionSelected(item)"
(mousedown)="$event.preventDefault()"
(mouseenter)="activeIndex.set(index)"
(click)="select(item)"
@@ -99,6 +99,15 @@
{{ displayWith()(item) }}
</button>
}
@if (showingPreview()) {
<div
role="option"
aria-disabled="true"
class="cursor-default border-t border-defaultborder bg-light/50 px-3 py-2 text-[0.8125rem] italic text-textmuted dark:border-defaultborder/10 dark:bg-black/10 dark:text-white/50"
>
{{ previewText() }}
</div>
}
}
</div>
</ng-template>
@@ -1,11 +1,11 @@
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 { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { Observable, Subject, of, throwError } from 'rxjs';
import { Autocomplete } from './autocomplete';
import { AutocompleteSearchFn } from './autocomplete.types';
import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types';
interface LookupItem {
readonly code: string;
@@ -26,24 +26,29 @@ const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
[searchFn]="searchFn"
[displayWith]="displayWith"
[valueWith]="valueWith"
placeholder="e.g.: USD"
[selectedItem]="selectedItem()"
[resolveValueFn]="resolveValueFn()"
[minSearchLength]="minLength"
[debounceTime]="delay()"
[readonly]="readonly()"
[showDropdownOnFocus]="openOnFocus"
[showDropdownOnFocus]="openOnFocus()"
[submitAttempted]="submitAttempted()"
/>
`
})
class HostComponent {
readonly control = new FormControl<string | null>(null);
readonly selectedItem = signal<LookupItem | null>(null);
readonly resolveValueFn = signal<AutocompleteResolveValueFn<LookupItem, string> | 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;
readonly openOnFocus = signal(false);
readonly submitAttempted = signal(false);
}
describe('Autocomplete', () => {
@@ -73,6 +78,11 @@ describe('Autocomplete', () => {
it('initializes with an empty selection', () => {
expect(component.searchText()).toBe('');
expect(component.options()).toEqual([]);
expect(input().value).toBe('');
expect(input().placeholder).toBe('e.g.: USD');
expect(input().className).not.toContain('ti-form-control');
expect(input().className).toContain('placeholder:text-textmuted');
expect(input().className).toContain('dark:placeholder:text-white/50');
});
it('integrates with a reactive form control', () => {
@@ -80,6 +90,14 @@ describe('Autocomplete', () => {
expect(host.control.value).toBe('IN');
});
it('treats edited selected text as search text rather than a selected item', () => {
component.select(INDIA);
expect(component.activeItem()).toEqual(INDIA);
type('Ind');
expect(component.searchText()).toBe('Ind');
expect(component.activeItem()).toBeNull();
});
it('does not emit onChange from writeValue', () => {
const change = vi.fn();
component.registerOnChange(change);
@@ -87,6 +105,28 @@ describe('Autocomplete', () => {
expect(change).not.toHaveBeenCalled();
});
it('does not show invalid styling after touch before submit', () => {
host.control.setValidators(Validators.required);
host.control.updateValueAndValidity();
host.control.markAsTouched();
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(false);
});
it('shows invalid styling after submit and removes it when valid', () => {
host.control.setValidators(Validators.required);
host.control.updateValueAndValidity();
host.submitAttempted.set(true);
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Country is required.');
host.control.setValue('IN');
fixture.detectChanges();
expect(input().classList.contains('is-invalid')).toBe(false);
expect(fixture.nativeElement.textContent).not.toContain('Country is required.');
});
it('applies a disabled form state', () => {
host.control.disable();
fixture.detectChanges();
@@ -117,6 +157,40 @@ describe('Autocomplete', () => {
vi.useRealTimers();
});
it('loads five preview records on focus before the user types', () => {
vi.useFakeTimers();
const search = vi.fn(() => of([INDIA, INDONESIA]));
host.openOnFocus.set(true);
host.searchFn = search;
fixture.detectChanges();
input().focus();
vi.advanceTimersByTime(0);
fixture.detectChanges();
expect(search).toHaveBeenCalledWith('', 5);
expect(component.options()).toEqual([INDIA, INDONESIA]);
expect(component.showingPreview()).toBe(true);
expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...');
vi.useRealTimers();
});
it('replaces preview records with typed search results', () => {
vi.useFakeTimers();
host.openOnFocus.set(true);
host.delay.set(0);
host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]);
fixture.detectChanges();
input().focus();
vi.advanceTimersByTime(0);
expect(component.options()).toEqual([INDIA]);
type('in');
vi.advanceTimersByTime(0);
fixture.detectChanges();
expect(component.options()).toEqual([INDONESIA]);
expect(component.showingPreview()).toBe(false);
expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...');
vi.useRealTimers();
});
it('cancels stale search requests', () => {
vi.useFakeTimers();
const first = new Subject<readonly LookupItem[]>();
@@ -227,6 +301,36 @@ describe('Autocomplete', () => {
expect(component.searchText()).toBe('India');
});
it('does not resolve an empty string form value', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
fixture.detectChanges();
host.control.setValue('');
fixture.detectChanges();
expect(resolve).not.toHaveBeenCalled();
});
it('uses the matching selected item without resolving it again', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
host.selectedItem.set(INDIA);
fixture.detectChanges();
host.control.setValue('IN');
fixture.detectChanges();
expect(component.searchText()).toBe('India');
expect(resolve).not.toHaveBeenCalled();
});
it('resolves a non-empty value when no matching selected item is supplied', () => {
const resolve = vi.fn(() => of(INDIA));
host.resolveValueFn.set(resolve);
fixture.detectChanges();
host.control.setValue('IN');
fixture.detectChanges();
expect(resolve).toHaveBeenCalledWith('IN');
expect(component.searchText()).toBe('India');
});
it('does not retain a stale label when edit values change', () => {
host.selectedItem.set(INDIA);
host.control.setValue('IN');
@@ -40,6 +40,12 @@ interface SearchResult<TItem> {
readonly term: string;
readonly options: readonly TItem[];
readonly failed: boolean;
readonly preview: boolean;
}
interface SearchRequest {
readonly term: string;
readonly preview: boolean;
}
@Component({
@@ -59,9 +65,10 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
private readonly injector = inject(Injector);
private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`;
private readonly inputTerms$ = new Subject<string>();
private readonly searchRequests$ = new Subject<SearchRequest>();
private readonly valuesToResolve$ = new Subject<TValue>();
private formValue: TValue | null = null;
private readonly controlStateVersion = signal(0);
private labelEdited = false;
private onChange: (value: TValue | null) => void = () => {};
private onTouched: () => void = () => {};
@@ -81,6 +88,8 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly minSearchLength = input(1);
readonly debounceTime = input(300);
readonly limit = input(10);
readonly previewLimit = input(5);
readonly previewText = input('Type to search more...');
readonly disabled = input(false);
readonly readonly = input(false);
readonly clearable = input(true);
@@ -89,7 +98,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly emptyText = input('No results found');
readonly typeToSearchText = input('Type to search');
readonly errorText = input('Unable to load results');
readonly showDropdownOnFocus = input(false);
readonly showDropdownOnFocus = input(true);
readonly closeOnSelect = input(true);
readonly autocomplete = input('off');
readonly ariaLabel = input<string | null>(null);
@@ -102,6 +111,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly validationMessages = input<ValidationMessageMap>({});
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top');
readonly itemSelected = output<TItem>();
@@ -118,6 +128,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly activeItem = signal<TItem | null>(null);
readonly searchText = signal('');
readonly error = signal<string | null>(null);
readonly showingPreview = signal(false);
readonly formDisabled = signal(false);
readonly panelWidth = signal(0);
@@ -136,21 +147,24 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly message = computed(() => {
if (this.loading()) return this.loadingText();
if (this.error()) return this.errorText();
if (this.showingPreview()) return '';
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(() => {
this.controlStateVersion();
const control = this.control();
const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted()));
const invalid = !!(control?.invalid && this.submitAttempted());
return [
'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor',
'ti-form-select w-full rounded-sm border border-defaultborder bg-white text-defaulttextcolor',
'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70',
'focus:border-primary focus:ring-1 focus:ring-primary',
'pe-16',
'placeholder:text-textmuted placeholder:opacity-100 dark:placeholder:text-white/50',
'focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none',
'pe-10 transition-colors',
invalid ? 'is-invalid border-danger' : '',
this.isDisabled() ? 'cursor-not-allowed opacity-60' : '',
this.isDisabled() ? 'cursor-not-allowed bg-light opacity-60 dark:bg-black/20' : '',
this.inputClass()
].filter(Boolean).join(' ');
});
@@ -161,24 +175,49 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
];
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 });
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();
});
});
this.searchRequests$.pipe(
map(request => ({ ...request, term: request.term.trim() })),
debounce(request => timer(request.preview ? 0 : Math.max(0, this.debounceTime()))),
distinctUntilChanged((previous, current) =>
previous.term === current.term && previous.preview === current.preview
),
switchMap(request => {
if (!request.preview && request.term.length < this.minSearchLength()) {
return of<SearchResult<TItem>>({ ...request, 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 }))
const limit = request.preview ? this.previewLimit() : this.limit();
return this.searchFn()(request.term, limit).pipe(
map(options => ({ ...request, options, failed: false })),
catchError(() => of<SearchResult<TItem>>({ ...request, options: [], failed: true }))
);
}),
takeUntilDestroyed()
).subscribe(result => {
this.loading.set(false);
this.showingPreview.set(result.preview && !result.failed);
this.options.set(result.options);
this.activeIndex.set(-1);
this.error.set(result.failed ? this.errorText() : null);
@@ -203,7 +242,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
writeValue(value: TValue | null): void {
this.formValue = value ?? null;
this.labelEdited = false;
if (this.formValue === null) {
if (!this.hasResolvableValue(this.formValue)) {
this.applyResolvedItem(null);
return;
}
@@ -228,17 +267,21 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
const text = event.target.value;
const previousText = this.searchText();
this.searchText.set(text);
this.activeItem.set(null);
this.showingPreview.set(false);
this.options.set([]);
this.labelEdited = this.formValue !== null && text !== previousText;
this.searchChanged.emit(text.trim());
this.open();
this.inputTerms$.next(text);
this.searchRequests$.next({ term: text, preview: false });
}
onFocus(): void {
if (this.showDropdownOnFocus()) {
this.open();
this.inputTerms$.next(this.searchText());
}
if (this.showDropdownOnFocus()) this.openPreview();
}
onClick(): void {
if (this.showDropdownOnFocus()) this.openPreview();
}
onBlur(): void {
@@ -305,6 +348,19 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; }
optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; }
isOptionSelected(item: TItem): boolean {
return this.formValue !== null && this.valuesEqual(this.valueWith()(item), this.formValue);
}
optionClass(item: TItem, index: number): string {
const highlighted = this.activeIndex() === index;
const selected = this.isOptionSelected(item);
return [
'block w-full px-3 py-2 text-start text-[0.8125rem] transition-colors',
highlighted || selected
? 'bg-primary text-white'
: 'bg-white text-defaulttextcolor hover:bg-primary hover:text-white dark:bg-bodybg dark:text-white/70 dark:hover:bg-primary dark:hover:text-white'
].join(' ');
}
private setActive(index: number): void {
if (index < 0 || index >= this.options().length) return;
@@ -319,6 +375,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
this.options.set([]);
this.error.set(null);
this.loading.set(false);
this.showingPreview.set(false);
this.labelEdited = false;
this.onChange(null);
this.onTouched();
@@ -335,4 +392,16 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
}
private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); }
private hasResolvableValue(value: TValue | null): value is TValue {
return value !== null && (typeof value !== 'string' || value.trim().length > 0);
}
private openPreview(): void {
if (this.isDisabled() || this.readonly() || this.isOpen()) return;
this.open();
if (!this.searchText().trim()) {
this.searchRequests$.next({ term: '', preview: true });
}
}
}
@@ -1,45 +1,49 @@
<div [class]="resolvedWrapperClass()">
@if (showLabel()) {
<label [for]="inputId()" [class]="resolvedLabelClass()" >
{{ label() }}
@if (required()) {
<span [class]="requiredClass()" aria-hidden="true" >
*
</span>
<label [for]="inputId()" [class]="resolvedLabelClass()">
{{ label() }}
@if (required()) {
<span [class]="requiredClass()" aria-hidden="true">
*
</span>
<span class="sr-only">
Required
</span>
}
</label>
<span class="sr-only">
Required
</span>
}
@if (showHelpIcon()) {
<button type="button" class="text-muted hover:text-primary" [attr.aria-label]="help()" tooltipVariant="info" [appTooltip]="resolvedHelp()"
aria-label="Field help">
<i class="ti ti-info-circle"></i>
</button>
}
</label>
}
<div [class]="resolvedContentClass()">
@if (description()) {
<p [id]="descriptionId()" [class]="descriptionClass()" >
{{ description() }}
</p>
<p [id]="descriptionId()" [class]="descriptionClass()">
{{ description() }}
</p>
}
<ng-content />
@if (showHint()) {
<p
[id]="hintId()"
[class]="hintClass()"
>
{{ hint() }}
</p>
<p [id]="hintId()" [class]="hintClass()">
{{ hint() }}
</p>
}
@if (!hideValidation()) {
<app-form-validation-message
[id]="validationId()"
[fieldName]="label()"
[control]="control()"
[messages]="validationMessages()"
[showWhenDirty]="showValidationWhenDirty()"
/>
<app-form-validation-message [id]="validationId()" [fieldName]="label()" [control]="control()"
[messages]="validationMessages()" [showWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()" />
}
</div>
</div>
</div>
@@ -12,13 +12,14 @@ import {
FormValidationMessage,
ValidationMessageMap
} from '../form-validation-message/form-validation-message';
import { TooltipDirective } from '../../../directives/tooltip/tooltip.directive';
export type FormLabelPosition = 'top' | 'left' | 'hidden';
@Component({
selector: 'app-form-field',
standalone: true,
imports: [FormValidationMessage],
imports: [FormValidationMessage, TooltipDirective],
templateUrl: './form-field.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -36,6 +37,15 @@ export class FormField {
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly showHelpIcon = computed(() => {
return !!this.help()?.trim();
});
readonly resolvedHelp = computed(() => {
return this.help()?.trim() ?? '';
});
readonly labelPosition = input<FormLabelPosition>('top');
@@ -120,9 +130,8 @@ export class FormField {
}
return (
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showValidationWhenDirty() && control.dirty)
);
});
@@ -6,6 +6,7 @@
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[help]="help()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()"
@@ -7,7 +7,7 @@ import { ValidationMessageMap } from '../form-validation-message/form-validation
export type FormInputType = | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search';
export type FormInputMode = | 'none'| 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
export type FormInputMode = | 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
export type FormInputIconPosition = 'left' | 'right';
@@ -28,7 +28,7 @@ export type FormInputIconPosition = 'left' | 'right';
export class FormInput implements ControlValueAccessor {
private readonly injector = inject(Injector);
readonly inputId = input.required<string>();
readonly label = input.required<string>();
@@ -54,14 +54,15 @@ export class FormInput implements ControlValueAccessor {
readonly pattern = input<string | null>(null);
readonly min = input<number | null>(null);
readonly max = input<number | null>(null);
readonly step = input<number | string | null>(null);
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly hideValidation = input(false);
readonly showValidationWhenDirty = input(false);
@@ -78,25 +79,25 @@ export class FormInput implements ControlValueAccessor {
readonly showPasswordToggle = input(true);
readonly loading = input(false);
readonly wrapperClass = input('');
readonly fieldContentClass = input('');
readonly labelClass = input('');
readonly inputClass = input('');
readonly ariaLabel = input<string | null>(null);
readonly ariaDescription = input<string | null>(null);
readonly value = signal<string | number | null>(null);
readonly formDisabled = signal(false);
readonly passwordVisible = signal(false);
private readonly controlStateVersion = signal(0);
private onChange: (value: string | number | null) => void = () => {};
private onChange: (value: string | number | null) => void = () => { };
private onTouched: () => void = () => {};
private onTouched: () => void = () => { };
constructor() {
effect(() => {
@@ -192,9 +193,8 @@ export class FormInput implements ControlValueAccessor {
const showInvalidState = !!(
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showValidationWhenDirty() && control.dirty)
)
);
@@ -228,9 +228,8 @@ export class FormInput implements ControlValueAccessor {
if (
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showValidationWhenDirty() && control.dirty)
)
) {
ids.push(`${this.inputId()}-validation`);
@@ -367,9 +367,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
if (
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showValidationWhenDirty() && control.dirty)
)
) {
ids.push(`${this.resolvedInputId()}-validation`);
@@ -406,9 +405,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
const showInvalidState = !!(
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showValidationWhenDirty() && control.dirty)
)
);
@@ -71,9 +71,8 @@ export class FormValidationMessage {
}
return (
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showWhenDirty() && control.dirty)
);
});
@@ -1,10 +1,24 @@
import { Directive, ElementRef, HostListener, OnDestroy, inject, input } from '@angular/core';
import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay';
import {
ComponentRef,
Directive,
ElementRef,
HostListener,
OnDestroy,
effect,
inject,
input
} from '@angular/core';
import {
ConnectedPosition,
Overlay,
OverlayRef
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { Tooltip } from './tooltip/tooltip';
import {
Tooltip
} from './tooltip/tooltip';
import { TooltipVariant } from './tooltip/tooltip';
export type TooltipPosition =
| 'top'
@@ -27,20 +41,42 @@ export class TooltipDirective implements OnDestroy {
readonly tooltipPosition =
input<TooltipPosition>('top');
readonly tooltipVariant =
input<TooltipVariant>('default');
readonly tooltipDisabled = input(false);
readonly tooltipDelay = input(200);
private overlayRef: OverlayRef | null = null;
private showTimeout: ReturnType<typeof setTimeout> | null = null;
private tooltipComponentRef:
ComponentRef<Tooltip> | null = null;
private showTimeout:
ReturnType<typeof setTimeout> | null = null;
constructor() {
effect(() => {
const text = this.appTooltip();
const variant = this.tooltipVariant();
this.tooltipComponentRef?.setInput('text', text);
this.tooltipComponentRef?.setInput(
'variant',
variant
);
});
}
@HostListener('mouseenter')
@HostListener('focusin')
show(): void {
const tooltipText = this.appTooltip().trim();
if (
this.tooltipDisabled() ||
!this.appTooltip()
tooltipText.length === 0
) {
return;
}
@@ -48,8 +84,9 @@ export class TooltipDirective implements OnDestroy {
this.clearTimeout();
this.showTimeout = setTimeout(() => {
this.showTimeout = null;
this.openTooltip();
}, this.tooltipDelay());
}, Math.max(0, this.tooltipDelay()));
}
@HostListener('mouseleave')
@@ -60,37 +97,49 @@ export class TooltipDirective implements OnDestroy {
}
private openTooltip(): void {
if (this.overlayRef) {
if (this.overlayRef?.hasAttached()) {
return;
}
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions(this.getPositions());
.flexibleConnectedTo(
this.elementRef.nativeElement
)
.withPositions(this.getPositions())
.withPush(true);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.reposition()
scrollStrategy:
this.overlay.scrollStrategies.reposition()
});
const portal = new ComponentPortal(Tooltip);
const componentRef = this.overlayRef.attach(portal);
this.tooltipComponentRef =
this.overlayRef.attach(portal);
componentRef.setInput(
this.tooltipComponentRef.setInput(
'text',
this.appTooltip()
this.appTooltip().trim()
);
this.tooltipComponentRef.setInput(
'variant',
this.tooltipVariant()
);
}
private closeTooltip(): void {
this.overlayRef?.dispose();
this.overlayRef = null;
this.tooltipComponentRef = null;
}
private clearTimeout(): void {
if (!this.showTimeout) {
if (this.showTimeout === null) {
return;
}
@@ -110,6 +159,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'center',
overlayY: 'bottom',
offsetY: -8
},
{
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
offsetY: 8
}
],
@@ -120,6 +176,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'center',
overlayY: 'top',
offsetY: 8
},
{
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
offsetY: -8
}
],
@@ -130,6 +193,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'end',
overlayY: 'center',
offsetX: -8
},
{
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: 8
}
],
@@ -140,6 +210,13 @@ export class TooltipDirective implements OnDestroy {
overlayX: 'start',
overlayY: 'center',
offsetX: 8
},
{
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -8
}
]
};
@@ -1,7 +1,7 @@
<div
<!-- <div
class="
pointer-events-none
max-w-[250px]
max-w-xs
whitespace-normal
rounded-sm
bg-primary
@@ -16,4 +16,11 @@
role="tooltip"
>
{{ text() }}
</div> -->
<div
[class]="resolvedTooltipClass()"
role="tooltip"
>
{{ text() }}
</div>
@@ -1,9 +1,15 @@
import {
ChangeDetectionStrategy,
Component,
input
input,
computed
} from '@angular/core';
export type TooltipVariant =
| 'default'
| 'info';
@Component({
selector: 'app-tooltip',
standalone: true,
@@ -12,4 +18,42 @@ import {
})
export class Tooltip {
readonly text = input.required<string>();
readonly variant =
input<TooltipVariant>('default');
readonly resolvedTooltipClass = computed(() => {
const baseClasses = [
'pointer-events-none',
'max-w-xs',
'whitespace-normal',
'rounded-sm',
'px-2',
'py-1',
'text-xs',
'font-medium',
'leading-4'
];
const variantClasses =
this.variant() === 'info'
? [
'border',
'border-defaultborder',
'bg-white',
'text-defaulttextcolor',
'shadow-lg',
'dark:bg-bodybg'
]
: [
'bg-primary',
'text-white',
'shadow-sm'
];
return [
...baseClasses,
...variantClasses
].join(' ');
});
}