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