/** * Accordion — unit / DOM tests (Vitest + happy-dom). * * These exercise the public API and the ARIA contract documented in * `Accordion.ts` against a real (happy-dom) document, mirroring how a consumer * enhances semantic markup. Keyboard interactions that depend on real focus / * `activeElement` are covered by the Playwright `Accordion.e2e.ts` suite. */ import { describe, it, expect, vi } from 'vitest'; import { Accordion, type AccordionOptions } from './Accordion'; interface Section { title: string; body: string; /** Start expanded (adds `data-expanded` to the trigger). */ open?: boolean; } const SECTIONS: readonly Section[] = [ { title: 'One', body: 'Panel one.' }, { title: 'Two', body: 'Panel two.' }, { title: 'Three', body: 'Panel three.' }, ]; /** Build the semantic accordion markup and enhance it. */ function build( sections: readonly Section[] = SECTIONS, options: AccordionOptions = {}, ): { root: HTMLElement; instance: Accordion } { const root = document.createElement('div'); root.className = 'accordion'; for (const section of sections) { const heading = document.createElement('h3'); heading.className = 'accordion__heading'; const trigger = document.createElement('button'); trigger.type = 'button'; trigger.className = 'accordion__trigger'; trigger.textContent = section.title; if (section.open) trigger.setAttribute('data-expanded', ''); heading.append(trigger); const panel = document.createElement('div'); panel.className = 'accordion__panel'; const p = document.createElement('p'); p.textContent = section.body; panel.append(p); root.append(heading, panel); } document.body.append(root); const instance = new Accordion(root, options); return { root, instance }; } /** Convenience getters for the trigger / panel of item `i`. */ function trigger(root: HTMLElement, i: number): HTMLButtonElement { return root.querySelectorAll('.accordion__trigger')[i]!; } function panel(root: HTMLElement, i: number): HTMLElement { return root.querySelectorAll('.accordion__panel')[i]!; } describe('Accordion — construction', () => { it('throws when root is not an element', () => { // @ts-expect-error deliberately wrong type expect(() => new Accordion(null)).toThrow(TypeError); }); it('reports the number of items via `length`', () => { const { instance } = build(); expect(instance.length).toBe(3); }); }); describe('Accordion — initial ARIA wiring', () => { it('wires aria-controls, aria-labelledby, region role and hidden panels', () => { const { root } = build(); for (let i = 0; i < 3; i++) { const t = trigger(root, i); const p = panel(root, i); expect(t.getAttribute('aria-expanded')).toBe('false'); expect(t.getAttribute('aria-controls')).toBe(p.id); expect(t.id).not.toBe(''); expect(p.id).not.toBe(''); expect(p.getAttribute('role')).toBe('region'); expect(p.getAttribute('aria-labelledby')).toBe(t.id); expect(p.hidden).toBe(true); // The authoring hint is cleaned up. expect(t.hasAttribute('data-expanded')).toBe(false); } }); it('opens an item marked data-expanded and shows its panel', () => { const { root } = build([{ title: 'One', body: 'x', open: true }, ...SECTIONS.slice(1)]); expect(trigger(root, 0).getAttribute('aria-expanded')).toBe('true'); expect(panel(root, 0).hidden).toBe(false); }); it('does not attach role=region when useRegionRole is false', () => { const { root } = build(SECTIONS, { useRegionRole: false }); expect(panel(root, 0).hasAttribute('role')).toBe(false); expect(panel(root, 0).hasAttribute('aria-labelledby')).toBe(false); }); it('keeps at most one item open in single-open mode even if markup asks for more', () => { const { root } = build([ { title: 'One', body: 'x', open: true }, { title: 'Two', body: 'y', open: true }, { title: 'Three', body: 'z' }, ]); expect(trigger(root, 0).getAttribute('aria-expanded')).toBe('true'); expect(trigger(root, 1).getAttribute('aria-expanded')).toBe('false'); }); it('does not call onToggle during initial silent setup', () => { const onToggle = vi.fn(); build([{ title: 'One', body: 'x', open: true }, ...SECTIONS.slice(1)], { onToggle }); expect(onToggle).not.toHaveBeenCalled(); }); }); describe('Accordion — expand / collapse / toggle API', () => { it('expands and collapses a single item', () => { const { instance } = build(); expect(instance.isExpanded(0)).toBe(false); instance.expand(0); expect(instance.isExpanded(0)).toBe(true); instance.collapse(0); expect(instance.isExpanded(0)).toBe(false); }); it('toggle flips the current state', () => { const { instance } = build(); instance.toggle(1); expect(instance.isExpanded(1)).toBe(true); instance.toggle(1); expect(instance.isExpanded(1)).toBe(false); }); it('single-open mode closes the previously open panel when another opens', () => { const { instance } = build(); instance.expand(0); instance.expand(2); expect(instance.isExpanded(0)).toBe(false); expect(instance.isExpanded(2)).toBe(true); }); it('allowMultiple keeps several panels open at once', () => { const { instance } = build(SECTIONS, { allowMultiple: true }); instance.expand(0); instance.expand(2); expect(instance.isExpanded(0)).toBe(true); expect(instance.isExpanded(2)).toBe(true); }); it('ignores out-of-range indices', () => { const { instance } = build(); expect(() => instance.expand(99)).not.toThrow(); expect(instance.isExpanded(99)).toBe(false); }); }); describe('Accordion — allowToggle:false (always one open)', () => { it('marks the open header aria-disabled and refuses to collapse it', () => { const { root, instance } = build(SECTIONS, { allowToggle: false }); // With no data-expanded, the first item is forced open. expect(instance.isExpanded(0)).toBe(true); expect(trigger(root, 0).getAttribute('aria-disabled')).toBe('true'); instance.collapse(0); expect(instance.isExpanded(0)).toBe(true); }); it('moves aria-disabled to whichever header is open', () => { const { root, instance } = build(SECTIONS, { allowToggle: false }); instance.expand(1); expect(trigger(root, 0).hasAttribute('aria-disabled')).toBe(false); expect(trigger(root, 1).getAttribute('aria-disabled')).toBe('true'); }); }); describe('Accordion — events', () => { it('fires accordion:toggle with the correct detail on expand and collapse', () => { const { root, instance } = build(); const events: Array<{ index: number; expanded: boolean }> = []; root.addEventListener('accordion:toggle', (e) => { const detail = (e as CustomEvent<{ index: number; expanded: boolean }>).detail; events.push({ index: detail.index, expanded: detail.expanded }); }); instance.expand(1); instance.collapse(1); expect(events).toEqual([ { index: 1, expanded: true }, { index: 1, expanded: false }, ]); }); it('invokes onToggle with the same detail as the event', () => { const onToggle = vi.fn(); const { instance } = build(SECTIONS, { onToggle }); instance.expand(2); expect(onToggle).toHaveBeenCalledTimes(1); const detail = onToggle.mock.calls[0]![0] as { index: number; expanded: boolean; trigger: HTMLButtonElement; panel: HTMLElement; }; expect(detail.index).toBe(2); expect(detail.expanded).toBe(true); expect(detail.trigger).toBeInstanceOf(HTMLButtonElement); expect(detail.panel.classList.contains('accordion__panel')).toBe(true); }); it('toggles via a click on the trigger button', () => { const { root, instance } = build(); trigger(root, 0).click(); expect(instance.isExpanded(0)).toBe(true); trigger(root, 0).click(); expect(instance.isExpanded(0)).toBe(false); }); }); describe('Accordion — destroy', () => { it('removes listeners so later clicks do nothing', () => { const { root, instance } = build(); instance.destroy(); trigger(root, 0).click(); expect(instance.isExpanded(0)).toBe(false); }); });