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