import { toArray, uid } from '../shared/dom'; import { ACCORDION_CHANGE_EVENT, type AccordionChangeDetail, type AccordionOptions, } from './accordion.types'; interface AccordionItem { value: string; item: HTMLElement; trigger: HTMLElement; panel: HTMLElement; } const SELECTORS = { item: '[data-c42-accordion-item]', trigger: '[data-c42-accordion-trigger]', panel: '[data-c42-accordion-panel]', } as const; /** * Headless accordion controller. Attaches behaviour, ARIA and keyboard support * to existing markup; it never applies visual styles. * * Expected markup: * ```html *
*
* *
Content
*
*
* ``` */ export class Accordion { private readonly root: HTMLElement; private readonly multiple: boolean; private readonly collapsible: boolean; private items: AccordionItem[] = []; private readonly open = new Set(); private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: AccordionOptions = {}) { this.root = root; this.multiple = options.multiple ?? false; this.collapsible = options.collapsible ?? true; this.collect(); this.init(options.defaultValue ?? null); } private collect(): void { const itemEls = Array.from(this.root.querySelectorAll(SELECTORS.item)); this.items = itemEls.map((item, index) => { const trigger = item.querySelector(SELECTORS.trigger); const panel = item.querySelector(SELECTORS.panel); if (!trigger || !panel) { throw new Error('[42/accordion] Each item must contain a trigger and a panel.'); } const value = item.dataset.value ?? String(index); return { value, item, trigger, panel }; }); } private init(defaultValue: string | string[] | null): void { const initial = toArray(defaultValue); this.items.forEach(({ value, trigger, panel }, index) => { const triggerId = trigger.id || uid('acc-trigger'); const panelId = panel.id || uid('acc-panel'); trigger.id = triggerId; panel.id = panelId; if (trigger.tagName !== 'BUTTON') { trigger.setAttribute('role', 'button'); if (!trigger.hasAttribute('tabindex')) { trigger.setAttribute('tabindex', '0'); } } trigger.setAttribute('aria-controls', panelId); panel.setAttribute('role', 'region'); panel.setAttribute('aria-labelledby', triggerId); // Wrap panel children for animated collapse if (!panel.querySelector('[data-c42-accordion-panel-content]')) { const wrapper = document.createElement('div'); wrapper.setAttribute('data-c42-accordion-panel-content', ''); while (panel.firstChild) wrapper.appendChild(panel.firstChild); panel.appendChild(wrapper); } const onClick = (): void => this.toggle(value); const onKeydown = (event: KeyboardEvent): void => this.onKeydown(event, index); trigger.addEventListener('click', onClick); trigger.addEventListener('keydown', onKeydown); this.cleanups.push(() => { trigger.removeEventListener('click', onClick); trigger.removeEventListener('keydown', onKeydown); }); if (initial.includes(value)) { this.open.add(value); } }); if (!this.multiple && this.open.size > 1) { const [first] = [...this.open]; this.open.clear(); if (first) { this.open.add(first); } } this.render(); } private onKeydown(event: KeyboardEvent, index: number): void { const triggers = this.items.map((entry) => entry.trigger); let next = -1; switch (event.key) { case 'ArrowDown': next = (index + 1) % triggers.length; break; case 'ArrowUp': next = (index - 1 + triggers.length) % triggers.length; break; case 'Home': next = 0; break; case 'End': next = triggers.length - 1; break; case 'Enter': case ' ': { const current = this.items[index]; if (current && current.trigger.tagName !== 'BUTTON') { event.preventDefault(); this.toggle(current.value); } return; } default: return; } event.preventDefault(); triggers[next]?.focus(); } private render(): void { this.items.forEach(({ value, item, trigger, panel }) => { const isOpen = this.open.has(value); const state = isOpen ? 'open' : 'closed'; item.dataset.state = state; trigger.dataset.state = state; panel.dataset.state = state; trigger.setAttribute('aria-expanded', String(isOpen)); panel.setAttribute('aria-hidden', String(!isOpen)); }); } private emit(): void { const detail: AccordionChangeDetail = { value: [...this.open] }; this.root.dispatchEvent(new CustomEvent(ACCORDION_CHANGE_EVENT, { detail, bubbles: true })); } toggle(value: string): void { if (this.open.has(value)) { if (!this.collapsible && this.open.size === 1) { return; } this.open.delete(value); } else { if (!this.multiple) { this.open.clear(); } this.open.add(value); } this.render(); this.emit(); } openItem(value: string): void { if (this.open.has(value)) { return; } if (!this.multiple) { this.open.clear(); } this.open.add(value); this.render(); this.emit(); } closeItem(value: string): void { if (!this.open.delete(value)) { return; } this.render(); this.emit(); } get value(): string[] { return [...this.open]; } /** 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.cleanups.forEach((fn) => fn()); this.cleanups = []; } }