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