import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition, } from '@angular/cdk/overlay'; import { booleanAttribute, ChangeDetectionStrategy, Component, computed, DestroyRef, effect, forwardRef, inject, input, model, numberAttribute, output, signal, viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { type ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; import { of, Subject, timer } from 'rxjs'; import { catchError, debounce, distinctUntilChanged, switchMap, tap, } from 'rxjs/operators'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import { ChipComponent } from '../../chip/chip.component'; import { TooltipComponent, TooltipTriggerDirective, } from '../../tooltip'; import { FORM_AFFIX_CLASS, FORM_CONTROL_INNER_CLASS, FORM_DISABLED_CLASS, FORM_ERROR_CLASS, FORM_FIELD_CLASS, FORM_FIELD_ERROR_CLASS, FORM_HINT_CLASS, FORM_LABEL_CLASS, FORM_OVERLAY_LIST_CLASS, FORM_OVERLAY_PANEL_CLASS, } from '../form-field.classes'; import type { SearchComboboxBadgeFn, SearchComboboxLabelFn, SearchComboboxMarkFn, SearchComboboxSearchFn, SearchComboboxSubtitleFn, SearchComboboxTrackFn, } from './search-combobox.types'; let nextSearchComboboxId = 0; const PANEL_POSITIONS: ConnectedPosition[] = [ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4, }, { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4, }, ]; /** * Generic type-as-you-search combobox with a debounced async lookup and * overlay result list. * * Pass {@link search} plus {@link optionLabel} (and optional * {@link optionSubtitle}) so the same control works for customers, staff, * partners, etc. Emits the selected entity via `[(selected)]` / CVA. * * @typeParam T - Entity type returned by {@link search} and held in * {@link selected}. * * @example * ```html * * ``` */ @Component({ selector: 'africanies-search-combobox', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AfricaniesIconComponent, ChipComponent, CdkOverlayOrigin, CdkConnectedOverlay, TooltipComponent, TooltipTriggerDirective, ], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SearchComboboxComponent), multi: true, }, ], host: { class: 'relative block', }, template: `
@if (loading()) { } @else if (selectedMark()) { } @else { }
@if (loading()) {
Searching…
} @else if (searchError(); as err) {
{{ err }}
} @else if (options().length === 0) {
{{ emptyMessage() }}
} @else { @for (item of options(); track trackKey(item); let i = $index) { } }
@if (error(); as err) {

{{ err }}

} @else if (hint(); as h) {

{{ h }}

} `, }) /** * Generic type-as-you-search combobox with a debounced async lookup and overlay result list. */ export class SearchComboboxComponent implements ControlValueAccessor { private readonly destroyRef = inject(DestroyRef); protected readonly controlId = `africanies-search-combobox-${++nextSearchComboboxId}`; protected readonly hintId = `${this.controlId}-hint`; protected readonly errorId = `${this.controlId}-error`; protected readonly listboxId = `${this.controlId}-listbox`; protected readonly labelClass = FORM_LABEL_CLASS; protected readonly affixClass = FORM_AFFIX_CLASS; protected readonly innerClass = FORM_CONTROL_INNER_CLASS; protected readonly hintClass = FORM_HINT_CLASS; protected readonly errorClass = FORM_ERROR_CLASS; protected readonly overlayPanelClass = FORM_OVERLAY_PANEL_CLASS; protected readonly overlayListClass = FORM_OVERLAY_LIST_CLASS; protected readonly panelPositions = PANEL_POSITIONS; protected readonly minPanelWidth = 224; private readonly triggerOrigin = viewChild(CdkOverlayOrigin); private readonly querySubject = new Subject(); private onChange: (value: T | null) => void = () => undefined; private onTouched: () => void = () => undefined; /** Visible field label. */ readonly label = input(''); /** Helper copy under the field. Hidden while {@link error} is set. */ readonly hint = input(undefined); /** Field-level validation message. */ readonly error = input(null); readonly placeholder = input(''); /** Debounce before calling {@link search} (ms). Default 500. */ readonly debounceMs = input(500, { transform: numberAttribute }); /** Minimum trimmed query length before search runs. Default 2. */ readonly minQueryLength = input(2, { transform: numberAttribute }); readonly emptyMessage = input('No matches'); /** Async lookup — parent owns HTTP / filtering. */ readonly search = input.required>(); readonly optionLabel = input.required>(); readonly optionSubtitle = input | undefined>( undefined, ); readonly optionBadge = input | undefined>( undefined, ); readonly optionMark = input | undefined>(undefined); readonly markTooltip = input('Professional Business Plan'); readonly trackBy = input>( (item: T) => this.optionLabel()(item), ); readonly selected = model(null); readonly queryChange = output(); readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute, }); protected readonly cvaDisabled = signal(false); protected readonly disabled = computed( () => this.disabledInput() || this.cvaDisabled(), ); protected readonly query = signal(''); protected readonly open = signal(false); protected readonly loading = signal(false); protected readonly options = signal([]); protected readonly searchError = signal(null); protected readonly activeIndex = signal(-1); protected readonly panelWidth = signal(this.minPanelWidth); protected readonly shellClass = computed(() => { let classes = FORM_FIELD_CLASS; if (this.error()) { classes += ` ${FORM_FIELD_ERROR_CLASS}`; } if (this.disabled()) { classes += ` ${FORM_DISABLED_CLASS}`; } return classes; }); protected readonly describedBy = computed(() => { if (this.error()) { return this.errorId; } if (this.hint()) { return this.hintId; } return null; }); protected readonly activeOptionId = computed(() => { const index = this.activeIndex(); if (index < 0 || !this.open()) { return null; } return this.optionId(index); }); protected readonly selectedMark = computed(() => { const sel = this.selected(); const fn = this.optionMark(); return !!sel && !!fn && fn(sel); }); constructor() { effect(() => { const sel = this.selected(); const labelFn = this.optionLabel(); if (sel) { this.query.set(labelFn(sel)); } }); this.querySubject .pipe( debounce(() => timer(Math.max(0, Number(this.debounceMs()) || 0))), distinctUntilChanged(), switchMap((text) => { const trimmed = text.trim(); const min = Math.max(0, Number(this.minQueryLength()) || 0); if (trimmed.length < min) { this.options.set([]); this.searchError.set(null); this.loading.set(false); this.open.set(false); return of([] as T[]); } this.loading.set(true); this.open.set(true); return this.search()(trimmed).pipe( tap(() => this.searchError.set(null)), catchError((err) => { this.searchError.set( err instanceof Error ? err.message : 'Could not load results.', ); return of([] as T[]); }), ); }), takeUntilDestroyed(this.destroyRef), ) .subscribe((results) => { this.loading.set(false); this.options.set(results); this.activeIndex.set(results.length ? 0 : -1); if (this.query().trim().length > 0) { this.open.set(true); } }); } writeValue(value: T | null): void { this.selected.set(value); this.query.set(value ? this.optionLabel()(value) : ''); } registerOnChange(fn: (value: T | null) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { this.cvaDisabled.set(isDisabled); } /** Clear selection and query text. */ clear(): void { this.query.set(''); this.commit(null); this.options.set([]); this.open.set(false); } protected trackKey(item: T): string | number { return this.trackBy()(item); } protected subtitleFor(item: T): string | undefined { const fn = this.optionSubtitle(); return fn ? fn(item)?.trim() || undefined : undefined; } protected badgeFor(item: T) { const fn = this.optionBadge(); return fn ? fn(item) ?? undefined : undefined; } protected markFor(item: T): boolean { const fn = this.optionMark(); return fn ? fn(item) : false; } protected optionId(index: number): string { return `${this.controlId}-option-${index}`; } protected onInput(event: Event): void { const next = (event.target as HTMLInputElement).value; this.query.set(next); this.queryChange.emit(next); if (this.selected() !== null) { this.commit(null); } this.syncPanelWidth(); this.querySubject.next(next); } protected onFocus(): void { this.syncPanelWidth(); if (this.query().trim() && this.options().length) { this.open.set(true); } } protected onBlur(): void { this.onTouched(); } protected onKeydown(event: KeyboardEvent): void { if ( !this.open() && (event.key === 'ArrowDown' || event.key === 'ArrowUp') ) { if (this.options().length) { this.open.set(true); event.preventDefault(); } return; } if (!this.open()) { if (event.key === 'Escape') { (event.target as HTMLInputElement).blur(); } return; } const count = this.options().length; switch (event.key) { case 'ArrowDown': { event.preventDefault(); if (!count) { return; } this.activeIndex.set((this.activeIndex() + 1) % count); break; } case 'ArrowUp': { event.preventDefault(); if (!count) { return; } this.activeIndex.set((this.activeIndex() - 1 + count) % count); break; } case 'Enter': { event.preventDefault(); const item = this.options()[this.activeIndex()]; if (item) { this.selectItem(item); } break; } case 'Escape': { event.preventDefault(); this.closePanel(); break; } default: break; } } protected selectItem(item: T): void { this.query.set(this.optionLabel()(item)); this.commit(item); this.closePanel(); } protected closePanel(): void { this.open.set(false); this.activeIndex.set(-1); } protected onOutsideClick(event: MouseEvent): void { const origin = this.triggerOrigin()?.elementRef.nativeElement as | HTMLElement | undefined; if (origin?.contains(event.target as Node)) { return; } this.closePanel(); } protected onOverlayDetach(): void { this.open.set(false); } private commit(item: T | null): void { this.selected.set(item); this.onChange(item); } private syncPanelWidth(): void { const origin = this.triggerOrigin()?.elementRef.nativeElement as | HTMLElement | undefined; const width = origin?.getBoundingClientRect().width ?? this.minPanelWidth; this.panelWidth.set(Math.max(this.minPanelWidth, Math.round(width))); } }