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()) {
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)));
}
}