@tapestry-ui/accordion
v0.3.0
Published
A nestable, WCAG 2.1 AA accordion (disclosure) for Preact — headless behavior, optional slide, skin via CSS. Forged inside a woven-tapestry game; ships with its container contract, not just its markup.
Maintainers
Readme
@tapestry-ui/accordion
A nestable, accessible accordion for Preact — one disclosure, or a set of them. Headless behavior, skin via CSS. Forged inside a woven-tapestry video game, where it learned its most important lesson the hard way (see The container contract).
npm i @tapestry-ui/accordionimport { Disclosure, Accordion } from '@tapestry-ui/accordion';
import '@tapestry-ui/accordion/styles.css'; // structural only — optional
<Disclosure summary="SOUND">
<VolumeSliders />
</Disclosure>
// nesting is just children
<Disclosure summary="settings" defaultOpen>
<Disclosure summary="advanced">…</Disclosure>
</Disclosure>
// the data-source layer; `exclusive` makes it an accordion
<Accordion
exclusive
items={[
{ summary: 'one', content: <A /> },
{ summary: 'two', content: <B /> },
]}
/>Accessibility (WCAG 2.1 AA by design, not by audit)
- The summary is a real
<button>— Enter/Space, focus, and every assistive tech behavior come free and honest. aria-expanded+aria-controlswire the WAI-ARIA disclosure pattern; ids are auto-generated and collision-free under nesting.headingLevel={2..6}wraps the button in a real heading, so an accordion that titles a section never lies to the document outline.- The closed body is removed from the DOM, not visually hidden — screen readers never wade through collapsed forests.
:focus-visibleoutline ships in the structural CSS and is yours to restyle, never to remove.
Sliding open (keepMounted)
By default a closed body is removed, which is honest but cannot be
animated — there is nothing to animate from. Pass keepMounted and the
body stays in the DOM, carrying inert whenever it is closed:
<Disclosure summary="SOUND" keepMounted>…</Disclosure>inert takes the closed content out of the focus order and out of the
accessibility tree — the same promise removal makes — so the accordion can
slide without lying to anyone. The shipped CSS animates it with the
0fr → 1fr grid trick (and hides it outright once shut, so browsers
without inert keep the same guarantee). prefers-reduced-motion:
reduce turns the transition off.
| custom property | default | what it tunes |
| --- | --- | --- |
| --tui-disclosure-slide | 280ms | how long the body takes to open and shut |
The children are wrapped in [data-part="body-inner"] in this mode, since
the clip has to happen on an element that is not the grid row itself.
⭐ The container contract
An accordion is only half a component. The other half is the scroll contract of whatever contains it. A Accordion never owns scrolling and never constrains its container; when open accordions can exceed the available space, some ancestor you choose must be the scroll region:
.my-modal-content { /* ONE scrollable middle… */
flex: 1 1 auto;
min-height: 0;
overflow-y: auto; /* …owns ALL the growth */
}Skip this and an open accordion will push its tail past your modal's bounds — this exact bug is the reason this package exists. Put the accordion INSIDE the region that scrolls, never as a sibling below it.
Skin
Structural CSS only — nothing in styles.css picks a color. Hooks:
| hook | what |
|---|---|
| [data-part="root"] [data-state="open"\|"closed"] | the accordion + its state |
| [data-part="summary"] / "caret" / "body" / "heading" | the parts |
| class / summaryClass / bodyClass props | merge your own classes |
| --tui-disclosure-gap · --tui-disclosure-indent · --tui-disclosure-summary-pad | spacing |
| --tui-disclosure-caret-closed / -open (content strings) · --tui-disclosure-caret-size | the caret |
| --tui-disclosure-focus | the focus ring |
API
<Disclosure summary children defaultOpen? open? onOpenChange? disabled?
headingLevel? class? summaryClass? bodyClass? id?> — controlled when
open is passed (the component then only reports intent through
onOpenChange), uncontrolled otherwise.
<Accordion items exclusive? keepMounted? headingLevel? label? onOpenChange?
…classes> — the molecule: disclosures from data, exclusive for
one-at-a-time, and Up/Down/Home/End across its own headers. Exported as
Squeezebox too. Everything it does, composition can; it just owns the
set's state and keyboard.
