import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition, } from '@angular/cdk/overlay'; import { booleanAttribute, ChangeDetectionStrategy, Component, computed, ElementRef, forwardRef, inject, input, model, numberAttribute, output, signal, viewChild, } from '@angular/core'; import { type ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import { ModeColorService } from '@africanies/africanies-theme'; import { ModalService } from '../../overlay/modal.service'; import { FORM_AFFIX_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, FORM_OVERLAY_SEARCH_CLASS, } from '../form-field.classes'; import type { SelectCreateConfig, SelectOption, SelectSize } from './select.types'; let nextSelectId = 0; /** Preferred panel placement — flips upward when there is no room below. */ const SELECT_PANEL_POSITIONS: ConnectedPosition[] = [ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4, }, { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4, }, ]; /** * Option select following the shared AFRICANIES form field pattern. * * ## Single vs multiple (`selectedChange` shape) * * - **`multiple=false` (default):** `selected` / CVA / `selectedChange` use * `SelectOption | null` — selecting replaces the previous value. * - **`multiple=true`:** `selected` / CVA / `selectedChange` use * `SelectOption[]` — every change emits the **full** selection array. * Switching modes is a breaking type change for consumers; do not assume one * output shape works for both. * * ## Create paths * * - **`allowFreeText`** (requires `searchable`): inline “Add \” when the * search text does not exactly match an option label — for string-only options * with no backing entity. * - **`create`**: modal via {@link ModalService} + {@link OVERLAY_DATA} — for * entities that need a real creation flow. Independent of free-text; both may * be enabled together. * * The dropdown panel is a **CDK overlay** (attached to `body`) so it is not * clipped by parent `overflow: hidden` / transform containing blocks. * Click-outside (transparent backdrop + `overlayOutsideClick`) and Escape * dismiss the panel. * * ## Prefix / suffix * * - **Control slots** — project into the shell with `[prefix]` / `[suffix]` * (same pattern as TextInput). Used as the empty-state affixes. Set * `[showTriggerIcon]="false"` when the suffix replaces the built-in caret * (caret/spinner sit in a trailing `FORM_AFFIX` gutter, not inside the * label button). * - **Option slots** — set `prefix` / `suffix` on each {@link SelectOption} to * an `IconName`. In the list/chips always; in **single-select**, when an * option is selected its icons replace the shell prefix/suffix slots. * * @typeParam T - Option value type. * * @example * ```html * * * * ``` * ```ts * warehouses = signal([ * { label: 'Lagos Hub', value: 'los', prefix: 'warehouse', suffix: 'globe' }, * ]); * ``` */ @Component({ selector: 'africanies-select', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [AfricaniesIconComponent, CdkOverlayOrigin, CdkConnectedOverlay], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SelectComponent), multi: true, }, ], host: { class: 'relative block', }, template: `
@if (shellPrefix(); as prefixIcon) { } @else if (shellPrefixImageUrl(); as prefixImageUrl) { } @else if (shellPrefixText(); as prefixText) { }
@if (multiple() && selectedList().length) { @for (chip of selectedList(); track trackOption(chip)) { @if (chip.prefix; as prefixIcon) { } @else if (chip.prefixImageUrl) { } @else if (chip.prefixText) { } {{ chip.label }} @if (chip.suffix; as suffixIcon) { } } }
@if (shellSuffix(); as suffixIcon) { } @if (loading()) { } @else if (showTriggerIcon()) { }
@if (searchable()) {
}
@if (loading()) {
{{ loadingLabel() }}
} @else { @if (create(); as createCfg) { } @if (showFreeTextRow()) { } } @if (filteredOptions().length === 0 && !showFreeTextRow() && !loading()) {
No matches
} @for (opt of filteredOptions(); track trackOption(opt); let i = $index) { } @if (multiple() && maxSelected() !== null && maxSelected() !== undefined) {
Up to {{ maxSelected() }} selected
}
@if (error(); as err) { } @else if (loading()) {

{{ loadingLabel() }}

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

{{ h }}

} `, }) /** * Option select following the shared AFRICANIES form field pattern. */ export class SelectComponent implements ControlValueAccessor { private readonly modal = inject(ModalService, { optional: true }); protected readonly modeColor = inject(ModeColorService); protected readonly controlId = `africanies-select-${++nextSelectId}`; protected readonly hintId = `${this.controlId}-hint`; protected readonly errorId = `${this.controlId}-error`; protected readonly labelClass = FORM_LABEL_CLASS; protected readonly affixClass = FORM_AFFIX_CLASS; /** Leading flag / avatar image in option rows and the single-select shell. */ protected readonly optionPrefixImageClass = 'size-4 shrink-0 rounded-full object-cover'; 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 overlaySearchClass = FORM_OVERLAY_SEARCH_CLASS; protected readonly panelPositions = SELECT_PANEL_POSITIONS; /** Floor width for the overlay panel when the trigger width is unknown. */ protected readonly minPanelWidth = 224; private readonly searchInput = viewChild>('searchInput'); private readonly triggerOrigin = viewChild(CdkOverlayOrigin); /** Visible field label. */ readonly label = input(''); /** Helper copy. Hidden while {@link error} is set. */ readonly hint = input(undefined); /** Field-level validation or load message. Distinct from `ErrorStateComponent`. */ readonly error = input(null); /** * When true, shows an inline Retry control next to {@link error}. * Use for async option load failures with `(retry)`. Leave false for * validation messages (e.g. “Type is required.”). */ readonly showRetry = input(false, { transform: booleanAttribute }); /** Retry link label when {@link showRetry} is true. */ readonly retryLabel = input('Retry'); /** Retry link copy while {@link retrying} is true. */ readonly retryingLabel = input('Retrying…'); /** * When true, the inline retry control is disabled and shows {@link retryingLabel}. */ readonly retrying = input(false, { transform: booleanAttribute }); /** * Emitted when the user activates the inline retry control. * Pair with {@link showRetry} for async option load failures. */ readonly retry = output(); /** * Emitted as the user types in the searchable panel filter. * Use for server-side option search; pair with {@link loading}. */ readonly searchChange = output(); /** * When true, options are loading — spinner on the trigger, panel, and hint row. * Blocks opening the panel until loading completes. */ readonly loading = input(false, { transform: booleanAttribute }); /** Copy shown while {@link loading} is true. */ readonly loadingLabel = input('Loading…'); /** Placeholder when nothing is selected. */ readonly placeholder = input(''); /** * Full option list (`[(options)]` / `(optionsChange)`). Parent usually owns * the canonical list — listen to `optionsChange` after create/free-text. */ readonly options = model[]>([]); /** * Current selection (`[(selected)]` / `(selectedChange)`). * * **Shape depends on {@link multiple}:** * - single → `SelectOption | null` * - multiple → `SelectOption[]` (full array on every change) * * Switching modes is a breaking type change for consumers. */ readonly selected = model | SelectOption[] | null>(null); /** * When true, show a search field that filters options by label * (case-insensitive substring). Required for {@link allowFreeText}. */ readonly searchable = input(false, { transform: booleanAttribute }); /** * When true (and {@link searchable}), show an inline “Add \” row when * the query does not exactly match an existing label. Independent of * {@link create}. */ readonly allowFreeText = input(false, { transform: booleanAttribute }); /** * When true, selection is `SelectOption[]` with chips + checkboxes; the * panel stays open after each pick. */ readonly multiple = input(false, { transform: booleanAttribute }); /** * Optional cap in multiple mode. Remaining unselected options disable when * reached (clicks are not silently ignored without UI feedback). */ readonly maxSelected = input(null, { transform: (v) => { if (v === null || v === undefined || v === '') { return null; } return numberAttribute(v); }, }); /** * Modal-create config. Opens via {@link ModalService}; result is mapped with * `mapResult`, appended to options, then selected. */ readonly create = input | null>(null); /** Host disable flag (in addition to CVA). */ readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute, }); /** * When false, hides the built-in `angle-down` trailing affix so callers can * supply a chevron via the `[suffix]` slot (e.g. compact page-size selects). * Loading still shows the spinner in that same trailing gutter. */ readonly showTriggerIcon = input(true, { transform: booleanAttribute }); /** * Control height. Defaults to `md` (`h-10`). Use `sm` (`h-8`) next to * sm `africanies-button` (pagination size picker). */ readonly size = input('md'); protected readonly open = signal(false); protected readonly searchQuery = signal(''); protected readonly activeIndex = signal(0); protected readonly cvaDisabled = signal(false); protected readonly panelWidth = signal('auto'); /** Suppresses re-entrant close when CDK emits `detach` after we already closed. */ private closingFromApi = false; protected readonly disabled = computed( () => this.disabledInput() || this.cvaDisabled(), ); /** True while async options are loading or a retry is in flight. */ protected readonly isBusy = computed( () => this.loading() || this.retrying(), ); /** Trigger is not interactive while disabled or loading. */ protected readonly triggerDisabled = computed( () => this.disabled() || this.isBusy(), ); protected readonly shellClass = computed(() => { let classes = FORM_FIELD_CLASS; if (this.error()) { classes += ` ${FORM_FIELD_ERROR_CLASS}`; } if (this.size() === 'sm') { classes += ' !h-8'; } if (this.multiple() && this.selectedList().length) { classes += ' !h-auto min-h-10 items-stretch py-1.5'; } if (this.disabled()) { classes += ` ${FORM_DISABLED_CLASS}`; } else if (this.loading()) { classes += ' cursor-wait'; } else { // Whole shell is the hit target (prefix/suffix affixes sit outside the // trigger button); pointer cues that. classes += ' cursor-pointer'; } return classes; }); protected readonly describedBy = computed(() => { if (this.error()) { return this.errorId; } if (this.loading()) { return this.hintId; } if (this.hint()) { return this.hintId; } return null; }); protected readonly selectedList = computed((): SelectOption[] => { const sel = this.selected(); if (this.multiple()) { return Array.isArray(sel) ? sel : []; } return sel && !Array.isArray(sel) ? [sel] : []; }); protected readonly filteredOptions = computed(() => { const q = this.searchQuery().trim().toLowerCase(); const opts = this.options(); if (!this.searchable() || !q) { return opts; } return opts.filter((o) => o.label.toLowerCase().includes(q)); }); protected readonly showFreeTextRow = computed(() => { if (!this.allowFreeText() || !this.searchable()) { return false; } const q = this.searchQuery().trim(); if (!q) { return false; } return !this.options().some( (o) => o.label.toLowerCase() === q.toLowerCase(), ); }); protected readonly createIndex = computed(() => (this.create() ? 0 : -1)); protected readonly freeTextIndex = computed(() => { if (!this.showFreeTextRow()) { return -1; } return this.create() ? 1 : 0; }); protected readonly navigableCount = computed(() => { const create = this.create() ? 1 : 0; const free = this.showFreeTextRow() ? 1 : 0; return free + this.filteredOptions().length + create; }); protected readonly displayLabel = computed(() => { // Multiple mode shows chips; keep the trigger text empty so chips own the labels. if (this.multiple()) { return ''; } const sel = this.selected(); return sel && !Array.isArray(sel) ? sel.label : ''; }); /** True when multiple mode has at least one chip selected. */ protected readonly hasMultiSelection = computed( () => this.multiple() && this.selectedList().length > 0, ); /** * Loading spinner or built-in caret occupies the trailing affix gutter — * content area drops its right padding so the icon is not double-inset. */ protected readonly showTrailingTriggerIcon = computed( () => this.loading() || this.showTriggerIcon(), ); /** * Placeholder for the trigger — hidden in multiple mode once chips exist. */ protected readonly triggerText = computed(() => { if (this.hasMultiSelection()) { return ''; } return this.placeholder() || 'Select…'; }); /** * Single-select current option (for shell prefix/suffix icons). * `null` in multiple mode or when nothing is selected. */ protected readonly singleSelected = computed((): SelectOption | null => { if (this.multiple()) { return null; } const sel = this.selected(); return sel && !Array.isArray(sel) ? sel : null; }); /** Selected option prefix → shell prefix slot (single-select only). */ protected readonly shellPrefix = computed( () => this.singleSelected()?.prefix ?? null, ); /** Selected option image prefix → shell prefix slot (single-select only). */ protected readonly shellPrefixImageUrl = computed(() => { if (this.shellPrefix()) { return null; } return this.singleSelected()?.prefixImageUrl ?? null; }); /** Selected option text prefix → shell prefix slot (single-select only). */ protected readonly shellPrefixText = computed(() => { if (this.shellPrefix() || this.shellPrefixImageUrl()) { return null; } return this.singleSelected()?.prefixText ?? null; }); /** Selected option suffix → shell suffix slot (single-select only). */ protected readonly shellSuffix = computed( () => this.singleSelected()?.suffix ?? null, ); private onChange: ( value: SelectOption | SelectOption[] | null, ) => void = () => undefined; private onTouched: () => void = () => undefined; /** * Active/hover option classes — soft mode accent (not near-white * `background-welcome`, which made light text illegible in dark mode). * * @param filteredIndex - Index within {@link filteredOptions}. * @returns Tailwind class string for the option button. */ protected optionClass(filteredIndex: number): string { const active = this.activeIndex() === this.optionIndex(filteredIndex); const colors = this.modeColor.classes(); const base = 'flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-body text-ink dark:text-white disabled:opacity-50 disabled:cursor-not-allowed'; return active ? `${base} ${colors.soft}` : `${base} ${colors.softHover}`; } /** @returns Classes for the free-text create row. */ protected freeTextClass(): string { const active = this.activeIndex() === this.freeTextIndex(); const colors = this.modeColor.classes(); const base = `flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-body ${colors.text}`; return active ? `${base} ${colors.soft}` : `${base} ${colors.softHover}`; } /** @returns Classes for the modal-create trigger row. */ protected createRowClass(): string { const active = this.activeIndex() === this.createIndex(); const colors = this.modeColor.classes(); const base = `flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-body ${colors.text}`; return active ? `${base} ${colors.soft}` : `${base} ${colors.softHover}`; } /** * Multi-select checkbox chrome for a list option. * @param opt - Option whose selection state drives the mark. * @returns Tailwind class string for the checkbox glyph. */ protected multiCheckClass(opt: SelectOption): string { const base = 'inline-flex size-4 items-center justify-center rounded border shrink-0 text-white dark:border-white/20'; if (!this.isSelected(opt)) { return `${base} border-neutral-400`; } const c = this.modeColor.classes(); return `${base} ${c.bg} ${c.border}`; } /** * @param value - Selection; shape must match {@link multiple}. */ writeValue(value: SelectOption | SelectOption[] | null): void { this.selected.set(value); } /** * @param fn - Selection change for Reactive Forms. */ registerOnChange( fn: (value: SelectOption | SelectOption[] | null) => void, ): void { this.onChange = fn; } /** @param fn - Touched callback. */ registerOnTouched(fn: () => void): void { this.onTouched = fn; } /** @param isDisabled - Blocks interaction when true. */ setDisabledState(isDisabled: boolean): void { this.cvaDisabled.set(isDisabled); } protected trackOption(opt: SelectOption): string { return `${opt.label}:${String(opt.value)}`; } protected optionIndex(filteredIndex: number): number { return ( (this.create() ? 1 : 0) + (this.showFreeTextRow() ? 1 : 0) + filteredIndex ); } protected isSelected(opt: SelectOption): boolean { return this.selectedList().some((s) => Object.is(s.value, opt.value)); } protected isOptionDisabled(opt: SelectOption): boolean { if (opt.disabled || this.disabled() || this.loading()) { return true; } if (!this.multiple()) { return false; } const max = this.maxSelected(); if (max == null) { return false; } if (this.isSelected(opt)) { return false; } return this.selectedList().length >= max; } /** * Opens/closes when the user clicks the shell chrome (prefix/suffix slots * sit outside the trigger button). */ protected onShellClick(): void { this.toggleOpen(); } /** * Trigger button path — stop bubbling so {@link onShellClick} does not * double-toggle. * @param event - Pointer event from the listbox trigger. */ protected onTriggerClick(event: MouseEvent): void { event.stopPropagation(); this.toggleOpen(); } protected toggleOpen(): void { if (this.triggerDisabled()) { return; } if (this.open()) { this.closePanel(); return; } this.openPanel(); } /** Sync panel width to the trigger shell (exact match when measurable). */ protected openPanel(): void { const origin = this.triggerOrigin()?.elementRef.nativeElement as | HTMLElement | undefined; const triggerWidth = origin?.getBoundingClientRect().width ?? 0; this.panelWidth.set( triggerWidth > 0 ? triggerWidth : this.minPanelWidth, ); this.open.set(true); this.activeIndex.set(0); queueMicrotask(() => this.searchInput()?.nativeElement.focus()); } protected closePanel(): void { if (!this.open()) { return; } this.closingFromApi = true; this.open.set(false); this.onTouched(); queueMicrotask(() => { this.closingFromApi = false; }); } /** * Dismiss when the user clicks outside the panel. * Clicks on the trigger are ignored so {@link toggleOpen} owns that path * (avoids close-then-reopen when the transparent backdrop is not covering). * @param event */ protected onOutsideClick(event: MouseEvent): void { const origin = this.triggerOrigin()?.elementRef.nativeElement as | HTMLElement | undefined; const target = event.target as Node | null; if (origin && target && origin.contains(target)) { return; } this.closePanel(); } /** CDK detach (e.g. scroll strategy) — keep `open` in sync without double-touch. */ protected onOverlayDetach(): void { if (this.closingFromApi) { return; } if (this.open()) { this.open.set(false); this.onTouched(); } } protected onSearchInput(event: Event): void { const value = (event.target as HTMLInputElement).value; this.searchQuery.set(value); this.activeIndex.set(0); this.searchChange.emit(value); } /** * Search sits inside the listbox; without stopping propagation, Enter / * arrows run {@link onPanelKeydown} twice (input then listbox) and the * second pass toggles the first option — deselecting an existing chip * right after a free-text add. * @param event */ protected onSearchKeydown(event: KeyboardEvent): void { event.stopPropagation(); this.onPanelKeydown(event); } protected onFreeTextClick(event: MouseEvent): void { event.stopPropagation(); this.commitFreeText(); } protected onCreateClick(event: MouseEvent): void { event.stopPropagation(); this.openCreateModal(); } protected onTriggerKeydown(event: KeyboardEvent): void { if (this.disabled()) { return; } if ( event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ' ) { event.preventDefault(); if (!this.open()) { this.openPanel(); } else if (event.key === 'Enter' || event.key === ' ') { this.activateCurrent(); } else { this.moveActive(event.key === 'ArrowDown' ? 1 : -1); } } else if (event.key === 'Escape') { this.closePanel(); } } protected onPanelKeydown(event: KeyboardEvent): void { if (event.key === 'ArrowDown') { event.preventDefault(); this.moveActive(1); } else if (event.key === 'ArrowUp') { event.preventDefault(); this.moveActive(-1); } else if (event.key === 'Enter') { event.preventDefault(); this.activateCurrent(); } else if (event.key === 'Escape') { event.preventDefault(); this.closePanel(); } } protected moveActive(delta: number): void { const count = this.navigableCount(); if (count === 0) { return; } const next = (this.activeIndex() + delta + count) % count; this.activeIndex.set(next); } protected activateCurrent(): void { const idx = this.activeIndex(); if (this.showFreeTextRow() && idx === this.freeTextIndex()) { this.commitFreeText(); return; } if (this.create() && idx === this.createIndex()) { this.openCreateModal(); return; } const optIdx = idx - (this.create() ? 1 : 0) - (this.showFreeTextRow() ? 1 : 0); const opt = this.filteredOptions()[optIdx]; if (opt) { this.selectOption(opt); } } protected selectOption(opt: SelectOption): void { if (this.isOptionDisabled(opt)) { return; } if (this.multiple()) { const current = [...this.selectedList()]; const existing = current.findIndex((s) => Object.is(s.value, opt.value)); if (existing >= 0) { current.splice(existing, 1); } else { current.push(opt); } this.commitSelection(current); // Stay open so the user can pick several without reopening. return; } this.commitSelection(opt); this.closePanel(); this.searchQuery.set(''); } protected removeChip(opt: SelectOption, event: Event): void { event.stopPropagation(); if (this.disabled()) { return; } const next = this.selectedList().filter( (s) => !Object.is(s.value, opt.value), ); this.commitSelection(next); } protected commitFreeText(): void { const label = this.searchQuery().trim(); if (!label) { return; } const option = { label, value: label as T, } as SelectOption; this.appendCreatedOption(option); } protected openCreateModal(): void { const cfg = this.create(); if (!cfg) { return; } if (!this.modal) { // WHY console error: create requires provideAfricaniesUiOverlays() / ModalService. console.error( '[africanies-select] create requires ModalService — call provideAfricaniesUiOverlays() at bootstrap.', ); return; } const handle = this.modal.open(cfg.component, { data: cfg.data }); handle.afterClosed().subscribe((result) => { if (result === undefined || result === null) { return; } const option = cfg.mapResult(result); this.appendCreatedOption(option); }); } private appendCreatedOption(option: SelectOption): void { // model() emits optionsChange for parents that persist the canonical list. this.options.set([...this.options(), option]); if (this.multiple()) { const max = this.maxSelected(); const list = [...this.selectedList()]; if (!list.some((s) => Object.is(s.value, option.value))) { if (max == null || list.length < max) { list.push(option); } } this.commitSelection(list); this.searchQuery.set(''); return; } this.commitSelection(option); this.searchQuery.set(''); this.closePanel(); } private commitSelection( next: SelectOption | SelectOption[] | null, ): void { // model() emits selectedChange; CVA still needs onChange for form controls. this.selected.set(next); this.onChange(next); } protected onBlur(): void { this.onTouched(); } protected onRetry(event: MouseEvent): void { event.preventDefault(); event.stopPropagation(); if (this.disabled() || this.retrying()) { return; } this.retry.emit(); } }