# 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 `

Panel one content.

Panel two content.

``` 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 (`

`–`

`). 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('[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).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 `