← All components

Accordion.e2e

Everything needed to reproduce this component is inlined below (README and full source). Individual raw files: README.md Accordion.e2e.ts Accordion.stories.ts Accordion.test.ts Accordion.ts accordion.css

README.md (raw)

# Accordion

A framework-agnostic, accessible **accordion** built in plain, strictly-typed
vanilla TypeScript. It implements the
[WAI-ARIA Authoring Practices Guide (APG) "Accordion" pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/).

The class **enhances existing semantic markup** rather than generating it, so the
component degrades gracefully without JavaScript, stays easy to author, and keeps
every accessibility guarantee in one auditable place. The source is written to
double as a **reference / translation template**: it can be hand- or AI-ported to
React, Vue, Svelte, etc. without losing any of the ARIA semantics.

---

## Required markup (progressive enhancement)

Provide, in document order, alternating **heading → panel** pairs inside a single
root element. Each header is a native `<button>` wrapped in a heading element
(`<h2>`, `<h3>`, …). Using a real `<button>` means Enter/Space activation,
focusability, and the correct role all come "for free" from the platform — the
APG explicitly recommends this, so the component never adds `role="button"` or
manages `tabindex` on the trigger.

```html
<div class="accordion" data-accordion>
  <h3 class="accordion__heading">
    <button class="accordion__trigger" type="button">Section one</button>
  </h3>
  <div class="accordion__panel">
    <p>Panel one content.</p>
  </div>

  <h3 class="accordion__heading">
    <button class="accordion__trigger" type="button" data-expanded>Section two</button>
  </h3>
  <div class="accordion__panel">
    <p>Panel two content.</p>
  </div>
</div>
```

Rules the markup must follow:

- **Pairing.** Each `.accordion__panel` is paired with the `.accordion__trigger`
  whose heading is the panel's **immediately preceding element sibling**. In
  other words, the panel is the heading's `nextElementSibling`.
- **Heading wrapper.** Every `.accordion__trigger` must be wrapped in a heading
  element (`<h1>`–`<h6>`). Choose the heading **level** to fit your page outline;
  the heading only communicates hierarchy and must NOT carry the button role.
- **Initial state.** Mark an item as initially open with the `data-expanded`
  attribute on its trigger button. This authoring hint is consumed and removed
  from the DOM during setup.
- **No hand-written ARIA.** You do **not** write any ARIA attributes or ids
  yourself. The class wires up `aria-expanded`, `aria-controls`, `role`,
  `aria-labelledby`, `hidden`, `aria-disabled`, and generates any missing ids.

The class selectors are the single source of truth for the markup hooks:
`.accordion__trigger` for the button and `.accordion__panel` for the panel.

---

## Installation & usage

The component ships as a class. Import it, build (or server-render) the semantic
markup above, then enhance the root element:

```ts
import { Accordion } from './Accordion';
import './accordion.css';

const root = document.querySelector<HTMLElement>('[data-accordion]')!;
const accordion = new Accordion(root, {
  allowMultiple: false,
  allowToggle: true,
});
```

The constructor throws a `TypeError` if `root` is not an `HTMLElement`, and throws
an `Error` if a trigger is missing its heading wrapper or its adjacent panel.

```ts
new Accordion(root: HTMLElement, options?: AccordionOptions)
```

---

## Options

All options are optional; every field falls back to the default below.

| Option            | Type      | Default        | Description |
| ----------------- | --------- | -------------- | ----------- |
| `allowMultiple`   | `boolean` | `false`        | Allow several panels to be open at the same time. When `true`, opening one panel never closes another and every panel is always individually collapsible (so `allowToggle` is implied). |
| `allowToggle`     | `boolean` | `true`         | In single-open mode (`allowMultiple: false`), allow the currently-open panel to be collapsed by clicking its own header (leaving nothing open). When `false`, exactly one panel is always open and the open header is marked `aria-disabled="true"`. **Ignored when `allowMultiple` is `true`.** |
| `arrowNavigation` | `boolean` | `true`         | Enable the optional APG arrow-key / Home / End focus movement between headers. Disable to reproduce the minimal (Enter/Space/Tab only) example. |
| `wrapFocus`       | `boolean` | `true`         | When arrow navigation is on, wrap focus around the ends (Down on the last header focuses the first, and vice versa). |
| `useRegionRole`   | `boolean` | `true`         | Apply `role="region"` + `aria-labelledby` to each panel. Turn off to avoid creating too many landmarks in accordions with many simultaneously-open panels (~6+). |
| `idPrefix`        | `string`  | `"accordion"`  | Prefix used when generating element ids. Useful to keep ids stable/readable. |
| `onToggle`        | `(detail: AccordionToggleDetail) => void` | `() => {}` | Called on every expand/collapse — the same moments the `accordion:toggle` event fires, with the same `AccordionToggleDetail` payload. Not called during initial setup. |

### Initial-state reconciliation

- In **single-open mode** at most one item may start open. If the markup marks
  several with `data-expanded`, only the first is kept open.
- In **single-open + non-collapsible mode** (`allowMultiple: false`,
  `allowToggle: false`), one panel must always be open. If the author did not mark
  one with `data-expanded`, the first item is opened by default.

---

## Events

Every state change dispatches an `accordion:toggle` `CustomEvent` on the **root**
element. It `bubbles`, and is **not** fired during initial setup (so listeners do
not fire on load).

```ts
interface AccordionToggleDetail {
  /** Zero-based index of the affected item. */
  index: number;
  /** Whether the item is now expanded. */
  expanded: boolean;
  /** The trigger button element of the affected item. */
  trigger: HTMLButtonElement;
  /** The panel element of the affected item. */
  panel: HTMLElement;
}
```

```ts
root.addEventListener('accordion:toggle', (event) => {
  const { index, expanded } = (event as CustomEvent<AccordionToggleDetail>).detail;
  console.log(`Item ${index} is now ${expanded ? 'open' : 'closed'}`);
});
```

The `onToggle` option (see [Options](#options)) fires at the exact same moments
with the exact same `AccordionToggleDetail` payload, for consumers who prefer a
plain callback over `addEventListener`:

```ts
const accordion = new Accordion(root, {
  onToggle: ({ index, expanded }) => {
    console.log(`Item ${index} is now ${expanded ? 'open' : 'closed'}`);
  },
});
```

---

## Public API

| Member                    | Signature                     | Description |
| ------------------------- | ----------------------------- | ----------- |
| `length`                  | `get length(): number`        | Number of header/panel pairs managed by this instance. |
| `isExpanded(index)`       | `(index: number) => boolean`  | Whether the item at `index` is currently expanded. Returns `false` for an invalid index. |
| `expand(index)`           | `(index: number) => void`     | Expand the item at `index`. No-op if already open or the index is invalid. In single-open mode this first collapses any other open panel. |
| `collapse(index)`         | `(index: number) => void`     | Collapse the item at `index`. No-op if already closed or invalid. In single-open + non-collapsible mode (`allowMultiple: false`, `allowToggle: false`) the open panel may not be collapsed, so this is a no-op for the open item. |
| `toggle(index)`           | `(index: number) => void`     | Expand or collapse the item at `index` based on its current state. |
| `destroy()`               | `() => void`                  | Remove all event listeners added by this instance. ARIA attributes are left in place (they remain valid). Call before discarding the DOM subtree or re-initialising to avoid duplicate listeners / leaks. |

```ts
const accordion = new Accordion(root);

accordion.length;            // e.g. 3
accordion.isExpanded(0);     // true | false
accordion.expand(1);
accordion.collapse(1);
accordion.toggle(2);
accordion.destroy();
```

---

## Accessibility contract

### ARIA attributes applied to each **trigger button**

| Attribute        | Value | Notes |
| ---------------- | ----- | ----- |
| `aria-expanded`  | `"true"` / `"false"` | `"true"` when its panel is visible, `"false"` when hidden. |
| `aria-controls`  | panel id | Points at the panel the button shows/hides. |
| `aria-disabled`  | `"true"` | Set **only** for the currently-open header in single-open + non-collapsible mode (`allowMultiple: false`, `allowToggle: false`). Uses `aria-disabled` rather than the `disabled` attribute so the header stays focusable and discoverable, exactly as the APG describes. Removed otherwise. |

### ARIA attributes applied to each **panel**

| Attribute        | Value | Notes |
| ---------------- | ----- | ----- |
| `role`           | `"region"` | Optional per the APG; turns the panel into a landmark. Applied only when `useRegionRole` is `true`. |
| `aria-labelledby`| trigger id | Names the region by its header. Applied only when `useRegionRole` is `true`. |
| `hidden`         | boolean attribute | Toggled to show/hide the panel. Using `hidden` keeps collapsed content out of the accessibility tree and the tab order without any extra CSS. |

### Keyboard interaction

| Key             | Action | Requirement |
| --------------- | ------ | ----------- |
| **Enter / Space** | Toggle the focused header. | REQUIRED. Handled natively by the `<button>` element (the component listens only for `click`). |
| **Tab / Shift+Tab** | Move through the page's focusable elements. | REQUIRED. Native browser behaviour — nothing custom. |
| **Down Arrow**  | Move focus to the next header. | OPTIONAL. Enabled by `arrowNavigation`. |
| **Up Arrow**    | Move focus to the previous header. | OPTIONAL. Enabled by `arrowNavigation`. |
| **Home**        | Move focus to the first header. | OPTIONAL. Enabled by `arrowNavigation`. |
| **End**         | Move focus to the last header. | OPTIONAL. Enabled by `arrowNavigation`. |

The optional Arrow/Home/End movements only move **focus** between headers; they
never toggle a panel. When `wrapFocus` is on, Down on the last header focuses the
first (and Up on the first focuses the last); when off, focus stops at the ends.
Arrow/Home/End call `preventDefault()` to stop the page from scrolling.

---

## CSS & styling notes

The bundled `accordion.css` is deliberately bare: only what is needed to make the
pattern legible and to expose focus/expanded state. No colour system, no
animation. Override or replace freely in a real design system.

**Critical rule — never set `display` on `.accordion__panel`.** The component
shows and hides panels with the native `hidden` attribute, which relies on the UA
stylesheet's `[hidden] { display: none }` rule winning. A `display` declaration on
the panel would override `[hidden]` and break hiding entirely.

The provided styles also:

- Reset the native `<button>` chrome so `.accordion__trigger` reads as a plain,
  full-width bar.
- Keep a visible `:focus-visible` outline on the trigger and never remove it.
- Render a purely textual expand/collapse affordance from ARIA state
  (`[aria-expanded='true']`), so the visual indicator always matches what
  assistive technology announces.

Styling hooks available without extra classes:

| Selector | Purpose |
| -------- | ------- |
| `.accordion` | The container / group. |
| `.accordion__heading` | The heading wrapper (`<h2>`–`<h6>`). |
| `.accordion__trigger` | The native `<button>` header. |
| `.accordion__trigger[aria-expanded='true']` | Style the open header. |
| `.accordion__trigger[aria-disabled='true']` | Style the locked-open header (single-open + no-toggle). |
| `.accordion__panel` | The collapsible panel (hidden via `[hidden]`). |

Accordion.e2e.ts (raw)

/**
 * Accordion — end-to-end tests (Playwright, real browser).
 *
 * These drive the rendered "Default" Storybook story and verify the behaviours
 * that need a real browser: native `<button>` Enter/Space activation, the
 * optional Arrow/Home/End roving focus between headers, and click toggling. The
 * ARIA wiring and JS API are covered by the Vitest unit suite (`Accordion.test.ts`).
 *
 * Requires `bunx playwright install chromium` first. The Storybook web server is
 * started automatically (see `playwright.config.ts`).
 */
import { test, expect } from '@playwright/test';

const STORY = '/iframe.html?id=components-accordion--default&viewMode=story';

test.beforeEach(async ({ page }) => {
  await page.goto(STORY);
  await page.waitForSelector('.accordion__trigger');
});

test('Enter and Space toggle the focused header', async ({ page }) => {
  const triggers = page.locator('.accordion__trigger');
  const second = triggers.nth(1);

  await second.focus();
  await expect(second).toHaveAttribute('aria-expanded', 'false');

  await page.keyboard.press('Enter');
  await expect(second).toHaveAttribute('aria-expanded', 'true');

  await page.keyboard.press('Space');
  await expect(second).toHaveAttribute('aria-expanded', 'false');
});

test('clicking a header toggles its panel', async ({ page }) => {
  const triggers = page.locator('.accordion__trigger');
  const third = triggers.nth(2);

  await expect(third).toHaveAttribute('aria-expanded', 'false');
  await third.click();
  await expect(third).toHaveAttribute('aria-expanded', 'true');
  await third.click();
  await expect(third).toHaveAttribute('aria-expanded', 'false');
});

test('single-open mode collapses the previously open panel', async ({ page }) => {
  const triggers = page.locator('.accordion__trigger');
  // The first section starts open in the Default story.
  await expect(triggers.nth(0)).toHaveAttribute('aria-expanded', 'true');
  await triggers.nth(1).click();
  await expect(triggers.nth(0)).toHaveAttribute('aria-expanded', 'false');
  await expect(triggers.nth(1)).toHaveAttribute('aria-expanded', 'true');
});

test('Arrow, Home and End move header focus', async ({ page }) => {
  const triggers = page.locator('.accordion__trigger');
  await triggers.nth(0).focus();

  await page.keyboard.press('ArrowDown');
  await expect(triggers.nth(1)).toBeFocused();

  await page.keyboard.press('ArrowUp');
  await expect(triggers.nth(0)).toBeFocused();

  // Wrap: Up from the first goes to the last.
  await page.keyboard.press('ArrowUp');
  await expect(triggers.nth(2)).toBeFocused();

  await page.keyboard.press('Home');
  await expect(triggers.nth(0)).toBeFocused();

  await page.keyboard.press('End');
  await expect(triggers.nth(2)).toBeFocused();
});

Accordion.stories.ts (raw)

import type { Meta, StoryObj } from '@storybook/html';
import { Accordion, type AccordionOptions } from './Accordion';
import './accordion.css';
// The component's README, imported as raw Markdown (Vite `?raw`). It is surfaced
// on the autodocs "Docs" page below so the reference documentation and the live
// stories stay in one place.
//
// NOTE: the project ships `storybook-readme` for this purpose, but that addon
// (v5, React/Vue era) is incompatible with Storybook 8 — it depends on the
// removed `@storybook/addons` global-decorator API and on React, neither of
// which exists here. We therefore use Storybook 8's native docs mechanism
// (`autodocs` + `parameters.docs.description.component`) to render the README.
import readme from './README.md?raw';

/**
 * Storybook stories for the Accordion component.
 *
 * The library is framework-agnostic vanilla TS, so each story's `render`
 * builds real DOM, then enhances it with `new Accordion(...)` — exactly how a
 * consumer would use it. The `@storybook/addon-a11y` panel runs axe-core
 * against the rendered output so regressions in the ARIA wiring surface here.
 */

interface Section {
  title: string;
  body: string;
  /** Start this section expanded (maps to `data-expanded` on the trigger). */
  open?: boolean;
}

const SAMPLE_SECTIONS: readonly Section[] = [
  {
    title: 'Personal information',
    body: 'Name, date of birth, and contact details.',
    open: true,
  },
  { title: 'Billing address', body: 'Where invoices are sent.' },
  { title: 'Shipping address', body: 'Where orders are delivered.' },
];

/**
 * Build the semantic accordion markup and activate the component.
 * Returns the root element Storybook mounts.
 */
function renderAccordion(
  sections: readonly Section[],
  options: AccordionOptions = {},
): HTMLElement {
  const root = document.createElement('div');
  root.className = 'accordion';
  root.setAttribute('data-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);
  }

  new Accordion(root, options);
  return root;
}

const meta: Meta<AccordionOptions> = {
  title: 'Components/Accordion',
  // `autodocs` (see `.storybook/main.ts` → `docs.autodocs: "tag"`) generates a
  // "Docs" page for this component; the README is rendered at the top of it.
  tags: ['autodocs'],
  parameters: {
    docs: {
      description: {
        component: readme,
      },
    },
  },
  argTypes: {
    // `table.defaultValue.summary` fills the Controls/Docs "Default" column.
    // `@storybook/html` cannot read these from the TS interface, so they are
    // kept in sync with the defaults in `AccordionOptions` by hand.
    allowMultiple: {
      control: 'boolean',
      description: 'Allow more than one panel open at once.',
      table: { defaultValue: { summary: 'false' } },
    },
    allowToggle: {
      control: 'boolean',
      description:
        'In single-open mode, allow the open panel to be collapsed. Ignored when allowMultiple is true.',
      table: { defaultValue: { summary: 'true' } },
    },
    arrowNavigation: {
      control: 'boolean',
      description: 'Enable optional Up/Down/Home/End focus movement between headers.',
      table: { defaultValue: { summary: 'true' } },
    },
    wrapFocus: {
      control: 'boolean',
      description: 'Wrap arrow-key focus around the first/last header.',
      table: { defaultValue: { summary: 'true' } },
    },
    useRegionRole: {
      control: 'boolean',
      description: 'Expose each panel as an ARIA landmark region.',
      table: { defaultValue: { summary: 'true' } },
    },
    // No `control`: this is a callback, not something a user edits via the
    // Controls panel. `action: 'accordion:toggle'` tells the Actions addon
    // (part of `@storybook/addon-essentials`, see `.storybook/main.ts`) to
    // inject a spy function as this arg and log every call — index + expanded
    // state — to the Actions panel, mirroring the `accordion:toggle` event.
    onToggle: {
      action: 'accordion:toggle',
      description:
        'Called on every expand/collapse with the same detail as the accordion:toggle event.',
      table: { category: 'Events', defaultValue: { summary: '() => {}' } },
    },
  },
  render: (args) => renderAccordion(SAMPLE_SECTIONS, args),
};

export default meta;
type Story = StoryObj<AccordionOptions>;

/**
 * Default configuration: single panel open at a time, and the open panel can be
 * collapsed (nothing open) by clicking its own header.
 */
export const Default: Story = {
  args: {
    allowMultiple: false,
    allowToggle: true,
    arrowNavigation: true,
    wrapFocus: true,
    useRegionRole: true,
  },
};

Accordion.test.ts (raw)

/**
 * 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<HTMLButtonElement>('.accordion__trigger')[i]!;
}
function panel(root: HTMLElement, i: number): HTMLElement {
  return root.querySelectorAll<HTMLElement>('.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);
  });
});

Accordion.ts (raw)

/**
 * Accordion
 * =========
 *
 * A framework-agnostic, class-based implementation of the WAI-ARIA Authoring
 * Practices Guide (APG) "Accordion" pattern:
 *
 *   https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
 *
 * This file is intentionally written as a **reference / translation template**:
 * plain, strictly-typed vanilla TypeScript with heavy documentation so it can be
 * hand- or AI-translated into React, Vue, Svelte, etc. without losing any of the
 * accessibility semantics. Prefer clarity over cleverness here.
 *
 * ---------------------------------------------------------------------------
 * Accessibility contract (what the APG requires and what this class guarantees)
 * ---------------------------------------------------------------------------
 *
 * Structure
 *  - Every accordion header is a NATIVE `<button>` wrapped in a heading element
 *    (`<h2>`, `<h3>`, ...). Using a real button means Enter/Space activation,
 *    focusability and the correct role all come "for free" from the platform —
 *    the APG explicitly recommends this. We therefore do NOT add `role="button"`
 *    or manage tabindex on the trigger ourselves.
 *  - The heading only communicates hierarchy; it must NOT carry the button role.
 *    Choose the heading LEVEL (h2/h3/...) in your markup to fit the page outline.
 *
 * States & properties applied to each trigger button
 *  - `aria-expanded`  : "true" when its panel is visible, "false" when hidden.
 *  - `aria-controls`  : id of the panel the button shows/hides.
 *  - `aria-disabled`  : set to "true" ONLY for the currently-open header when the
 *                       accordion does not permit collapsing it (single-open mode
 *                       with `allowToggle: false`). We use `aria-disabled` rather
 *                       than the `disabled` attribute so the header stays focusable
 *                       and discoverable, exactly as the APG describes.
 *
 * Properties applied to each panel
 *  - `role="region"`      : (optional per APG) turns the panel into a landmark so
 *                           screen-reader users can navigate to it. Disable via
 *                           `useRegionRole: false` to avoid landmark proliferation
 *                           when there are many (~6+) simultaneously-open panels.
 *  - `aria-labelledby`    : id of the trigger button, so the region is named by
 *                           its header.
 *  - `hidden`             : toggled to show/hide the panel. Using the `hidden`
 *                           attribute keeps collapsed content out of the a11y tree
 *                           and the tab order without any extra CSS.
 *
 * Keyboard interaction
 *  - Enter / Space        : toggle the focused header. REQUIRED. Handled natively
 *                           by the `<button>` element → we only listen for `click`.
 *  - Tab / Shift+Tab      : move through the page's focusable elements. REQUIRED.
 *                           Native behaviour — nothing to implement.
 *  - Down / Up Arrow      : move focus to the next / previous header. OPTIONAL.
 *  - Home / End           : move focus to the first / last header. OPTIONAL.
 *
 * The optional roving-arrow behaviours are enabled by default (`arrowNavigation`)
 * because they materially improve the experience, but they can be turned off to
 * mirror the "minimal" APG example.
 *
 * ---------------------------------------------------------------------------
 * Expected markup (progressive enhancement)
 * ---------------------------------------------------------------------------
 *
 * The class ENHANCES semantic markup rather than generating it, so the component
 * degrades gracefully without JavaScript and stays easy to author. Provide, in
 * document order, alternating heading→panel pairs inside the root element:
 *
 * ```html
 * <div class="accordion" data-accordion>
 *   <h3 class="accordion__heading">
 *     <button class="accordion__trigger" type="button">Section one</button>
 *   </h3>
 *   <div class="accordion__panel">
 *     <p>Panel one content.</p>
 *   </div>
 *
 *   <h3 class="accordion__heading">
 *     <button class="accordion__trigger" type="button" data-expanded>Section two</button>
 *   </h3>
 *   <div class="accordion__panel">
 *     <p>Panel two content.</p>
 *   </div>
 * </div>
 * ```
 *
 * Notes on the markup:
 *  - Each `.accordion__panel` is paired with the `.accordion__trigger` that
 *    immediately precedes it (the button's heading is the panel's previous
 *    element sibling).
 *  - Mark an item as initially open with `data-expanded` on its trigger button.
 *  - You do NOT need to write any ARIA attributes or ids yourself — the class
 *    wires up `aria-expanded`, `aria-controls`, `role`, `aria-labelledby`,
 *    `hidden`, and generates any missing ids.
 */

/** Options controlling accordion behaviour. All fields are optional. */
export interface AccordionOptions {
  /**
   * Allow several panels to be open at the same time.
   * When `true`, opening one panel never closes another and every panel is
   * always individually collapsible (so `allowToggle` is implied).
   * @default false
   */
  allowMultiple?: boolean;

  /**
   * In single-open mode (`allowMultiple: false`), allow the currently-open
   * panel to be collapsed by clicking its own header (leaving nothing open).
   * When `false`, exactly one panel is always open and the open header is
   * marked `aria-disabled="true"`.
   * Ignored when `allowMultiple` is `true`.
   * @default true
   */
  allowToggle?: boolean;

  /**
   * Enable the OPTIONAL APG arrow-key / Home / End focus movement between
   * headers. Disable to reproduce the minimal (Enter/Space/Tab only) example.
   * @default true
   */
  arrowNavigation?: boolean;

  /**
   * When arrow navigation is on, wrap focus around the ends (Down on the last
   * header focuses the first, and vice versa).
   * @default true
   */
  wrapFocus?: boolean;

  /**
   * Apply `role="region"` + `aria-labelledby` to each panel. Turn off to avoid
   * creating too many landmarks in accordions with many open panels.
   * @default true
   */
  useRegionRole?: boolean;

  /**
   * Prefix used when generating element ids. Useful to keep ids stable/readable.
   * @default "accordion"
   */
  idPrefix?: string;

  /**
   * Callback invoked on every expand/collapse — the same moments the
   * `accordion:toggle` `CustomEvent` fires (see {@link AccordionToggleDetail}).
   * Receives the affected item's index and its new expanded state (plus the
   * trigger/panel elements), so consumers who prefer a plain callback over
   * `addEventListener('accordion:toggle', ...)` don't have to wire one up
   * themselves. Like the event, it is NOT called during initial setup.
   * @default () => {}
   */
  onToggle?: (detail: AccordionToggleDetail) => void;
}

/** Detail payload for the `accordion:toggle` custom event. */
export interface AccordionToggleDetail {
  /** Zero-based index of the affected item. */
  index: number;
  /** Whether the item is now expanded. */
  expanded: boolean;
  /** The trigger button element of the affected item. */
  trigger: HTMLButtonElement;
  /** The panel element of the affected item. */
  panel: HTMLElement;
}

/** Internal representation of one header/panel pair. */
interface AccordionItem {
  /** The heading wrapper element (e.g. `<h3>`). */
  readonly heading: HTMLElement;
  /** The native `<button>` that toggles the panel. */
  readonly trigger: HTMLButtonElement;
  /** The collapsible panel element. */
  readonly panel: HTMLElement;
}

/**
 * Module-level counter guaranteeing unique generated ids across every Accordion
 * instance on the page, so `aria-controls`/`aria-labelledby` never collide.
 */
let uidCounter = 0;

/** CSS selectors for the required markup hooks. Kept here as the single source of truth. */
const SELECTORS = {
  trigger: '.accordion__trigger',
  panel: '.accordion__panel',
} as const;

export class Accordion {
  private readonly root: HTMLElement;
  private readonly options: Required<AccordionOptions>;
  private readonly items: readonly AccordionItem[];

  /**
   * Bound click handler kept as a field so the exact same reference can be
   * removed in `destroy()` (an inline arrow would be a different reference).
   */
  private readonly onClick = (event: MouseEvent): void => {
    const trigger = (event.target as Element | null)?.closest<HTMLButtonElement>(
      SELECTORS.trigger,
    );
    if (!trigger || !this.root.contains(trigger)) return;

    const index = this.items.findIndex((item) => item.trigger === trigger);
    if (index === -1) return;

    this.toggle(index);
  };

  /** Bound keydown handler for the OPTIONAL arrow/Home/End focus movement. */
  private readonly onKeydown = (event: KeyboardEvent): void => {
    const target = event.target as Element | null;
    const currentIndex = this.items.findIndex((item) => item.trigger === target);
    if (currentIndex === -1) return; // key wasn't pressed on one of our headers

    const lastIndex = this.items.length - 1;
    let nextIndex: number | null = null;

    switch (event.key) {
      case 'ArrowDown':
        nextIndex =
          currentIndex === lastIndex
            ? this.options.wrapFocus
              ? 0
              : null
            : currentIndex + 1;
        break;
      case 'ArrowUp':
        nextIndex =
          currentIndex === 0
            ? this.options.wrapFocus
              ? lastIndex
              : null
            : currentIndex - 1;
        break;
      case 'Home':
        nextIndex = 0;
        break;
      case 'End':
        nextIndex = lastIndex;
        break;
      default:
        return; // not a key we handle
    }

    if (nextIndex === null) return;
    // Prevent the browser from scrolling the page for Arrow/Home/End.
    event.preventDefault();
    this.items[nextIndex]?.trigger.focus();
  };

  /**
   * @param root    The accordion container element (the element carrying the
   *                heading/panel pairs). Passing a non-element throws.
   * @param options Behavioural options; see {@link AccordionOptions}.
   */
  constructor(root: HTMLElement, options: AccordionOptions = {}) {
    if (!(root instanceof HTMLElement)) {
      throw new TypeError('Accordion: `root` must be an HTMLElement.');
    }
    this.root = root;

    // Resolve options once into a fully-populated object so the rest of the
    // class never has to deal with `undefined`.
    this.options = {
      allowMultiple: options.allowMultiple ?? false,
      allowToggle: options.allowToggle ?? true,
      arrowNavigation: options.arrowNavigation ?? true,
      wrapFocus: options.wrapFocus ?? true,
      useRegionRole: options.useRegionRole ?? true,
      idPrefix: options.idPrefix ?? 'accordion',
      onToggle: options.onToggle ?? (() => {}),
    };

    this.items = this.collectItems();
    this.setupAria();
    this.bindEvents();
  }

  // ---------------------------------------------------------------------------
  // Setup
  // ---------------------------------------------------------------------------

  /**
   * Discover the header/panel pairs from the markup. Each trigger button's
   * panel is the trigger heading's next element sibling that matches the panel
   * selector, which keeps authoring simple and the pairing unambiguous.
   */
  private collectItems(): AccordionItem[] {
    const triggers = Array.from(
      this.root.querySelectorAll<HTMLButtonElement>(SELECTORS.trigger),
    );

    return triggers.map((trigger, i) => {
      const heading = trigger.closest<HTMLElement>('h1,h2,h3,h4,h5,h6') ?? trigger.parentElement;
      if (!heading) {
        throw new Error(
          `Accordion: trigger #${i} must be wrapped in a heading element.`,
        );
      }

      const panel = heading.nextElementSibling;
      if (!(panel instanceof HTMLElement) || !panel.matches(SELECTORS.panel)) {
        throw new Error(
          `Accordion: trigger #${i} has no adjacent "${SELECTORS.panel}" panel.`,
        );
      }

      return { heading, trigger, panel };
    });
  }

  /**
   * Apply all static ARIA wiring and reconcile the initial open/closed state
   * declared in the markup (`data-expanded` on triggers).
   */
  private setupAria(): void {
    // Which items are requested open in the markup?
    const initiallyOpen = this.items.map((item) =>
      item.trigger.hasAttribute('data-expanded'),
    );

    // In single-open mode at most one item may start open. Keep the first.
    if (!this.options.allowMultiple) {
      let seenOpen = false;
      for (let i = 0; i < initiallyOpen.length; i++) {
        if (initiallyOpen[i] && seenOpen) initiallyOpen[i] = false;
        else if (initiallyOpen[i]) seenOpen = true;
      }
      // Single-open + no-toggle means one panel must ALWAYS be open. If the
      // author didn't mark one, default to the first item.
      if (!seenOpen && !this.options.allowToggle && initiallyOpen.length > 0) {
        initiallyOpen[0] = true;
      }
    }

    this.items.forEach((item, i) => {
      const uid = `${this.options.idPrefix}-${++uidCounter}`;
      // Ensure both elements have ids so aria-controls / aria-labelledby resolve.
      if (!item.trigger.id) item.trigger.id = `${uid}-trigger`;
      if (!item.panel.id) item.panel.id = `${uid}-panel`;

      item.trigger.setAttribute('aria-controls', item.panel.id);

      if (this.options.useRegionRole) {
        item.panel.setAttribute('role', 'region');
        item.panel.setAttribute('aria-labelledby', item.trigger.id);
      }

      // Clean up the authoring hint so it doesn't linger in the DOM.
      item.trigger.removeAttribute('data-expanded');

      this.setExpanded(i, initiallyOpen[i] ?? false, { silent: true });
    });
  }

  /** Attach delegated event listeners on the root. */
  private bindEvents(): void {
    this.root.addEventListener('click', this.onClick);
    if (this.options.arrowNavigation) {
      this.root.addEventListener('keydown', this.onKeydown);
    }
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  /** Number of header/panel pairs managed by this instance. */
  get length(): number {
    return this.items.length;
  }

  /** Whether the item at `index` is currently expanded. */
  isExpanded(index: number): boolean {
    return this.items[index]?.trigger.getAttribute('aria-expanded') === 'true';
  }

  /** Expand the item at `index` (no-op if already open or index invalid). */
  expand(index: number): void {
    if (!this.items[index] || this.isExpanded(index)) return;

    // Single-open mode: opening one closes any other open panel.
    if (!this.options.allowMultiple) {
      this.items.forEach((_, i) => {
        if (i !== index && this.isExpanded(i)) this.setExpanded(i, false);
      });
    }
    this.setExpanded(index, true);
  }

  /** Collapse the item at `index`, honouring the always-one-open rule. */
  collapse(index: number): void {
    if (!this.items[index] || !this.isExpanded(index)) return;

    // In single-open + no-toggle mode the open panel may not be collapsed.
    if (!this.options.allowMultiple && !this.options.allowToggle) return;

    this.setExpanded(index, false);
  }

  /** Toggle the item at `index` based on its current state. */
  toggle(index: number): void {
    if (this.isExpanded(index)) this.collapse(index);
    else this.expand(index);
  }

  /**
   * Remove all listeners added by this instance. ARIA attributes are left in
   * place (they remain valid); call this before discarding the DOM subtree or
   * re-initialising to avoid duplicate listeners / leaks.
   */
  destroy(): void {
    this.root.removeEventListener('click', this.onClick);
    this.root.removeEventListener('keydown', this.onKeydown);
  }

  // ---------------------------------------------------------------------------
  // Internal state mutation
  // ---------------------------------------------------------------------------

  /**
   * The single low-level function that flips one item's state and keeps every
   * ARIA attribute + the `hidden` attribute in sync. All public mutators funnel
   * through here so the DOM can never drift out of a valid ARIA state.
   *
   * @param index   Which item to change.
   * @param expanded Target state.
   * @param opts.silent When true, suppress the `accordion:toggle` event and the
   *                    `onToggle` callback (used during initial setup so
   *                    listeners don't fire on load).
   */
  private setExpanded(
    index: number,
    expanded: boolean,
    opts: { silent?: boolean } = {},
  ): void {
    const item = this.items[index];
    if (!item) return;

    item.trigger.setAttribute('aria-expanded', String(expanded));
    item.panel.hidden = !expanded;

    // Reconcile aria-disabled for the "always one open" configuration: the open
    // header must advertise that it cannot be collapsed.
    const lockOpenHeader = !this.options.allowMultiple && !this.options.allowToggle;
    if (lockOpenHeader && expanded) {
      item.trigger.setAttribute('aria-disabled', 'true');
    } else {
      item.trigger.removeAttribute('aria-disabled');
    }

    if (!opts.silent) {
      const detail: AccordionToggleDetail = {
        index,
        expanded,
        trigger: item.trigger,
        panel: item.panel,
      };
      this.options.onToggle(detail);
      this.root.dispatchEvent(
        new CustomEvent<AccordionToggleDetail>('accordion:toggle', {
          detail,
          bubbles: true,
        }),
      );
    }
  }
}

accordion.css (raw)

/*
 * Accordion — functional-only skeleton of styling hooks
 * =====================================================
 *
 * This file is deliberately EMPTY of cosmetics. It exists as a documented
 * skeleton: every class the component produces and every state/ARIA selector it
 * toggles is listed below as a (mostly empty) rule block, so a design system can
 * see exactly which hooks exist and drop its own values in. There are no
 * borders, no spacing, no colours, no fonts, no markers here — the component
 * falls back entirely to the browser's default rendering.
 *
 * Why empty is enough:
 *  - Show / hide is driven by JS setting the native `hidden` attribute on the
 *    panel; the UA stylesheet's `[hidden] { display: none }` does the hiding.
 *  - The keyboard focus indicator is the browser's default focus ring.
 *  - Expand / collapse and the disabled-header behaviour are ARIA state on the
 *    trigger, applied by JS; none of it depends on CSS.
 * So removing the cosmetic CSS cannot break functionality.
 *
 * IMPORTANT — do not reintroduce these:
 *  - Never set `display` on `.accordion__panel`. The component shows/hides
 *    panels via the native `hidden` attribute; a `display` rule here would
 *    override `[hidden]` and break hiding. `[hidden] { display: none }` from the
 *    UA stylesheet must always win.
 *  - Never remove the focus indicator (e.g. `outline: 0` / `outline: none` on
 *    the trigger or its `:focus-visible`). The browser default now provides the
 *    focus ring; suppressing it would make keyboard use inaccessible.
 */

/* ---------------------------------------------------------------------------
 * Element hooks — one placeholder per class the component emits.
 * ------------------------------------------------------------------------- */

.accordion {
}

.accordion__heading {
}

.accordion__trigger {
}

.accordion__panel {
}

/* First trigger sits at the top of the container — style hook for the edge case
   where the first header needs to differ from the rest. */
.accordion__heading:first-child .accordion__trigger {
}

/* ---------------------------------------------------------------------------
 * State / ARIA hooks — the important ones. These mirror the attributes the JS
 * toggles, so styling always matches what assistive tech announces.
 * ------------------------------------------------------------------------- */

/* Trigger whose panel is open. */
.accordion__trigger[aria-expanded='true'] {
}

/* Trigger whose panel is closed. */
.accordion__trigger[aria-expanded='false'] {
}

/* Locked-open header (single-open + no-toggle): cannot be collapsed. */
.accordion__trigger[aria-disabled='true'] {
}

/* Keyboard focus. Leave empty so the browser default focus ring stands; never
   suppress it. */
.accordion__trigger:focus-visible {
}

/* Collapsed panel. Hidden by the native `hidden` attribute — never add a
   `display` here (see header note). */
.accordion__panel[hidden] {
}