import { uid } from '../shared/dom'; import { SELECT_CHANGE_EVENT, SELECT_CLOSE_EVENT, SELECT_OPEN_EVENT, type SelectChangeDetail, type SelectOptions, } from './select.types'; const SELECTORS = { trigger: '[data-c42-select-trigger]', value: '[data-c42-select-value]', listbox: '[data-c42-select-listbox]', option: '[data-c42-select-option]', } as const; /** * Headless single-select dropdown. Wires ARIA `listbox`/`option`, roving * `aria-activedescendant`, full keyboard navigation and selection to existing * markup; it never applies visual styles. * * Markup: * ```html *
* *
*
A
*
B
*
*
* ``` */ export class Select { private readonly root: HTMLElement; private readonly trigger: HTMLElement; private readonly valueEl: HTMLElement | null; private readonly listbox: HTMLElement; private readonly placeholder: string; private readonly options: HTMLElement[]; private selected: string | null = null; private opened = false; private activeIndex = -1; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: SelectOptions = {}) { const trigger = root.querySelector(SELECTORS.trigger); const listbox = root.querySelector(SELECTORS.listbox); if (!trigger || !listbox) { throw new Error('[42/select] Needs a [data-c42-select-trigger] and a [data-c42-select-listbox].'); } this.root = root; this.trigger = trigger; this.valueEl = trigger.querySelector(SELECTORS.value); this.listbox = listbox; this.placeholder = options.placeholder ?? this.valueEl?.textContent?.trim() ?? ''; this.options = Array.from(listbox.querySelectorAll(SELECTORS.option)); this.init(options.defaultValue ?? null); } private valueOf(option: HTMLElement): string { return option.dataset.value ?? option.textContent?.trim() ?? ''; } private labelOf(option: HTMLElement): string { return option.textContent?.trim() ?? ''; } private isDisabled(option: HTMLElement): boolean { return option.getAttribute('aria-disabled') === 'true' || option.dataset.disabled != null; } private init(defaultValue: string | null): void { const listboxId = this.listbox.id || uid('select-listbox'); this.listbox.id = listboxId; this.listbox.setAttribute('role', 'listbox'); this.listbox.setAttribute('hidden', ''); this.listbox.dataset.state = 'closed'; const triggerId = this.trigger.id || uid('select-trigger'); this.trigger.id = triggerId; this.trigger.setAttribute('aria-haspopup', 'listbox'); this.trigger.setAttribute('aria-expanded', 'false'); this.trigger.setAttribute('aria-controls', listboxId); this.trigger.dataset.state = 'closed'; this.root.dataset.state = 'closed'; this.options.forEach((option) => { const optionId = option.id || uid('select-option'); option.id = optionId; option.setAttribute('role', 'option'); option.setAttribute('aria-selected', 'false'); }); const hasDefault = defaultValue != null && this.options.some((opt) => this.valueOf(opt) === defaultValue); if (hasDefault) { this.applySelection(defaultValue); } else { this.renderSelection(); } const onTriggerClick = (): void => this.toggle(); const onTriggerKeydown = (event: KeyboardEvent): void => this.onTriggerKeydown(event); const onListboxClick = (event: Event): void => this.onListboxClick(event); const onListboxKeydown = (event: KeyboardEvent): void => this.onKeydown(event); const onPointerDown = (event: Event): void => this.onOutside(event); this.trigger.addEventListener('click', onTriggerClick); this.trigger.addEventListener('keydown', onTriggerKeydown); this.listbox.addEventListener('click', onListboxClick); this.listbox.addEventListener('keydown', onListboxKeydown); document.addEventListener('pointerdown', onPointerDown, true); this.cleanups.push( () => this.trigger.removeEventListener('click', onTriggerClick), () => this.trigger.removeEventListener('keydown', onTriggerKeydown), () => this.listbox.removeEventListener('click', onListboxClick), () => this.listbox.removeEventListener('keydown', onListboxKeydown), () => document.removeEventListener('pointerdown', onPointerDown, true), ); } private get enabledOptions(): HTMLElement[] { return this.options.filter((option) => !this.isDisabled(option)); } private onTriggerKeydown(event: KeyboardEvent): void { switch (event.key) { case 'ArrowDown': case 'ArrowUp': case 'Enter': case ' ': event.preventDefault(); if (!this.opened) { this.open(); } break; default: break; } } private onKeydown(event: KeyboardEvent): void { const enabled = this.enabledOptions; if (enabled.length === 0) { return; } switch (event.key) { case 'ArrowDown': event.preventDefault(); this.activeIndex = Math.min(this.activeIndex + 1, enabled.length - 1); this.updateActive(); break; case 'ArrowUp': event.preventDefault(); this.activeIndex = Math.max(this.activeIndex - 1, 0); this.updateActive(); break; case 'Home': event.preventDefault(); this.activeIndex = 0; this.updateActive(); break; case 'End': event.preventDefault(); this.activeIndex = enabled.length - 1; this.updateActive(); break; case 'Enter': case ' ': { const active = enabled[this.activeIndex]; if (active) { event.preventDefault(); this.selectOption(active); } break; } case 'Escape': event.preventDefault(); this.close(); this.trigger.focus(); break; default: break; } } private onListboxClick(event: Event): void { const option = (event.target as HTMLElement).closest(SELECTORS.option); if (option && this.options.includes(option) && !this.isDisabled(option)) { this.selectOption(option); } } private updateActive(): void { const enabled = this.enabledOptions; this.options.forEach((option) => delete option.dataset.active); const active = enabled[this.activeIndex]; if (active) { active.dataset.active = ''; this.listbox.setAttribute('aria-activedescendant', active.id); } else { this.listbox.removeAttribute('aria-activedescendant'); } } private selectOption(option: HTMLElement): void { this.applySelection(this.valueOf(option)); this.emit(); this.close(); this.trigger.focus(); } private applySelection(value: string): void { this.selected = value; this.renderSelection(); } private renderSelection(): void { const current = this.options.find((opt) => this.valueOf(opt) === this.selected); this.options.forEach((option) => { const isSelected = option === current; option.setAttribute('aria-selected', String(isSelected)); option.dataset.selected = String(isSelected); }); if (this.valueEl) { this.valueEl.textContent = current ? this.labelOf(current) : this.placeholder; } } private syncActiveToSelection(): void { const enabled = this.enabledOptions; const selectedIndex = enabled.findIndex((opt) => this.valueOf(opt) === this.selected); this.activeIndex = selectedIndex >= 0 ? selectedIndex : 0; this.updateActive(); } private onOutside(event: Event): void { if (this.opened && !this.root.contains(event.target as Node)) { this.close(); } } private emit(): void { const current = this.options.find((opt) => this.valueOf(opt) === this.selected); const detail: SelectChangeDetail = { value: this.selected ?? '', label: current ? this.labelOf(current) : '', }; this.root.dispatchEvent(new CustomEvent(SELECT_CHANGE_EVENT, { detail, bubbles: true })); } /** Open the listbox. No-op if already open. */ open(): void { if (this.opened) { return; } this.opened = true; this.listbox.removeAttribute('hidden'); this.listbox.dataset.state = 'open'; this.trigger.setAttribute('aria-expanded', 'true'); this.trigger.dataset.state = 'open'; this.root.dataset.state = 'open'; this.syncActiveToSelection(); this.root.dispatchEvent(new CustomEvent(SELECT_OPEN_EVENT, { bubbles: true })); } /** Close the listbox. No-op if already closed. */ close(): void { if (!this.opened) { return; } this.opened = false; this.listbox.setAttribute('hidden', ''); this.listbox.dataset.state = 'closed'; this.trigger.setAttribute('aria-expanded', 'false'); this.trigger.dataset.state = 'closed'; this.root.dataset.state = 'closed'; this.listbox.removeAttribute('aria-activedescendant'); this.activeIndex = -1; this.options.forEach((option) => delete option.dataset.active); this.root.dispatchEvent(new CustomEvent(SELECT_CLOSE_EVENT, { bubbles: true })); } /** Toggle the listbox open/closed. */ toggle(): void { if (this.opened) { this.close(); } else { this.open(); } } get value(): string | null { return this.selected; } /** Programmatically select a value. No-op if unknown or disabled. */ setValue(value: string): void { const option = this.options.find((opt) => this.valueOf(opt) === value); if (!option || this.isDisabled(option) || value === this.selected) { return; } this.applySelection(value); this.emit(); } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ 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.close(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }