diff --git a/.changeset/accordion.md b/.changeset/accordion.md new file mode 100644 index 0000000..66388ad --- /dev/null +++ b/.changeset/accordion.md @@ -0,0 +1,45 @@ +--- +'@dunky.dev/accordion': minor +'@dunky.dev/react-accordion': minor +--- + +Add the Accordion primitive — stacked disclosure sections following the +WAI-ARIA APG pattern, shipped as an agnostic core (`@dunky.dev/accordion`) +plus a React binding (`@dunky.dev/react-accordion`). + +The `type` discriminant picks the value shape (`single` speaks +`string | null`, `multiple` speaks `string[]`), so an impossible state — two +open items in single mode — is unrepresentable. Single mode is +non-collapsible by default, per the APG single-expansion pattern; opt into +`collapsible` to allow closing the open item. Arrow keys (orientation-aware), +Home, and End move focus across enabled triggers with wrap. + +A controlled `value` is authoritative (the Radix semantics): a press reports +the value it asked for through `onValueChange` and the accordion follows the +prop, so an update the consumer declines never drifts the UI away from the +prop. Per APG, the open item's trigger in single non-collapsible mode is +exposed as `aria-disabled` — its panel cannot be collapsed — while staying +focusable and reachable by keyboard navigation. + +```tsx +import { Accordion } from '@dunky.dev/react-accordion' + +function App() { + return ( + + + + Shipping + + Ships in 3-5 business days. + + + + Returns + + Free returns within 30 days. + + + ) +} +``` diff --git a/packages/core/accordion/README.md b/packages/core/accordion/README.md new file mode 100644 index 0000000..28f6a31 --- /dev/null +++ b/packages/core/accordion/README.md @@ -0,0 +1,30 @@ +# @dunky.dev/accordion + +The framework-agnostic accordion interaction, modeled as a state machine on +`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers +pair it with a substrate driver rather than driving the machine directly. + +The behavior contract — scenarios, guarantees, driver obligations — lives in +[SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/accordion +``` + +## Usage + +```ts +import { machine, connector } from '@dunky.dev/state-machine' +import { accordionMachine, accordionConnect } from '@dunky.dev/accordion' + +// `id` is substrate-minted (SSR-safe); the connect derives the per-item ids. +const service = machine(accordionMachine({ ...options, type: 'single', id: 'my-accordion' })) +connector(service, accordionConnect, options) // wires the consumer callbacks +service.start() + +// Items register as they appear; registration order is the navigation order. +service.send({ type: 'item.register', value: 'shipping' }) +service.send({ type: 'toggle', value: 'shipping' }) +``` diff --git a/packages/core/accordion/SPEC.md b/packages/core/accordion/SPEC.md new file mode 100644 index 0000000..ee3e289 --- /dev/null +++ b/packages/core/accordion/SPEC.md @@ -0,0 +1,179 @@ +# SPEC / Accordion + +## Reference + +- **W3C pattern**: [APG Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/), + over the normative + [WAI-ARIA 1.2 `button` / `region` / `aria-expanded` / `aria-controls`](https://www.w3.org/TR/wai-aria-1.2/) + definitions. +- **State machine**: built on `@dunky.dev/state-machine`. +- **Prior art**: API shape modeled on the Radix, Base UI, and Ark accordions. + +## Overview + +An accordion is a vertically (or horizontally) stacked set of headers, each +controlling a section of content that expands or collapses beneath it. It +condenses long or optional content into scannable headings — settings groups, +FAQs, progressive disclosure — where the user chooses what to read instead of +scrolling past everything. + +## Anatomy + +``` + — root; owns the open value and keyboard focus, renders nothing of its own + |_ — one disclosure: identified by its value, disableable + |_
— the heading wrapper that gives the trigger its outline level + | |_ — the button that toggles the item and hosts keyboard navigation + |_ — the section the trigger expands and collapses; hidden while closed +``` + +## Behavior + +Using the accordion is a walkthrough of intent, not a prop list: + +- The **root** owns which items are open, exposed controlled and uncontrolled. + The `type` discriminant picks the value shape: `single` holds at most one + open item and its value is one string (or `null`), `multiple` holds any + number and its value is a string array — the value's type follows what the + mode can express, so an impossible state (two open items in single mode) is + unrepresentable rather than merely rejected. Every change to the open set is + reported back through the value callback. A controlled `value` is + authoritative: a press reports the value it asked for and the open set + follows the prop — a change the consumer declines leaves the accordion + exactly where the prop says. +- In `single` mode the accordion is **non-collapsible by default**: re-pressing + the open item's trigger keeps it open, so one section stays expanded. Opting + into `collapsible` lets that re-press close it. In `multiple` mode every item + toggles freely — collapsibility is inherent, not an option. +- An **item** is identified by its `value` — the unit the open set, the ids, + and the callbacks all speak in. Items register with the machine as they + appear and unregister as they leave; registration order is the keyboard + navigation order. A disabled item (its own flag, or the whole accordion's) + ignores toggle intents and is skipped by keyboard navigation. +- The **trigger** toggles its item and carries the disclosure relationship to + assistive tech. All triggers sit in the page tab order; the arrow keys move + focus between them without touching the open state. +- The **content** is the region the trigger controls, labelled by it, and + hidden while the item is closed. It stays part of the anatomy whether open + or closed — visibility is state, not existence. + +Keyboard interaction, per the APG pattern: + +- **Enter / Space** on a trigger toggles its item (the trigger is a native + button, so activation is inherited, not re-implemented). +- **Arrow keys** move focus across enabled triggers, wrapping at both ends. + The axis follows `orientation`: a vertical accordion (the default) uses + ArrowDown / ArrowUp, a horizontal one ArrowRight / ArrowLeft; the cross-axis + keys are left alone. +- **Home / End** jump to the first / last enabled trigger. + +The machine decides which trigger holds focus; the substrate only carries that +decision to the platform. Navigation that lands nowhere (every item disabled, +or the whole accordion disabled) moves nothing — and a disabled accordion does +not consume the keys at all, so their page defaults survive. + +## States + +The machine's states track where keyboard focus is; the open set lives in +context and changes in any state. + +| State | Behavior | +| --------- | -------------------------------------------------------------------------------------------------------------------- | +| `idle` | No trigger holds focus. Toggle intents and item registration work; keyboard navigation has no anchor and is ignored. | +| `focused` | A trigger holds focus. Arrow / Home / End intents move the focused trigger across enabled items, wrapping. | + +### Toggle gating + +A toggle intent is gated in the machine, not the substrate: it is ignored when +the accordion or the item is disabled, and — in single non-collapsible mode — +when it would close the open item. An allowed toggle in `single` mode replaces +the open set; in `multiple` mode it adds or removes just that item. Under a +controlled `value` an allowed toggle moves nothing: it reports the open set it +asked for through the value callback, and the machine follows the prop when +the driver re-syncs it. + +### Focus movement + +Arrow / Home / End intents resolve against the registration order, skipping +disabled items and wrapping at the ends. The machine records the new focused +value and emits a focus request through a mailbox slot — a fresh token per +move, so the substrate reacts to every move, even one that lands on the same +trigger. DOM focus then follows the machine; the trigger's own focus/blur +events report back where focus actually is. + +## Accessibility + +Per APG Accordion: + +- **Trigger**: a button carrying `aria-expanded` for the item's state and + `aria-controls` referencing its content. Disabled items expose + `aria-disabled` — the machine ignores their toggles, so the trigger stays + focusable and discoverable rather than vanishing from the tab order. In + single non-collapsible mode the open item's trigger also announces + `aria-disabled` (per APG: a visible panel that cannot be collapsed disables + its header) — presentation only, so keyboard navigation still visits it and + the item's `data-disabled` is untouched. +- **Header**: a heading wrapping the trigger, giving it a place in the page + outline. +- **Content**: `role="region"` labelled by its trigger (`aria-labelledby`), so + an expanded section is a navigable landmark announced by its heading. +- **Ids**: every trigger/content id derives from the root's substrate-minted + id plus the item's value, so the cross-references always agree and stay + SSR-safe. The value is URI-encoded into the id, so any value — whitespace + included — yields a valid HTML id and a single resolvable IDREF. +- **Styling hooks**: every part carries `data-state` (`open` / `closed`), + `data-disabled` when disabled, and `data-orientation`. + +## Constraints + +- `aria-expanded` / `aria-controls` / `aria-labelledby` always describe the + actual open state and reference ids that resolve. +- In `single` mode at most one item is ever open; non-collapsible `single` + never reaches all-closed through user interaction (a controlled value or + default can still express it). +- Toggle and navigation intents on disabled items are ignored in the machine — + no substrate may re-implement the gate. +- Every change to the open set — or, under a controlled `value`, every change + a press asked for — is reported to the consumer; the controlled prop itself + is never echoed back through the callback. +- Keyboard navigation order is registration order: an item mounted later + appends to the order even if it renders earlier in the document. +- `type`, `collapsible`, and controlled-ness are fixed at build time; + `disabled`, `orientation`, and the controlled `value` re-sync from props + through machine events. +- Horizontal navigation is LTR-fixed in v0: `orientation="horizontal"` always + maps next to ArrowRight and previous to ArrowLeft. RTL awareness (a `dir` + option flipping the horizontal arrows, as Radix / Base UI / Ark do) is a + deliberate v0 exclusion, to be added with the wider direction story. + +## Internals + +- **Value shape follows the mode** (Radix's discriminant, over Ark's + always-array): `single` speaks `string | null`, `multiple` speaks + `string[]`. Internally the machine holds one canonical `string[]` — the + connect and the substrate translate at the edges, so the machine has a + single code path. +- **Registration order over DOM order**: the machine cannot see a DOM, so + navigation order is the order items report themselves. This keeps the core + substrate-free at the cost of the reorder caveat under Constraints. +- **`aria-disabled` over native `disabled`** on the trigger: the binding + vocabulary maps logical `disabled` to the ARIA attribute, keeping disabled + triggers perceivable to assistive tech; the actual gate lives in the + machine's guards. +- **Focus is a mailbox, not derived state**: which trigger should receive DOM + focus is an event-shaped decision (it can re-fire with the same target), so + it flows through a fresh-token context slot rather than being derived from + `focusedValue`. +- **The root renders no element of its own** — a deliberate v0 deviation from + the Radix / Base UI / Ark root `div`. The accordion needs no container for + behavior or accessibility (keyboard handling lives on the triggers, and no + ARIA relationship binds the items to a wrapper), so the root owns only state + and a root element would exist purely for consumer layout — which the + consumer supplies. Revisited together with the polymorphic-rendering + decision. +- **A controlled value is authoritative** (the Radix semantics): controlled-ness + is seeded into context at build time, and an allowed toggle then emits the + open set it asked for through an intent mailbox instead of moving the value — + the machine moves only when the driver re-syncs the prop. Uncontrolled + accordions move immediately and report from the value itself, so a rejected + update can never leave the UI drifted from the prop. diff --git a/packages/core/accordion/package.json b/packages/core/accordion/package.json new file mode 100644 index 0000000..569c178 --- /dev/null +++ b/packages/core/accordion/package.json @@ -0,0 +1,40 @@ +{ + "name": "@dunky.dev/accordion", + "version": "0.0.0", + "description": "Framework-agnostic accordion interaction, modeled as a state machine.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/core/accordion" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "^0.1.0", + "@dunky.dev/state-machine-bindings": "^0.1.0" + } +} diff --git a/packages/core/accordion/src/connect.ts b/packages/core/accordion/src/connect.ts new file mode 100644 index 0000000..57d6f25 --- /dev/null +++ b/packages/core/accordion/src/connect.ts @@ -0,0 +1,180 @@ +import { makeReaction, type Connect } from '@dunky.dev/state-machine' +import type { + AttrBindings, + EventBindings, + KeyboardPayload, +} from '@dunky.dev/state-machine-bindings' +import type { + AccordionContext, + AccordionIds, + AccordionItemOptions, + AccordionMachineEvent, + AccordionOptions, + AccordionOrientation, + AccordionStateName, +} from './types' + +// The bindings a part carries, drawn from the shared agnostic vocabulary; the +// index signature keeps parts assignable to the loose shape each substrate's +// normalize() accepts. `data-state` is the styling/animation hook. +export type AccordionPartBindings = EventBindings & + AttrBindings & { 'data-state'?: 'open' | 'closed' } & Record + +// The per-item ids all derive from the one base id plus the item value, so the +// trigger's aria-controls and the content's aria-labelledby always agree. +// The value is URI-encoded: an id must hold no whitespace, and aria-controls / +// aria-labelledby are IDREF lists, so a raw value like "item one" would split +// into two dangling references. +// Exported: the substrate's focus effect resolves the same trigger id. +export function accordionIds(id: string): AccordionIds { + return { + trigger: value => `${id}-trigger-${encodeURIComponent(value)}`, + content: value => `${id}-content-${encodeURIComponent(value)}`, + } +} + +/** The view-facing surface a driver reads from the running accordion machine. */ +export interface AccordionApi { + value: string[] + focusedValue: string | null + orientation: AccordionOrientation + disabled: boolean + ids: AccordionIds + setValue: (value: string[]) => void + isItemOpen: (value: string) => boolean + parts: { + item: (item: AccordionItemOptions) => AccordionPartBindings + header: (item: AccordionItemOptions) => AccordionPartBindings + trigger: (item: AccordionItemOptions) => AccordionPartBindings + content: (item: AccordionItemOptions) => AccordionPartBindings + } +} + +export const accordionConnect: Connect< + AccordionStateName, + AccordionContext, + AccordionMachineEvent, + AccordionOptions, + AccordionApi +> = ({ context, send }) => { + const ids = accordionIds(context.id) + const orientation = context.orientation + // The navigation axis follows orientation; cross-axis keys are left alone. + const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight' + const previousKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft' + + const isItemOpen = (value: string): boolean => context.value.includes(value) + const isItemDisabled = (item: AccordionItemOptions): boolean => + context.disabled || item.disabled === true + + // Shared across triggers (it never reads the item). A disabled accordion + // handles no navigation at all — the keys keep their page defaults. + const onTriggerKeyDown = (event?: KeyboardPayload): void => { + if (context.disabled) return + switch (event?.key) { + case nextKey: + send({ type: 'focus.next' }) + break + case previousKey: + send({ type: 'focus.previous' }) + break + case 'Home': + send({ type: 'focus.first' }) + break + case 'End': + send({ type: 'focus.last' }) + break + default: + return + } + // The key was consumed for navigation — the page must not scroll. + event.preventDefault?.() + } + + // The shared styling hooks every part of an item carries. + const itemAttrs = (item: AccordionItemOptions): AccordionPartBindings => ({ + 'data-state': isItemOpen(item.value) ? 'open' : 'closed', + 'data-disabled': isItemDisabled(item) ? '' : undefined, + 'data-orientation': orientation, + }) + + return { + // A copy, like every array crossing the boundary — a driver mutation must + // not corrupt machine context. + value: context.value.slice(), + focusedValue: context.focusedValue, + orientation, + disabled: context.disabled, + ids, + isItemOpen, + setValue(next) { + send({ type: 'value.set', value: next }) + }, + parts: { + item: itemAttrs, + header: itemAttrs, + trigger(item) { + const bindings = itemAttrs(item) + bindings.id = ids.trigger(item.value) + bindings.controls = ids.content(item.value) + bindings.expanded = isItemOpen(item.value) + // The gate lives in the machine's toggle guard; aria-disabled keeps + // the trigger perceivable (see SPEC.md — Design). Per APG, the open + // trigger of a non-collapsible single accordion also announces as + // disabled — presentation only: navigation still visits it and the + // item's data-disabled is untouched. + bindings.disabled = + isItemDisabled(item) || (isItemOpen(item.value) && !context.collapsible) || undefined + bindings.onPress = () => send({ type: 'toggle', value: item.value }) + bindings.onFocus = () => send({ type: 'focus.set', value: item.value }) + bindings.onBlur = () => send({ type: 'focus.clear' }) + bindings.onKeyDown = onTriggerKeyDown + return bindings + }, + content(item) { + const bindings = itemAttrs(item) + bindings.role = 'region' + bindings.id = ids.content(item.value) + bindings.labelledBy = ids.trigger(item.value) + return bindings + }, + }, + } +} + +const reaction = makeReaction< + AccordionStateName, + AccordionContext, + AccordionMachineEvent, + AccordionOptions +>() + +// The consumer speaks the mode's shape: the array for multiple, the single +// item (or null) for single. Multiple gets a copy — handing out the context +// array would let a consumer mutation corrupt the machine. +function reportValue(value: string[], props: AccordionOptions): void { + if (props.type === 'multiple') props.onValueChange?.(value.slice()) + else props.onValueChange?.(value[0] ?? null) +} + +// One reaction per consumer callback. Reactions fire in registration order — +// that order is the callback-order contract. See SPEC.md. +accordionConnect.reactions = [ + // Uncontrolled: the machine owns the value, every change reports. Under a + // controlled value the selector pins to null so the driver's prop re-sync is + // never echoed back at the consumer. + reaction( + m => (m.context.controlled ? null : m.context.value), + (value, props) => { + if (value !== null) reportValue(value, props) + }, + ), + // Controlled: each toggle reports the open set it asked for; the value + // itself moves only when the prop comes back through value.set. + reaction( + m => m.context.valueIntent, + (intent, props) => { + if (intent !== null) reportValue(intent.value, props) + }, + ), +] diff --git a/packages/core/accordion/src/index.ts b/packages/core/accordion/src/index.ts new file mode 100644 index 0000000..e6e2144 --- /dev/null +++ b/packages/core/accordion/src/index.ts @@ -0,0 +1,20 @@ +export { accordionMachine, type AccordionMachine } from './machine' +export { + accordionConnect, + accordionIds, + type AccordionApi, + type AccordionPartBindings, +} from './connect' +export type { + AccordionContext, + AccordionIds, + AccordionItemOptions, + AccordionMachineEvent, + AccordionMultipleOptions, + AccordionOptions, + AccordionOrientation, + AccordionSingleOptions, + AccordionStateName, + AccordionType, +} from './types' +export type { KeyboardPayload } from '@dunky.dev/state-machine-bindings' diff --git a/packages/core/accordion/src/machine.ts b/packages/core/accordion/src/machine.ts new file mode 100644 index 0000000..8466ba9 --- /dev/null +++ b/packages/core/accordion/src/machine.ts @@ -0,0 +1,236 @@ +import { + setup, + type Action, + type Guard, + type Machine, + type TransitionConfig, +} from '@dunky.dev/state-machine' +import type { + AccordionContext, + AccordionItemOptions, + AccordionMachineEvent, + AccordionOptions, + AccordionStateName, +} from './types' + +/** The running accordion machine — what a substrate holds and sends events to. */ +export type AccordionMachine = Machine + +type AccordionAction = Action +type AccordionGuard = Guard + +function isItemDisabled(context: AccordionContext, value: string): boolean { + if (context.disabled) return true + for (const item of context.items) { + if (item.value === value) return item.disabled === true + } + return false +} + +// Single mode holds at most one entry, whatever a driver sent through setValue. +function clampToMode(context: AccordionContext, value: string[]): string[] { + return context.type === 'single' && value.length > 1 ? value.slice(0, 1) : value +} + +function sameValue(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + for (let index = 0; index < a.length; index++) { + if (a[index] !== b[index]) return false + } + return true +} + +const canToggle: AccordionGuard = ({ context, event }) => { + if (event.type !== 'toggle') return false + if (isItemDisabled(context, event.value)) return false + // Single non-collapsible: the open item cannot be closed by re-pressing. + return !context.value.includes(event.value) || context.collapsible +} + +// Dedupe here so drivers can re-send the controlled value every render without +// firing spurious onValueChange reactions. +const valueChanged: AccordionGuard = ({ context, event }) => { + if (event.type !== 'value.set') return false + return !sameValue(context.value, clampToMode(context, event.value)) +} + +function nextToggleValue(context: AccordionContext, value: string): string[] { + if (context.value.includes(value)) return context.value.filter(open => open !== value) + if (context.type === 'single') return [value] + return [...context.value, value] +} + +const toggleValue: AccordionAction = ({ context, event, setContext }) => { + if (event.type !== 'toggle') return + const next = nextToggleValue(context, event.value) + // A controlled value is authoritative: the toggle only reports intent (a + // fresh mailbox token per press); the open set moves when the driver + // re-syncs the prop through value.set. + if (context.controlled) setContext({ valueIntent: { value: next } }) + else setContext({ value: next }) +} + +const setValue: AccordionAction = ({ context, event, setContext }) => { + if (event.type !== 'value.set') return + // Copy at the boundary — the sender keeps ownership of the array it passed. + setContext({ value: clampToMode(context, event.value).slice() }) +} + +const registerItem: AccordionAction = ({ context, event, setContext }) => { + if (event.type !== 'item.register') return + const disabled = event.disabled === true + const items = context.items + const index = items.findIndex(item => item.value === event.value) + if (index === -1) { + setContext({ items: [...items, { value: event.value, disabled }] }) + } else if (items[index].disabled !== disabled) { + // Update in place — a disabled flip must not move the item in the + // navigation order. + const next = items.slice() + next[index] = { value: event.value, disabled } + setContext({ items: next }) + } +} + +const unregisterItem: AccordionAction = ({ context, event, setContext }) => { + if (event.type !== 'item.unregister') return + setContext({ + items: context.items.filter(item => item.value !== event.value), + focusedValue: context.focusedValue === event.value ? null : context.focusedValue, + }) +} + +const setFocusedValue: AccordionAction = ({ event, setContext }) => { + if (event.type !== 'focus.set') return + setContext({ focusedValue: event.value }) +} + +const clearFocusedValue: AccordionAction = ({ setContext }) => { + setContext({ focusedValue: null }) +} + +const setDisabled: AccordionAction = ({ event, setContext }) => { + if (event.type !== 'disabled.set') return + setContext({ disabled: event.disabled }) +} + +const setOrientation: AccordionAction = ({ event, setContext }) => { + if (event.type !== 'orientation.set') return + setContext({ orientation: event.orientation }) +} + +// Records the decision: focusedValue is the new anchor, and the mailbox gets a +// fresh token so the substrate reacts to every move, even a repeated target. +function focusItem( + setContext: (patch: Partial) => void, + item: AccordionItemOptions, +): void { + setContext({ focusedValue: item.value, focusTarget: { value: item.value } }) +} + +// Scan from the focused anchor in `direction`, wrapping and skipping disabled +// items. A full cycle may land back on the anchor itself — focus stays put. +function moveFocus(context: AccordionContext, direction: 1 | -1): AccordionItemOptions | null { + const items = context.items + const count = items.length + if (context.disabled || count === 0) return null + let anchor = items.findIndex(item => item.value === context.focusedValue) + // No anchor (the focused item unregistered): enter from the matching edge. + if (anchor === -1) anchor = direction === 1 ? -1 : 0 + for (let step = 1; step <= count; step++) { + const index = (((anchor + direction * step) % count) + count) % count + if (items[index].disabled !== true) return items[index] + } + return null +} + +function edgeFocus(context: AccordionContext, direction: 1 | -1): AccordionItemOptions | null { + const items = context.items + if (context.disabled) return null + const start = direction === 1 ? 0 : items.length - 1 + for (let index = start; index >= 0 && index < items.length; index += direction) { + if (items[index].disabled !== true) return items[index] + } + return null +} + +const focusNext: AccordionAction = ({ context, setContext }) => { + const item = moveFocus(context, 1) + if (item !== null) focusItem(setContext, item) +} + +const focusPrevious: AccordionAction = ({ context, setContext }) => { + const item = moveFocus(context, -1) + if (item !== null) focusItem(setContext, item) +} + +const focusFirst: AccordionAction = ({ context, setContext }) => { + const item = edgeFocus(context, 1) + if (item !== null) focusItem(setContext, item) +} + +const focusLast: AccordionAction = ({ context, setContext }) => { + const item = edgeFocus(context, -1) + if (item !== null) focusItem(setContext, item) +} + +// The controlled `value` takes precedence over `defaultValue`; `null` is a +// meaningful controlled value (all closed), so the checks are on undefined. +function seedValue(options: AccordionOptions): string[] { + if (options.type === 'multiple') { + return [...(options.value !== undefined ? options.value : (options.defaultValue ?? []))] + } + const seed = options.value !== undefined ? options.value : (options.defaultValue ?? null) + return seed === null ? [] : [seed] +} + +export function accordionMachine( + options: AccordionOptions, +): TransitionConfig { + const context: AccordionContext = { + type: options.type, + // Multiple mode is inherently collapsible; single opts in. + collapsible: options.type === 'multiple' || options.collapsible === true, + controlled: options.value !== undefined, + disabled: options.disabled ?? false, + orientation: options.orientation ?? 'vertical', + // The substrate supplies a unique id; `accordion` is only a bare fallback. + id: options.id ?? 'accordion', + value: seedValue(options), + items: [], + focusedValue: null, + focusTarget: null, + valueIntent: null, + } + + return setup.as().createMachine({ + initial: 'idle', + context, + // Top-level: registration, toggling, and option sync work from any state. + on: { + 'item.register': { actions: registerItem }, + 'item.unregister': { actions: unregisterItem }, + toggle: { guard: canToggle, actions: toggleValue }, + 'value.set': { guard: valueChanged, actions: setValue }, + 'disabled.set': { actions: setDisabled }, + 'orientation.set': { actions: setOrientation }, + }, + states: { + idle: { + on: { + 'focus.set': { target: 'focused', actions: setFocusedValue }, + }, + }, + focused: { + on: { + 'focus.set': { actions: setFocusedValue }, + 'focus.clear': { target: 'idle', actions: clearFocusedValue }, + 'focus.next': focusNext, + 'focus.previous': focusPrevious, + 'focus.first': focusFirst, + 'focus.last': focusLast, + }, + }, + }, + }) +} diff --git a/packages/core/accordion/src/types.ts b/packages/core/accordion/src/types.ts new file mode 100644 index 0000000..684f27a --- /dev/null +++ b/packages/core/accordion/src/types.ts @@ -0,0 +1,116 @@ +// Public + machine-facing types for the framework-agnostic accordion primitive. +// The state machine is substrate-free: all event reading lives in a +// per-substrate driver. + +export type AccordionStateName = 'idle' | 'focused' + +export type AccordionType = 'single' | 'multiple' + +export type AccordionOrientation = 'horizontal' | 'vertical' + +/** An item's identity + disabled flag: what it registers with the machine and + * what the connect's part functions take to scope bindings to one item. */ +export interface AccordionItemOptions { + value: string + disabled?: boolean +} + +/** + * The per-item id factories, derived from the one `id` on context plus the + * item's value: each renders as `id` on one element and as an ARIA reference + * (aria-controls / aria-labelledby) on the other, and the connect wires both + * sides. + */ +export interface AccordionIds { + trigger: (value: string) => string + content: (value: string) => string +} + +export interface AccordionContext { + type: AccordionType + // Resolved per mode at build time: `multiple` is inherently collapsible, so + // the toggle guard only ever reads this flag. + collapsible: boolean + // Fixed at build time: a controlled `value` is authoritative, so a toggle + // reports intent through `valueIntent` instead of moving the open set. + controlled: boolean + disabled: boolean + orientation: AccordionOrientation + // The base id (substrate-minted, SSR-safe); the connect derives the per-item + // ids from it. + id: string + /** The canonical open set — `single` mode holds at most one entry. */ + value: string[] + /** Registered items in registration order — the keyboard navigation order. */ + items: AccordionItemOptions[] + /** The value of the trigger that currently holds focus. */ + focusedValue: string | null + /** Focus mailbox: a fresh token per navigation move, for the substrate to + * carry to the platform — even when the target repeats. */ + focusTarget: { value: string } | null + /** Intent mailbox for the controlled accordion: the open set a toggle asked + * for — a fresh token per press, reported through `onValueChange` while the + * value itself waits for the prop. */ + valueIntent: { value: string[] } | null +} + +// Navigation intents are distinct events (not payloads) so the focused state +// alone handles them; toggling and registration stay any-state. +export type AccordionMachineEvent = + | { type: 'toggle'; value: string } + | { type: 'value.set'; value: string[] } + | { type: 'item.register'; value: string; disabled?: boolean } + | { type: 'item.unregister'; value: string } + | { type: 'focus.set'; value: string } + | { type: 'focus.clear' } + | { type: 'focus.next' } + | { type: 'focus.previous' } + | { type: 'focus.first' } + | { type: 'focus.last' } + | { type: 'disabled.set'; disabled: boolean } + | { type: 'orientation.set'; orientation: AccordionOrientation } + +interface AccordionBaseOptions { + /** Base id for the accordion's parts; the substrate supplies a unique, + * SSR-safe one. The per-item ids derive from it plus the item value. */ + id?: string + /** Disables every item. @default false */ + disabled?: boolean + /** The arrow-key navigation axis. @default 'vertical' */ + orientation?: AccordionOrientation +} + +export interface AccordionSingleOptions extends AccordionBaseOptions { + /** At most one item open; the value is one string or `null`. */ + type: 'single' + /** Controlled open value — authoritative: presses report intent through + * `onValueChange` and the accordion follows this prop. */ + value?: string | null + /** Initial open value for the uncontrolled accordion. @default null */ + defaultValue?: string | null + /** Whether re-pressing the open item's trigger closes it. @default false */ + collapsible?: boolean + /** Reports the next value: the change made (uncontrolled) or the change a + * press asked for (controlled). */ + onValueChange?: (value: string | null) => void +} + +export interface AccordionMultipleOptions extends AccordionBaseOptions { + /** Any number of items open; the value is a string array. */ + type: 'multiple' + /** Controlled open value — authoritative: presses report intent through + * `onValueChange` and the accordion follows this prop. */ + value?: string[] + /** Initial open value for the uncontrolled accordion. @default [] */ + defaultValue?: string[] + /** Reports the next value: the change made (uncontrolled) or the change a + * press asked for (controlled). */ + onValueChange?: (value: string[]) => void +} + +/** + * The agnostic accordion options — the behavior a consumer configures. The + * `type` discriminant picks the value shape (see SPEC.md — Design). A + * substrate's props extend this with its own concerns (e.g. `children`). + */ +export type AccordionOptions = AccordionSingleOptions | AccordionMultipleOptions diff --git a/packages/core/accordion/tests/machine.test.ts b/packages/core/accordion/tests/machine.test.ts new file mode 100644 index 0000000..a6127b3 --- /dev/null +++ b/packages/core/accordion/tests/machine.test.ts @@ -0,0 +1,419 @@ +// The agnostic core of the accordion — machine + connect, no DOM, no framework. +import { describe, expect, it, vi } from 'vitest' +import { connector, machine, type Connector, type Machine } from '@dunky.dev/state-machine' +import { accordionConnect, accordionIds, accordionMachine } from '@dunky.dev/accordion' +import type { + AccordionApi, + AccordionContext, + AccordionItemOptions, + AccordionMachineEvent, + AccordionOptions, + AccordionStateName, +} from '@dunky.dev/accordion' + +// The per-item ids the connect derives from a base id of `acc`. +const ids = accordionIds('acc') + +interface Harness { + service: Machine + connection: Connector +} + +// Builds a started machine with items a/b/c registered — the standard fixture. +const build = ( + options: AccordionOptions = { type: 'single' }, + items: AccordionItemOptions[] = [{ value: 'a' }, { value: 'b' }, { value: 'c' }], +): Harness => { + const service = machine(accordionMachine({ id: 'acc', ...options })) + const connection = connector(service, accordionConnect, options) + service.start() + for (const item of items) { + service.send({ type: 'item.register', value: item.value, disabled: item.disabled }) + } + return { service, connection } +} + +const focus = (service: Harness['service'], value: string): void => { + service.send({ type: 'focus.set', value }) +} + +describe('accordion machine — open value', () => { + it('starts fully closed with no default', () => { + const { service } = build() + expect(service.context.value).toEqual([]) + }) + + it('seeds the single value from defaultValue, controlled value winning', () => { + expect(build({ type: 'single', defaultValue: 'b' }).service.context.value).toEqual(['b']) + expect(build({ type: 'single', value: 'a', defaultValue: 'b' }).service.context.value).toEqual([ + 'a', + ]) + }) + + it('seeds the multiple value array', () => { + const { service } = build({ type: 'multiple', defaultValue: ['a', 'c'] }) + expect(service.context.value).toEqual(['a', 'c']) + }) + + it('single: opening another item closes the open one', () => { + const { service } = build({ type: 'single', defaultValue: 'a' }) + service.send({ type: 'toggle', value: 'b' }) + expect(service.context.value).toEqual(['b']) + }) + + it('single non-collapsible (the default): re-toggling the open item keeps it open', () => { + const { service } = build({ type: 'single', defaultValue: 'a' }) + service.send({ type: 'toggle', value: 'a' }) + expect(service.context.value).toEqual(['a']) + }) + + it('single collapsible: re-toggling the open item closes it', () => { + const { service } = build({ type: 'single', collapsible: true, defaultValue: 'a' }) + service.send({ type: 'toggle', value: 'a' }) + expect(service.context.value).toEqual([]) + }) + + it('multiple: items toggle independently', () => { + const { service } = build({ type: 'multiple' }) + service.send({ type: 'toggle', value: 'a' }) + service.send({ type: 'toggle', value: 'c' }) + expect(service.context.value).toEqual(['a', 'c']) + service.send({ type: 'toggle', value: 'a' }) + expect(service.context.value).toEqual(['c']) + }) + + it('single: value.set clamps the open set to at most one entry', () => { + const { service } = build({ type: 'single' }) + service.send({ type: 'value.set', value: ['b', 'c'] }) + expect(service.context.value).toEqual(['b']) + }) + + it('controlled: a toggle reports intent without moving the value; value.set is not echoed', () => { + const onValueChange = vi.fn() + const { service } = build({ type: 'single', value: 'a', onValueChange }) + + service.send({ type: 'toggle', value: 'b' }) + expect(service.context.value).toEqual(['a']) + expect(onValueChange).toHaveBeenLastCalledWith('b') + + service.send({ type: 'value.set', value: ['b'] }) + expect(service.context.value).toEqual(['b']) + expect(onValueChange).toHaveBeenCalledTimes(1) + }) + + it('arrays never cross the boundary by reference — in or out', () => { + const onValueChange = vi.fn() + const { service, connection } = build({ type: 'multiple', onValueChange }) + + const incoming = ['a'] + service.send({ type: 'value.set', value: incoming }) + incoming.push('b') + expect(service.context.value).toEqual(['a']) + + onValueChange.mock.calls[0][0].push('c') + expect(service.context.value).toEqual(['a']) + + connection.snapshot.value.push('d') + expect(service.context.value).toEqual(['a']) + }) +}) + +describe('accordion machine — disabled gating', () => { + it('toggle on a disabled item is ignored', () => { + const { service } = build({ type: 'single' }, [{ value: 'a' }, { value: 'b', disabled: true }]) + service.send({ type: 'toggle', value: 'b' }) + expect(service.context.value).toEqual([]) + }) + + it('toggle is ignored while the whole accordion is disabled', () => { + const { service } = build({ type: 'single', disabled: true }) + service.send({ type: 'toggle', value: 'a' }) + expect(service.context.value).toEqual([]) + }) + + it('disabled.set re-enables toggling', () => { + const { service } = build({ type: 'single', disabled: true }) + service.send({ type: 'disabled.set', disabled: false }) + service.send({ type: 'toggle', value: 'a' }) + expect(service.context.value).toEqual(['a']) + }) +}) + +describe('accordion machine — item registry', () => { + it('registers in order and unregisters by value', () => { + const { service } = build() + expect(service.context.items.map(item => item.value)).toEqual(['a', 'b', 'c']) + + service.send({ type: 'item.unregister', value: 'b' }) + expect(service.context.items.map(item => item.value)).toEqual(['a', 'c']) + }) + + it('re-registering a value updates disabled in place, keeping the order', () => { + const { service } = build() + service.send({ type: 'item.register', value: 'b', disabled: true }) + expect(service.context.items.map(item => item.value)).toEqual(['a', 'b', 'c']) + expect(service.context.items[1].disabled).toBe(true) + }) +}) + +describe('accordion machine — focus', () => { + it('focus.set enters focused; focus.clear returns to idle', () => { + const { service } = build() + expect(service.state).toBe('idle') + + focus(service, 'a') + expect(service.state).toBe('focused') + expect(service.context.focusedValue).toBe('a') + + service.send({ type: 'focus.clear' }) + expect(service.state).toBe('idle') + expect(service.context.focusedValue).toBeNull() + }) + + it('navigation is ignored while idle', () => { + const { service } = build() + service.send({ type: 'focus.next' }) + expect(service.context.focusTarget).toBeNull() + expect(service.context.focusedValue).toBeNull() + }) + + it('focus.next moves forward, skipping disabled items and wrapping at the end', () => { + const { service } = build({ type: 'single' }, [ + { value: 'a' }, + { value: 'b', disabled: true }, + { value: 'c' }, + ]) + focus(service, 'a') + + service.send({ type: 'focus.next' }) + expect(service.context.focusedValue).toBe('c') + + service.send({ type: 'focus.next' }) + expect(service.context.focusedValue).toBe('a') + }) + + it('focus.previous moves backward with wrap', () => { + const { service } = build() + focus(service, 'a') + service.send({ type: 'focus.previous' }) + expect(service.context.focusedValue).toBe('c') + }) + + it('focus.first / focus.last land on the first / last enabled item', () => { + const { service } = build({ type: 'single' }, [ + { value: 'a', disabled: true }, + { value: 'b' }, + { value: 'c' }, + { value: 'd', disabled: true }, + ]) + focus(service, 'c') + + service.send({ type: 'focus.first' }) + expect(service.context.focusedValue).toBe('b') + + service.send({ type: 'focus.last' }) + expect(service.context.focusedValue).toBe('c') + }) + + it('each move writes a fresh focus mailbox token', () => { + const { service } = build() + focus(service, 'b') + + service.send({ type: 'focus.first' }) + const first = service.context.focusTarget + expect(first).toEqual({ value: 'a' }) + + service.send({ type: 'focus.first' }) + expect(service.context.focusTarget).toEqual({ value: 'a' }) + expect(service.context.focusTarget).not.toBe(first) + }) + + it('navigation moves nothing while the accordion is disabled', () => { + const { service } = build({ type: 'single', disabled: true }) + focus(service, 'a') + service.send({ type: 'focus.next' }) + expect(service.context.focusedValue).toBe('a') + expect(service.context.focusTarget).toBeNull() + }) + + it('navigation moves nothing when every item is disabled', () => { + const { service } = build({ type: 'single' }, [ + { value: 'a', disabled: true }, + { value: 'b', disabled: true }, + ]) + focus(service, 'a') + + service.send({ type: 'focus.next' }) + service.send({ type: 'focus.first' }) + expect(service.context.focusedValue).toBe('a') + expect(service.context.focusTarget).toBeNull() + }) + + it('after the focused item unregisters, navigation enters from the matching edge', () => { + const forward = build() + focus(forward.service, 'b') + forward.service.send({ type: 'item.unregister', value: 'b' }) + forward.service.send({ type: 'focus.next' }) + expect(forward.service.context.focusedValue).toBe('a') + + const backward = build() + focus(backward.service, 'b') + backward.service.send({ type: 'item.unregister', value: 'b' }) + backward.service.send({ type: 'focus.previous' }) + expect(backward.service.context.focusedValue).toBe('c') + }) +}) + +describe('accordion connect — logical bindings', () => { + it('trigger carries the disclosure wiring and toggles', () => { + const { service, connection } = build() + const trigger = connection.snapshot.parts.trigger({ value: 'a' }) + expect(trigger.id).toBe(ids.trigger('a')) + expect(trigger.controls).toBe(ids.content('a')) + expect(trigger.expanded).toBe(false) + + trigger.onPress?.() + expect(service.context.value).toEqual(['a']) + expect(connection.snapshot.parts.trigger({ value: 'a' }).expanded).toBe(true) + }) + + it('trigger focus/blur drive the focus state', () => { + const { service, connection } = build() + connection.snapshot.parts.trigger({ value: 'b' }).onFocus?.() + expect(service.state).toBe('focused') + expect(service.context.focusedValue).toBe('b') + + connection.snapshot.parts.trigger({ value: 'b' }).onBlur?.() + expect(service.state).toBe('idle') + }) + + it('the arrow axis follows orientation, live', () => { + const { service, connection } = build({ type: 'single', orientation: 'horizontal' }) + focus(service, 'a') + + const press = (key: string) => { + const payload = { + key, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + }, + } + connection.snapshot.parts.trigger({ value: 'a' }).onKeyDown?.(payload) + return payload + } + + expect(press('ArrowDown').defaultPrevented).toBe(false) + expect(service.context.focusedValue).toBe('a') + + expect(press('ArrowRight').defaultPrevented).toBe(true) + expect(service.context.focusedValue).toBe('b') + + service.send({ type: 'orientation.set', orientation: 'vertical' }) + expect(press('ArrowDown').defaultPrevented).toBe(true) + expect(service.context.focusedValue).toBe('c') + }) + + it('Home and End keys jump across enabled triggers', () => { + const { service, connection } = build() + focus(service, 'b') + + connection.snapshot.parts.trigger({ value: 'b' }).onKeyDown?.({ key: 'End' }) + expect(service.context.focusedValue).toBe('c') + + connection.snapshot.parts.trigger({ value: 'c' }).onKeyDown?.({ key: 'Home' }) + expect(service.context.focusedValue).toBe('a') + }) + + it('a disabled accordion leaves the navigation keys their default behavior', () => { + const { connection } = build({ type: 'single', disabled: true }) + const payload = { + key: 'Home', + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + }, + } + connection.snapshot.parts.trigger({ value: 'a' }).onKeyDown?.(payload) + expect(payload.defaultPrevented).toBe(false) + }) + + it('content carries the region wiring', () => { + const { connection } = build() + const content = connection.snapshot.parts.content({ value: 'a' }) + expect(content.role).toBe('region') + expect(content.id).toBe(ids.content('a')) + expect(content.labelledBy).toBe(ids.trigger('a')) + }) + + it('an item value with whitespace still derives a valid, single-id reference', () => { + expect(ids.trigger('item one')).not.toMatch(/\s/) + }) + + it('parts expose data-state / data-disabled / data-orientation', () => { + const { connection } = build({ type: 'single', defaultValue: 'a' }) + const openItem = connection.snapshot.parts.item({ value: 'a' }) + expect(openItem['data-state']).toBe('open') + expect(openItem['data-orientation']).toBe('vertical') + expect(openItem['data-disabled']).toBeUndefined() + + const disabledHeader = connection.snapshot.parts.header({ value: 'b', disabled: true }) + expect(disabledHeader['data-state']).toBe('closed') + expect(disabledHeader['data-disabled']).toBe('') + }) + + it('a disabled trigger is exposed as aria-disabled, per-item or accordion-wide', () => { + const { connection } = build() + expect(connection.snapshot.parts.trigger({ value: 'b', disabled: true }).disabled).toBe(true) + expect(connection.snapshot.parts.trigger({ value: 'a' }).disabled).toBeUndefined() + + const disabledAll = build({ type: 'single', disabled: true }) + expect(disabledAll.connection.snapshot.parts.trigger({ value: 'a' }).disabled).toBe(true) + }) + + it('single non-collapsible: the open trigger announces aria-disabled, per APG', () => { + const { connection } = build({ type: 'single', defaultValue: 'a' }) + expect(connection.snapshot.parts.trigger({ value: 'a' }).disabled).toBe(true) + // Presentation only — the item itself is not styled or navigated as disabled. + expect(connection.snapshot.parts.item({ value: 'a' })['data-disabled']).toBeUndefined() + + const collapsible = build({ type: 'single', collapsible: true, defaultValue: 'a' }) + expect(collapsible.connection.snapshot.parts.trigger({ value: 'a' }).disabled).toBeUndefined() + }) + + it('setValue drives the open set; the machine dedupes unchanged sets', () => { + const onValueChange = vi.fn() + const { service, connection } = build({ type: 'multiple', onValueChange }) + + connection.snapshot.setValue(['b', 'c']) + expect(service.context.value).toEqual(['b', 'c']) + expect(onValueChange).toHaveBeenCalledTimes(1) + + connection.snapshot.setValue(['b', 'c']) + expect(onValueChange).toHaveBeenCalledTimes(1) + }) +}) + +describe('accordion connect — reactions', () => { + it('single: onValueChange reports the item value, and null on collapse', () => { + const onValueChange = vi.fn() + const { service } = build({ type: 'single', collapsible: true, onValueChange }) + expect(onValueChange).not.toHaveBeenCalled() + + service.send({ type: 'toggle', value: 'a' }) + expect(onValueChange).toHaveBeenLastCalledWith('a') + + service.send({ type: 'toggle', value: 'a' }) + expect(onValueChange).toHaveBeenLastCalledWith(null) + expect(onValueChange).toHaveBeenCalledTimes(2) + }) + + it('multiple: onValueChange reports the open array', () => { + const onValueChange = vi.fn() + const { service } = build({ type: 'multiple', onValueChange }) + + service.send({ type: 'toggle', value: 'a' }) + service.send({ type: 'toggle', value: 'c' }) + expect(onValueChange).toHaveBeenLastCalledWith(['a', 'c']) + }) +}) diff --git a/packages/react/accordion/README.md b/packages/react/accordion/README.md new file mode 100644 index 0000000..a5eb3cf --- /dev/null +++ b/packages/react/accordion/README.md @@ -0,0 +1,42 @@ +# @dunky.dev/react-accordion + +React binding for [`@dunky.dev/accordion`](../../core/accordion): a compound +component — `Accordion` plus its parts — that drives the framework-free +accordion machine. The root owns the machine; parts translate the core's +logical bindings into DOM attributes and handlers, and wire the DOM-only +concerns (moving focus to the trigger the machine picks, hiding closed +content). + +Behavior contract: [`../../core/accordion/SPEC.md`](../../core/accordion/SPEC.md). +React-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/react-accordion +``` + +## Usage + +```tsx +import { Accordion } from '@dunky.dev/react-accordion' + +function Faq() { + return ( + + + + Shipping + + Ships in 3-5 business days. + + + + Returns + + Free returns within 30 days. + + + ) +} +``` diff --git a/packages/react/accordion/SPEC.md b/packages/react/accordion/SPEC.md new file mode 100644 index 0000000..6a45f34 --- /dev/null +++ b/packages/react/accordion/SPEC.md @@ -0,0 +1,99 @@ +# SPEC / React / Accordion + +The React implementation of the [core spec](../../core/accordion/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/accordion`](https://dunky.dev/ui/components/accordion). + +## Install + +```sh +npm install @dunky.dev/react-accordion +``` + +## Usage + +```tsx +import { Accordion } from '@dunky.dev/react-accordion' +; + + + Shipping + + Ships in 3-5 business days. + + + + Returns + + Free returns within 30 days. + + +``` + +React-specific notes on top of the core contract: + +- **`Content`** stays mounted while closed and hides with the native `hidden` + attribute — the DOM keeps the trigger's `aria-controls` reference resolvable + and the content findable in-page. +- The value prop shape follows the `type` discriminant: `type="single"` takes + `value` / `defaultValue` / `onValueChange` in `string | null`, + `type="multiple"` takes them in `string[]`. +- Everything ships headless — parts carry behavior, ARIA wiring, and + `data-state` / `data-disabled` / `data-orientation` attributes; styling is + the consumer's. + +## API + +### `Accordion` + +The root: owns the open value and keyboard focus, renders no DOM. Accepts the +core `AccordionOptions`. + +| Prop | Type | Default | Description | +| --------------- | --------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | +| `type` | `'single' \| 'multiple'` | — | Whether one or many items can be open; picks the value shape. Required. | +| `value` | `string \| null` (single) / `string[]` (multiple) | — | Controlled open value — authoritative; the accordion follows the prop. | +| `defaultValue` | `string \| null` (single) / `string[]` (multiple) | `null` / `[]` | Initial open value for the uncontrolled accordion. | +| `onValueChange` | `(value: string \| null) => void` / `(value: string[]) => void` | — | Reports the next value in the mode's shape: the change made (uncontrolled) or asked for (controlled). | +| `collapsible` | `boolean` (single only) | `false` | Whether re-pressing the open item's trigger closes it. | +| `disabled` | `boolean` | `false` | Disables every item. | +| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | The arrow-key navigation axis. | +| `id` | `string` | auto (`useId`) | Base id for the parts; per-item ids are derived from it. | +| `children` | `ReactNode` | — | The accordion's items. | + +### `Accordion.Item` + +One disclosure; registers itself with the machine and scopes the parts inside +to its `value`. + +| Prop | Type | Default | Description | +| ---------- | ----------------------- | ------- | ------------------------------------------------ | +| `value` | `string` | — | The item's identity in the open value. Required. | +| `disabled` | `boolean` | `false` | Disables this item. | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. | + +### `Accordion.Header` + +The heading wrapper around the trigger. + +| Prop | Type | Default | Description | +| ---------- | ---------------------- | ------- | --------------------------------- | +| `...props` | `ComponentProps<'h3'>` | — | Forwarded to the rendered `

`. | + +### `Accordion.Trigger` + +Toggles its item; hosts the arrow-key navigation. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered `