import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift } from '@floating-ui/dom'; import { uid } from '../shared/dom'; import { DROPDOWN_CLOSE_EVENT, DROPDOWN_OPEN_EVENT, type DropdownOptions, type Placement, } from './dropdown.types'; const SELECTORS = { trigger: '[data-c42-dropdown-trigger]', menu: '[data-c42-dropdown-menu]', item: '[data-c42-dropdown-item]', } as const; /** * Headless dropdown/menu controller. Handles toggle, outside-click dismissal, * keyboard navigation, ARIA wiring and floating-ui positioning. * * Markup: * ```html *
* *
* * *
*
* ``` */ export class Dropdown { private readonly root: HTMLElement; private readonly trigger: HTMLElement; private readonly menu: HTMLElement; private readonly placement: Placement; private readonly offset: number; private readonly closeOnSelect: boolean; private open = false; private stopAutoUpdate: (() => void) | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: DropdownOptions = {}) { const trigger = root.querySelector(SELECTORS.trigger); const menu = root.querySelector(SELECTORS.menu); if (!trigger || !menu) { throw new Error('[42/dropdown] Needs a trigger and a menu element.'); } this.root = root; this.trigger = trigger; this.menu = menu; this.placement = options.placement ?? 'bottom-start'; this.offset = options.offset ?? 4; this.closeOnSelect = options.closeOnSelect ?? true; this.init(); } private get items(): HTMLElement[] { return Array.from(this.menu.querySelectorAll(SELECTORS.item)); } private init(): void { const menuId = this.menu.id || uid('dropdown-menu'); this.menu.id = menuId; this.trigger.setAttribute('aria-haspopup', 'menu'); this.trigger.setAttribute('aria-controls', menuId); this.trigger.setAttribute('aria-expanded', 'false'); this.menu.setAttribute('role', 'menu'); this.menu.setAttribute('hidden', ''); this.menu.dataset.state = 'closed'; this.trigger.dataset.state = 'closed'; this.items.forEach((item) => item.setAttribute('role', 'menuitem')); const onTriggerClick = (): void => this.toggle(); const onTriggerKeydown = (event: Event): void => this.onTriggerKeydown(event as KeyboardEvent); const onMenuKeydown = (event: Event): void => this.onMenuKeydown(event as KeyboardEvent); const onDocumentPointer = (event: Event): void => this.onOutsidePointer(event); this.trigger.addEventListener('click', onTriggerClick); this.trigger.addEventListener('keydown', onTriggerKeydown); this.menu.addEventListener('keydown', onMenuKeydown); this.menu.addEventListener('click', this.onMenuClick); document.addEventListener('pointerdown', onDocumentPointer, true); this.cleanups.push( () => this.trigger.removeEventListener('click', onTriggerClick), () => this.trigger.removeEventListener('keydown', onTriggerKeydown), () => this.menu.removeEventListener('keydown', onMenuKeydown), () => this.menu.removeEventListener('click', this.onMenuClick), () => document.removeEventListener('pointerdown', onDocumentPointer, true), ); } private readonly onMenuClick = (event: Event): void => { const target = (event.target as HTMLElement).closest(SELECTORS.item); if (target && this.closeOnSelect) { this.close(); this.trigger.focus(); } }; private onOutsidePointer(event: Event): void { if (!this.open) { return; } if (!this.root.contains(event.target as Node)) { this.close(); } } private onTriggerKeydown(event: KeyboardEvent): void { if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this.openMenu(); this.focusItem(0); } } private onMenuKeydown(event: KeyboardEvent): void { const items = this.items; const currentIndex = items.indexOf(document.activeElement as HTMLElement); switch (event.key) { case 'ArrowDown': event.preventDefault(); this.focusItem((currentIndex + 1) % items.length); break; case 'ArrowUp': event.preventDefault(); this.focusItem((currentIndex - 1 + items.length) % items.length); break; case 'Home': event.preventDefault(); this.focusItem(0); break; case 'End': event.preventDefault(); this.focusItem(items.length - 1); break; case 'Escape': event.preventDefault(); this.close(); this.trigger.focus(); break; case 'Tab': this.close(); break; default: break; } } private focusItem(index: number): void { this.items[index]?.focus(); } toggle(): void { if (this.open) { this.close(); } else { this.openMenu(); } } openMenu(): void { if (this.open) { return; } this.open = true; this.menu.removeAttribute('hidden'); this.menu.dataset.state = 'open'; this.trigger.dataset.state = 'open'; this.trigger.setAttribute('aria-expanded', 'true'); this.stopAutoUpdate = autoUpdate(this.trigger, this.menu, () => void this.position()); this.root.dispatchEvent(new CustomEvent(DROPDOWN_OPEN_EVENT, { bubbles: true })); } close(): void { if (!this.open) { return; } this.open = false; this.menu.setAttribute('hidden', ''); this.menu.dataset.state = 'closed'; this.trigger.dataset.state = 'closed'; this.trigger.setAttribute('aria-expanded', 'false'); this.stopAutoUpdate?.(); this.stopAutoUpdate = null; this.root.dispatchEvent(new CustomEvent(DROPDOWN_CLOSE_EVENT, { bubbles: true })); } private async position(): Promise { const { x, y } = await computePosition(this.trigger, this.menu, { placement: this.placement, middleware: [offsetMiddleware(this.offset), flip(), shift({ padding: 5 })], }); Object.assign(this.menu.style, { left: `${x}px`, top: `${y}px`, position: 'absolute' }); } 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.close(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }