import { toArray, uid } from '../shared/dom'; import { COMBOBOX_CHANGE_EVENT, COMBOBOX_CLOSE_EVENT, COMBOBOX_OPEN_EVENT, type ComboboxChangeDetail, type ComboboxOptions, } from './combobox.types'; const SELECTORS = { input: '[data-c42-combobox-input]', list: '[data-c42-combobox-list]', option: '[data-c42-combobox-option]', } as const; /** * Headless combobox / multiselect. Filters a static option list, supports single * or multiple selection, full keyboard navigation and ARIA wiring. * * Markup: * ```html *
* * *
* ``` */ export class Combobox { private readonly root: HTMLElement; private readonly input: HTMLInputElement; private readonly list: HTMLElement; private readonly multiple: boolean; private readonly filterEnabled: boolean; private readonly options: HTMLElement[]; private readonly selected = new Set(); private open = false; private activeIndex = -1; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: ComboboxOptions = {}) { const input = root.querySelector(SELECTORS.input); const list = root.querySelector(SELECTORS.list); if (!input || !list) { throw new Error('[42/combobox] Needs an input and a list element.'); } this.root = root; this.input = input; this.list = list; this.multiple = options.multiple ?? false; this.filterEnabled = options.filter ?? true; this.options = Array.from(list.querySelectorAll(SELECTORS.option)); this.init(options.defaultValue ?? null); } private valueOf(option: HTMLElement): string { return option.dataset.value ?? option.textContent?.trim() ?? ''; } private init(defaultValue: string | string[] | null): void { const listId = this.list.id || uid('combobox-list'); this.list.id = listId; this.list.setAttribute('role', 'listbox'); if (this.multiple) { this.list.setAttribute('aria-multiselectable', 'true'); } this.list.setAttribute('hidden', ''); this.list.dataset.state = 'closed'; this.input.setAttribute('role', 'combobox'); this.input.setAttribute('aria-expanded', 'false'); this.input.setAttribute('aria-controls', listId); this.input.setAttribute('aria-autocomplete', 'list'); toArray(defaultValue).forEach((value) => this.selected.add(value)); this.options.forEach((option) => { const optionId = option.id || uid('combobox-option'); option.id = optionId; option.setAttribute('role', 'option'); const isSelected = this.selected.has(this.valueOf(option)); option.setAttribute('aria-selected', String(isSelected)); option.dataset.selected = String(isSelected); }); const onFocus = (): void => this.openList(); const onInput = (): void => this.onInput(); const onKeydown = (event: KeyboardEvent): void => this.onKeydown(event); const onListClick = (event: Event): void => this.onListClick(event); const onPointerDown = (event: Event): void => this.onOutside(event); this.input.addEventListener('focus', onFocus); this.input.addEventListener('input', onInput); this.input.addEventListener('keydown', onKeydown); this.list.addEventListener('click', onListClick); document.addEventListener('pointerdown', onPointerDown, true); this.cleanups.push( () => this.input.removeEventListener('focus', onFocus), () => this.input.removeEventListener('input', onInput), () => this.input.removeEventListener('keydown', onKeydown), () => this.list.removeEventListener('click', onListClick), () => document.removeEventListener('pointerdown', onPointerDown, true), ); this.syncSingleInputLabel(); } private get visibleOptions(): HTMLElement[] { return this.options.filter((option) => !option.hasAttribute('hidden')); } private onInput(): void { if (this.filterEnabled) { this.filter(this.input.value); } this.openList(); } private filter(query: string): void { const needle = query.trim().toLowerCase(); this.options.forEach((option) => { const haystack = (option.dataset.search ?? option.textContent ?? '').toLowerCase(); const match = needle === '' || haystack.includes(needle); option.toggleAttribute('hidden', !match); }); this.activeIndex = -1; this.updateActive(); } private onListClick(event: Event): void { const option = (event.target as HTMLElement).closest(SELECTORS.option); if (option && this.options.includes(option)) { this.toggleOption(option); } } private onKeydown(event: KeyboardEvent): void { const visible = this.visibleOptions; switch (event.key) { case 'ArrowDown': event.preventDefault(); if (!this.open) { this.openList(); } this.activeIndex = Math.min(this.activeIndex + 1, visible.length - 1); this.updateActive(); break; case 'ArrowUp': event.preventDefault(); this.activeIndex = Math.max(this.activeIndex - 1, 0); this.updateActive(); break; case 'Enter': { const active = visible[this.activeIndex]; if (this.open && active) { event.preventDefault(); this.toggleOption(active); } break; } case 'Escape': this.closeList(); break; default: break; } } private updateActive(): void { const visible = this.visibleOptions; this.options.forEach((option) => delete option.dataset.active); const active = visible[this.activeIndex]; if (active) { active.dataset.active = ''; this.input.setAttribute('aria-activedescendant', active.id); } else { this.input.removeAttribute('aria-activedescendant'); } } private toggleOption(option: HTMLElement): void { const value = this.valueOf(option); if (this.multiple) { if (this.selected.has(value)) { this.selected.delete(value); } else { this.selected.add(value); } } else { this.selected.clear(); this.selected.add(value); } this.options.forEach((opt) => { const isSelected = this.selected.has(this.valueOf(opt)); opt.setAttribute('aria-selected', String(isSelected)); opt.dataset.selected = String(isSelected); }); if (!this.multiple) { this.syncSingleInputLabel(); this.closeList(); } this.emit(); } private syncSingleInputLabel(): void { if (this.multiple) { return; } const [value] = [...this.selected]; const option = this.options.find((opt) => this.valueOf(opt) === value); if (option) { this.input.value = option.textContent?.trim() ?? ''; } } openList(): void { if (this.open) { return; } this.open = true; this.list.removeAttribute('hidden'); this.list.dataset.state = 'open'; this.input.setAttribute('aria-expanded', 'true'); this.root.dispatchEvent(new CustomEvent(COMBOBOX_OPEN_EVENT, { bubbles: true })); } closeList(): void { if (!this.open) { return; } this.open = false; this.list.setAttribute('hidden', ''); this.list.dataset.state = 'closed'; this.input.setAttribute('aria-expanded', 'false'); this.input.removeAttribute('aria-activedescendant'); this.activeIndex = -1; this.updateActive(); this.root.dispatchEvent(new CustomEvent(COMBOBOX_CLOSE_EVENT, { bubbles: true })); } private onOutside(event: Event): void { if (this.open && !this.root.contains(event.target as Node)) { this.closeList(); } } private emit(): void { const detail: ComboboxChangeDetail = { values: [...this.selected] }; this.root.dispatchEvent(new CustomEvent(COMBOBOX_CHANGE_EVENT, { detail, bubbles: true })); } get values(): string[] { return [...this.selected]; } get isOpen(): boolean { return this.open; } on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.closeList(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }