import { uid } from '../shared/dom'; import { getFocusable } from '../shared/focus'; import { MEGA_MENU_CLOSE_EVENT, MEGA_MENU_OPEN_EVENT, type MegaMenuCloseDetail, type MegaMenuOpenDetail, type MegaMenuOptions, } from './mega-menu.types'; const SELECTORS = { item: '[data-c42-mega-menu-item]', trigger: ':scope > [data-c42-mega-menu-trigger]', panel: ':scope > [data-c42-mega-menu-panel]', } as const; interface MenuItem { el: HTMLElement; trigger: HTMLElement; panel: HTMLElement | null; value: string; } /** * Headless mega-menu controller. A horizontal bar of top-level triggers, each * optionally opening a large multi-column panel. Opens on hover/focus/click, * closes on Escape / outside-click / focus-leave, and supports arrow-key * navigation between triggers. Implements the disclosure-menu ARIA pattern * (buttons with `aria-expanded`/`aria-controls`); it applies no visual styles. * * Markup: * ```html * * ``` */ export class MegaMenu { private readonly root: HTMLElement; private readonly items: MenuItem[]; private readonly openOnHover: boolean; private readonly openDelay: number; private readonly closeDelay: number; private openItem: MenuItem | null = null; private timer: ReturnType | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: MegaMenuOptions = {}) { this.root = root; this.openOnHover = options.openOnHover ?? true; this.openDelay = options.openDelay ?? 0; this.closeDelay = options.closeDelay ?? 150; this.items = this.collectItems(); this.init(); } private collectItems(): MenuItem[] { return Array.from(this.root.querySelectorAll(SELECTORS.item)) .map((el) => { const trigger = el.querySelector(SELECTORS.trigger); if (!trigger) { return null; } const panel = el.querySelector(SELECTORS.panel); return { el, trigger, panel, value: trigger.dataset.value || el.dataset.value || uid('mega-item'), }; }) .filter((item): item is MenuItem => item !== null); } private get triggers(): HTMLElement[] { return this.items.map((item) => item.trigger); } private init(): void { for (const item of this.items) { if (item.panel) { const panelId = item.panel.id || uid('mega-panel'); item.panel.id = panelId; item.trigger.setAttribute('aria-haspopup', 'true'); item.trigger.setAttribute('aria-controls', panelId); item.trigger.setAttribute('aria-expanded', 'false'); if (item.trigger instanceof HTMLButtonElement && !item.trigger.hasAttribute('type')) { item.trigger.type = 'button'; } item.panel.setAttribute('hidden', ''); item.panel.dataset.state = 'closed'; item.el.dataset.state = 'closed'; const onTriggerClick = (): void => this.toggle(item.value); item.trigger.addEventListener('click', onTriggerClick); this.cleanups.push(() => item.trigger.removeEventListener('click', onTriggerClick)); if (this.openOnHover) { const onEnter = (): void => this.scheduleOpen(item); const onLeave = (): void => this.scheduleClose(); item.el.addEventListener('mouseenter', onEnter); item.el.addEventListener('mouseleave', onLeave); this.cleanups.push( () => item.el.removeEventListener('mouseenter', onEnter), () => item.el.removeEventListener('mouseleave', onLeave), ); } } const onKeydown = (event: Event): void => this.onTriggerKeydown(item, event as KeyboardEvent); item.trigger.addEventListener('keydown', onKeydown); this.cleanups.push(() => item.trigger.removeEventListener('keydown', onKeydown)); } const onRootKeydown = (event: Event): void => { if ((event as KeyboardEvent).key === 'Escape' && this.openItem) { const trigger = this.openItem.trigger; this.closeAll(); trigger.focus(); } }; const onOutsidePointer = (event: Event): void => { if (this.openItem && !this.root.contains(event.target as Node)) { this.closeAll(); } }; const onFocusOut = (event: Event): void => { const next = (event as FocusEvent).relatedTarget as Node | null; if (this.openItem && (!next || !this.root.contains(next))) { this.closeAll(); } }; this.root.addEventListener('keydown', onRootKeydown); this.root.addEventListener('focusout', onFocusOut); document.addEventListener('pointerdown', onOutsidePointer, true); this.cleanups.push( () => this.root.removeEventListener('keydown', onRootKeydown), () => this.root.removeEventListener('focusout', onFocusOut), () => document.removeEventListener('pointerdown', onOutsidePointer, true), ); } private clearTimer(): void { if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; } } private scheduleOpen(item: MenuItem): void { this.clearTimer(); if (this.openDelay > 0) { this.timer = setTimeout(() => this.openMenu(item), this.openDelay); } else { this.openMenu(item); } } private scheduleClose(): void { this.clearTimer(); if (this.closeDelay > 0) { this.timer = setTimeout(() => this.closeAll(), this.closeDelay); } else { this.closeAll(); } } private onTriggerKeydown(item: MenuItem, event: KeyboardEvent): void { const triggers = this.triggers; const index = triggers.indexOf(item.trigger); switch (event.key) { case 'ArrowRight': event.preventDefault(); triggers[(index + 1) % triggers.length]!.focus(); break; case 'ArrowLeft': event.preventDefault(); triggers[(index - 1 + triggers.length) % triggers.length]!.focus(); break; case 'Home': event.preventDefault(); triggers[0]!.focus(); break; case 'End': event.preventDefault(); triggers[triggers.length - 1]!.focus(); break; case 'ArrowDown': if (item.panel) { event.preventDefault(); this.openMenu(item); (getFocusable(item.panel)[0] ?? item.panel).focus(); } break; default: break; } } private openMenu(item: MenuItem): void { this.clearTimer(); if (!item.panel) { return; } if (this.openItem && this.openItem !== item) { this.closeItem(this.openItem); } if (this.openItem === item) { return; } this.openItem = item; item.panel.removeAttribute('hidden'); item.panel.dataset.state = 'open'; item.el.dataset.state = 'open'; item.trigger.setAttribute('aria-expanded', 'true'); const detail: MegaMenuOpenDetail = { value: item.value }; this.root.dispatchEvent(new CustomEvent(MEGA_MENU_OPEN_EVENT, { detail, bubbles: true })); } private closeItem(item: MenuItem): void { if (!item.panel) { return; } item.panel.setAttribute('hidden', ''); item.panel.dataset.state = 'closed'; item.el.dataset.state = 'closed'; item.trigger.setAttribute('aria-expanded', 'false'); const detail: MegaMenuCloseDetail = { value: item.value }; this.root.dispatchEvent(new CustomEvent(MEGA_MENU_CLOSE_EVENT, { detail, bubbles: true })); } /** Open a panel by its item value. */ open(value: string): void { const item = this.items.find((i) => i.value === value); if (item) { this.openMenu(item); } } /** Toggle a panel by its item value. */ toggle(value: string): void { const item = this.items.find((i) => i.value === value); if (!item || !item.panel) { return; } if (this.openItem === item) { this.closeAll(); } else { this.openMenu(item); } } /** Close any open panel. */ closeAll(): void { this.clearTimer(); if (this.openItem) { this.closeItem(this.openItem); this.openItem = null; } } get openValue(): string | null { return this.openItem?.value ?? null; } /** 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.closeAll(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }