add tenant and user endpoints, services, models and guards
This commit is contained in:
@@ -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(' ');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user