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 = [];
}
}