@tapestry-ui/accordion 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Minton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # @tapestry-ui/accordion
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A nestable, accessible accordion for **Preact** — one disclosure, or a
4
+ set of them. Headless
5
+ behavior, skin via CSS. Forged inside a woven-tapestry video game,
6
+ where it learned its most important lesson the hard way (see **The
7
+ container contract**).
8
+
9
+ ```sh
10
+ npm i @tapestry-ui/accordion
11
+ ```
12
+
13
+ ```tsx
14
+ import { Disclosure, Accordion } from '@tapestry-ui/accordion';
15
+ import '@tapestry-ui/accordion/styles.css'; // structural only — optional
16
+
17
+ <Disclosure summary="SOUND">
18
+ <VolumeSliders />
19
+ </Disclosure>
20
+
21
+ // nesting is just children
22
+ <Disclosure summary="settings" defaultOpen>
23
+ <Disclosure summary="advanced">…</Disclosure>
24
+ </Disclosure>
25
+
26
+ // the data-source layer; `exclusive` makes it an accordion
27
+ <Accordion
28
+ exclusive
29
+ items={[
30
+ { summary: 'one', content: <A /> },
31
+ { summary: 'two', content: <B /> },
32
+ ]}
33
+ />
34
+ ```
35
+
36
+ ## Accessibility (WCAG 2.1 AA by design, not by audit)
37
+ - The summary is a **real `<button>`** — Enter/Space, focus, and every
38
+ assistive tech behavior come free and honest.
39
+ - `aria-expanded` + `aria-controls` wire the WAI-ARIA disclosure
40
+ pattern; ids are auto-generated and collision-free under nesting.
41
+ - `headingLevel={2..6}` wraps the button in a real heading, so an accordion
42
+ that titles a section never lies to the document outline.
43
+ - The closed body is **removed from the DOM**, not visually hidden —
44
+ screen readers never wade through collapsed forests.
45
+ - `:focus-visible` outline ships in the structural CSS and is yours to
46
+ restyle, never to remove.
47
+
48
+ ## Sliding open (`keepMounted`)
49
+ By default a closed body is **removed**, which is honest but cannot be
50
+ animated — there is nothing to animate from. Pass `keepMounted` and the
51
+ body stays in the DOM, carrying **`inert`** whenever it is closed:
52
+
53
+ ```tsx
54
+ <Disclosure summary="SOUND" keepMounted>…</Disclosure>
55
+ ```
56
+
57
+ `inert` takes the closed content out of the focus order **and** out of the
58
+ accessibility tree — the same promise removal makes — so the accordion can
59
+ slide without lying to anyone. The shipped CSS animates it with the
60
+ `0fr → 1fr` grid trick (and hides it outright once shut, so browsers
61
+ without `inert` keep the same guarantee). `prefers-reduced-motion:
62
+ reduce` turns the transition off.
63
+
64
+ | custom property | default | what it tunes |
65
+ | --- | --- | --- |
66
+ | `--tui-disclosure-slide` | `280ms` | how long the body takes to open and shut |
67
+
68
+ The children are wrapped in `[data-part="body-inner"]` in this mode, since
69
+ the clip has to happen on an element that is not the grid row itself.
70
+
71
+ ## ⭐ The container contract
72
+ **An accordion is only half a component. The other half is the scroll
73
+ contract of whatever contains it.** A Accordion never owns scrolling and
74
+ never constrains its container; when open accordions can exceed the
75
+ available space, some ancestor **you choose** must be the scroll
76
+ region:
77
+
78
+ ```css
79
+ .my-modal-content { /* ONE scrollable middle… */
80
+ flex: 1 1 auto;
81
+ min-height: 0;
82
+ overflow-y: auto; /* …owns ALL the growth */
83
+ }
84
+ ```
85
+
86
+ Skip this and an open accordion will push its tail past your modal's
87
+ bounds — this exact bug is the reason this package exists. Put the
88
+ accordion INSIDE the region that scrolls, never as a sibling below it.
89
+
90
+ ## Skin
91
+ Structural CSS only — nothing in `styles.css` picks a color. Hooks:
92
+
93
+ | hook | what |
94
+ |---|---|
95
+ | `[data-part="root"]` `[data-state="open"\|"closed"]` | the accordion + its state |
96
+ | `[data-part="summary"]` / `"caret"` / `"body"` / `"heading"` | the parts |
97
+ | `class` / `summaryClass` / `bodyClass` props | merge your own classes |
98
+ | `--tui-disclosure-gap` · `--tui-disclosure-indent` · `--tui-disclosure-summary-pad` | spacing |
99
+ | `--tui-disclosure-caret-closed` / `-open` (content strings) · `--tui-disclosure-caret-size` | the caret |
100
+ | `--tui-disclosure-focus` | the focus ring |
101
+
102
+ ## API
103
+ `<Disclosure summary children defaultOpen? open? onOpenChange? disabled?
104
+ headingLevel? class? summaryClass? bodyClass? id?>` — controlled when
105
+ `open` is passed (the component then only reports intent through
106
+ `onOpenChange`), uncontrolled otherwise.
107
+
108
+ `<Accordion items exclusive? keepMounted? headingLevel? label? onOpenChange?
109
+ …classes>` — the molecule: disclosures from data, `exclusive` for
110
+ one-at-a-time, and Up/Down/Home/End across its own headers. Exported as
111
+ `Squeezebox` too. Everything it does, composition can; it just owns the
112
+ set's state and keyboard.
@@ -0,0 +1,54 @@
1
+ import { type ComponentChildren, type JSX } from 'preact';
2
+ export interface DisclosureProps {
3
+ /** what the closed disclosure shows — text or any inline nodes */
4
+ summary: ComponentChildren;
5
+ children?: ComponentChildren;
6
+ /** uncontrolled initial state (default closed) */
7
+ defaultOpen?: boolean;
8
+ /** controlled state — when provided, the component follows it and only
9
+ * reports intent through onOpenChange */
10
+ open?: boolean;
11
+ onOpenChange?: (open: boolean) => void;
12
+ /** keep the closed body in the DOM so CSS can transition it open and shut.
13
+ * Closed, it is `inert`: out of the focus order and out of the a11y tree —
14
+ * the same promise removal makes, which is what lets it slide without
15
+ * lying to anyone. Default false (the body is removed). */
16
+ keepMounted?: boolean;
17
+ disabled?: boolean;
18
+ /** wrap the summary button in a heading of this level (2–6) so the
19
+ * disclosure can title a section without lying to the document outline */
20
+ headingLevel?: 2 | 3 | 4 | 5 | 6;
21
+ /** skin hooks — tapestry-ui components style through CSS, never props */
22
+ class?: string;
23
+ summaryClass?: string;
24
+ bodyClass?: string;
25
+ id?: string;
26
+ }
27
+ export declare function Disclosure(props: DisclosureProps): JSX.Element;
28
+ export interface AccordionItem {
29
+ id?: string;
30
+ summary: ComponentChildren;
31
+ content: ComponentChildren;
32
+ defaultOpen?: boolean;
33
+ disabled?: boolean;
34
+ }
35
+ export interface AccordionProps {
36
+ items: AccordionItem[];
37
+ /** at most one open at a time — the classic accordion behavior */
38
+ exclusive?: boolean;
39
+ /** see Disclosure.keepMounted — the whole set slides or none of it does */
40
+ keepMounted?: boolean;
41
+ headingLevel?: DisclosureProps['headingLevel'];
42
+ /** accessible name for the set, when it needs one of its own */
43
+ label?: string;
44
+ class?: string;
45
+ summaryClass?: string;
46
+ bodyClass?: string;
47
+ onOpenChange?: (openIds: string[]) => void;
48
+ }
49
+ /** Disclosures from data, with one-open-at-a-time as an option and Up/Down
50
+ * between the headers. Purely a layer over <Disclosure/>: everything it can
51
+ * do, composition can — this just owns the set's state and its keyboard. */
52
+ export declare function Accordion(props: AccordionProps): JSX.Element;
53
+ /** Brandon's name for the molecule, kept because it is the better word. */
54
+ export { Accordion as Squeezebox };
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ // @tapestry-ui/accordion — the Disclosure (atom) and the Accordion, née
3
+ // Squeezebox (molecule). Tapestry UI citizen #1.
4
+ //
5
+ // DESIGN RULINGS (Brandon, 2026-09-25; split on Brad Frost's atomic lines and
6
+ // renamed 2026-10-06): WCAG 2.1 AA from the ground up · nestable · skin via CSS ·
7
+ // as little coupling as we can get away with · the molecule is sugar over the
8
+ // atom and never a different animal.
9
+ //
10
+ // THE NAMES: a DISCLOSURE is one summary button and the region it controls —
11
+ // the atom. An ACCORDION is a set of them with one-open-at-a-time behavior and
12
+ // its own keyboard — the molecule. (A drawer, which this is not, slides in
13
+ // horizontally from an edge.)
14
+ //
15
+ // THE CONTAINER CONTRACT (the lesson this component was born from): a
16
+ // disclosure is only half a component — the other half is the scroll contract
17
+ // of whatever contains it. It NEVER owns scrolling and never grows its
18
+ // container; the CONSUMER decides which ancestor scrolls when open bodies
19
+ // exceed the space. See README "The container contract" — a modal that skips
20
+ // that section will push this body's tail out of its own bounds, exactly once,
21
+ // and then read the section.
22
+ //
23
+ // A11y shape: the WAI-ARIA disclosure pattern. The summary is a real <button>
24
+ // (Enter/Space free, focusable, honest in every AT) carrying aria-expanded +
25
+ // aria-controls; the body is a region the button controls. `headingLevel` wraps
26
+ // the button in <h2>..<h6> so document outlines stay truthful. The accordion
27
+ // adds the APG keyboard: Up/Down between headers, Home/End to the ends.
28
+ import { h } from 'preact';
29
+ import { useId, useRef, useState } from 'preact/hooks';
30
+ export function Disclosure(props) {
31
+ const autoId = useId();
32
+ const id = props.id ?? `tui-disclosure-${autoId}`;
33
+ const bodyId = `${id}-body`;
34
+ const controlled = props.open !== undefined;
35
+ const [ownOpen, setOwnOpen] = useState(props.defaultOpen ?? false);
36
+ const open = controlled ? props.open : ownOpen;
37
+ const toggle = () => {
38
+ if (props.disabled)
39
+ return;
40
+ const next = !open;
41
+ if (!controlled)
42
+ setOwnOpen(next);
43
+ props.onOpenChange?.(next);
44
+ };
45
+ const button = (_jsxs("button", { type: "button", class: cx('tui-disclosure__summary', props.summaryClass), "data-part": "summary", "aria-expanded": open, "aria-controls": bodyId, disabled: props.disabled, onClick: toggle, children: [_jsx("span", { class: "tui-disclosure__caret", "data-part": "caret", "aria-hidden": "true" }), props.summary] }));
46
+ const H = props.headingLevel ? `h${props.headingLevel}` : null;
47
+ return (_jsxs("div", { id: id, class: cx('tui-disclosure', props.class), "data-part": "root", "data-state": open ? 'open' : 'closed', "data-disabled": props.disabled ? '' : undefined, children: [H ? h(H, { class: 'tui-disclosure__heading', 'data-part': 'heading' }, button) : button, (open || props.keepMounted) && (_jsx("div", { id: bodyId, class: cx('tui-disclosure__body', props.bodyClass), "data-part": "body", role: "group", "data-animated": props.keepMounted ? '' : undefined, inert: !open || undefined, children: props.keepMounted
48
+ ? _jsx("div", { class: "tui-disclosure__body-inner", "data-part": "body-inner", children: props.children })
49
+ : props.children }))] }));
50
+ }
51
+ /** Disclosures from data, with one-open-at-a-time as an option and Up/Down
52
+ * between the headers. Purely a layer over <Disclosure/>: everything it can
53
+ * do, composition can — this just owns the set's state and its keyboard. */
54
+ export function Accordion(props) {
55
+ const autoId = useId();
56
+ const ids = props.items.map((it, i) => it.id ?? `tui-accordion-${autoId}-${i}`);
57
+ const setRef = useRef(null);
58
+ const [openIds, setOpenIds] = useState(props.items.flatMap((it, i) => (it.defaultOpen ? [ids[i]] : []))
59
+ .slice(0, props.exclusive ? 1 : undefined));
60
+ const setOpen = (itemId, open) => {
61
+ const next = open
62
+ ? props.exclusive ? [itemId] : [...openIds, itemId]
63
+ : openIds.filter((x) => x !== itemId);
64
+ setOpenIds(next);
65
+ props.onOpenChange?.(next);
66
+ };
67
+ /** only this set's own headers — a disclosure nested in a body is not ours */
68
+ const headers = () => [
69
+ ...(setRef.current?.querySelectorAll(':scope > [data-part="root"] > [data-part="summary"]:not(:disabled),' +
70
+ ':scope > [data-part="root"] > [data-part="heading"] > [data-part="summary"]:not(:disabled)') ?? []),
71
+ ];
72
+ const onKeyDown = (event) => {
73
+ const buttons = headers();
74
+ const from = buttons.indexOf(document.activeElement);
75
+ if (from === -1 || buttons.length === 0)
76
+ return; // focus is inside a body, not on a header
77
+ const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
78
+ if (step !== 0) {
79
+ event.preventDefault();
80
+ buttons[(from + step + buttons.length) % buttons.length].focus();
81
+ return;
82
+ }
83
+ if (event.key === 'Home' || event.key === 'End') {
84
+ event.preventDefault();
85
+ buttons[event.key === 'Home' ? 0 : buttons.length - 1].focus();
86
+ }
87
+ };
88
+ return (_jsx("div", { ref: setRef, class: cx('tui-accordion', props.class), "data-part": "accordion", "aria-label": props.label, onKeyDown: onKeyDown, children: props.items.map((it, i) => (_jsx(Disclosure, { id: ids[i], summary: it.summary, open: openIds.includes(ids[i]), onOpenChange: (o) => setOpen(ids[i], o), disabled: it.disabled, keepMounted: props.keepMounted, headingLevel: props.headingLevel, summaryClass: props.summaryClass, bodyClass: props.bodyClass, children: it.content }, ids[i]))) }));
89
+ }
90
+ /** Brandon's name for the molecule, kept because it is the better word. */
91
+ export { Accordion as Squeezebox };
92
+ function cx(...parts) {
93
+ return parts.filter(Boolean).join(' ');
94
+ }
@@ -0,0 +1,49 @@
1
+ import { type ComponentChildren, type JSX } from 'preact';
2
+ export interface DrawerProps {
3
+ /** what the closed drawer shows — text or any inline nodes */
4
+ summary: ComponentChildren;
5
+ children?: ComponentChildren;
6
+ /** uncontrolled initial state (default closed) */
7
+ defaultOpen?: boolean;
8
+ /** controlled state — when provided, the component follows it and only
9
+ * reports intent through onOpenChange */
10
+ open?: boolean;
11
+ onOpenChange?: (open: boolean) => void;
12
+ /** keep the closed body in the DOM so CSS can transition it open and shut.
13
+ * Closed, it is `inert`: out of the focus order and out of the a11y tree —
14
+ * the same promise removal makes, which is what lets a drawer slide
15
+ * without lying to anyone. Default false (the body is removed). */
16
+ keepMounted?: boolean;
17
+ disabled?: boolean;
18
+ /** wrap the summary button in a heading of this level (2–6) so the
19
+ * drawer can title a section without lying to the document outline */
20
+ headingLevel?: 2 | 3 | 4 | 5 | 6;
21
+ /** skin hooks — tapestry-ui components style through CSS, never props */
22
+ class?: string;
23
+ summaryClass?: string;
24
+ bodyClass?: string;
25
+ id?: string;
26
+ }
27
+ export declare function Drawer(props: DrawerProps): JSX.Element;
28
+ export interface DrawerItem {
29
+ id?: string;
30
+ summary: ComponentChildren;
31
+ content: ComponentChildren;
32
+ defaultOpen?: boolean;
33
+ disabled?: boolean;
34
+ }
35
+ export interface DrawerListProps {
36
+ items: DrawerItem[];
37
+ /** see Drawer.keepMounted — the whole list slides or none of it does */
38
+ keepMounted?: boolean;
39
+ /** at most one open at a time — the accordion behavior */
40
+ exclusive?: boolean;
41
+ headingLevel?: DrawerProps['headingLevel'];
42
+ class?: string;
43
+ summaryClass?: string;
44
+ bodyClass?: string;
45
+ onOpenChange?: (openIds: string[]) => void;
46
+ }
47
+ /** A list of drawers from data — and with `exclusive`, an accordion.
48
+ * Purely a layer over <Drawer/>: everything it can do, composition can. */
49
+ export declare function DrawerList(props: DrawerListProps): JSX.Element;
package/dist/drawer.js ADDED
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ // @tapestry-ui/drawer — the disclosure drawer (Tapestry UI citizen #1).
3
+ //
4
+ // DESIGN RULINGS (Brandon, 2026-09-25): WCAG 2.1 AA from the ground up ·
5
+ // nestable · skin via CSS · as little coupling as we can get away with ·
6
+ // composition-first, with a data-source layer as sugar on top.
7
+ //
8
+ // THE CONTAINER CONTRACT (the lesson this component was born from): a
9
+ // drawer is only half a component — the other half is the scroll contract
10
+ // of whatever contains it. A Drawer NEVER owns scrolling and never grows
11
+ // its container; the CONSUMER decides which ancestor scrolls when open
12
+ // drawers exceed the space. See README "The container contract" — a modal
13
+ // that skips that section will push this drawer's tail out of its own
14
+ // bounds, exactly once, and then read the section.
15
+ //
16
+ // A11y shape: the WAI-ARIA disclosure pattern. The summary is a real
17
+ // <button> (Enter/Space free, focusable, honest in every AT) carrying
18
+ // aria-expanded + aria-controls; the body is a region the button controls.
19
+ // `headingLevel` wraps the button in <h2>..<h6> so document outlines stay
20
+ // truthful when the drawer titles a section.
21
+ import { h, Fragment } from 'preact';
22
+ import { useId, useState } from 'preact/hooks';
23
+ export function Drawer(props) {
24
+ const autoId = useId();
25
+ const id = props.id ?? `tui-drawer-${autoId}`;
26
+ const bodyId = `${id}-body`;
27
+ const controlled = props.open !== undefined;
28
+ const [ownOpen, setOwnOpen] = useState(props.defaultOpen ?? false);
29
+ const open = controlled ? props.open : ownOpen;
30
+ const toggle = () => {
31
+ if (props.disabled)
32
+ return;
33
+ const next = !open;
34
+ if (!controlled)
35
+ setOwnOpen(next);
36
+ props.onOpenChange?.(next);
37
+ };
38
+ const button = (_jsxs("button", { type: "button", class: cx('tui-drawer-summary', props.summaryClass), "data-part": "summary", "aria-expanded": open, "aria-controls": bodyId, disabled: props.disabled, onClick: toggle, children: [_jsx("span", { class: "tui-drawer-caret", "data-part": "caret", "aria-hidden": "true" }), props.summary] }));
39
+ const H = props.headingLevel ? `h${props.headingLevel}` : null;
40
+ return (_jsxs("div", { id: id, class: cx('tui-drawer', props.class), "data-part": "root", "data-state": open ? 'open' : 'closed', "data-disabled": props.disabled ? '' : undefined, children: [H ? h(H, { class: 'tui-drawer-heading', 'data-part': 'heading' }, button) : button, (open || props.keepMounted) && (_jsx("div", { id: bodyId, class: cx('tui-drawer-body', props.bodyClass), "data-part": "body", role: "group", "data-animated": props.keepMounted ? '' : undefined, inert: !open || undefined, children: props.keepMounted
41
+ ? _jsx("div", { class: "tui-drawer-body-inner", "data-part": "body-inner", children: props.children })
42
+ : props.children }))] }));
43
+ }
44
+ /** A list of drawers from data — and with `exclusive`, an accordion.
45
+ * Purely a layer over <Drawer/>: everything it can do, composition can. */
46
+ export function DrawerList(props) {
47
+ const autoId = useId();
48
+ const ids = props.items.map((it, i) => it.id ?? `tui-dl-${autoId}-${i}`);
49
+ const [openIds, setOpenIds] = useState(props.items.flatMap((it, i) => (it.defaultOpen ? [ids[i]] : []))
50
+ .slice(0, props.exclusive ? 1 : undefined));
51
+ const setOpen = (itemId, open) => {
52
+ const next = open
53
+ ? props.exclusive ? [itemId] : [...openIds, itemId]
54
+ : openIds.filter((x) => x !== itemId);
55
+ setOpenIds(next);
56
+ props.onOpenChange?.(next);
57
+ };
58
+ return (_jsx(Fragment, { children: props.items.map((it, i) => (_jsx(Drawer, { id: ids[i], summary: it.summary, open: openIds.includes(ids[i]), onOpenChange: (o) => setOpen(ids[i], o), disabled: it.disabled, keepMounted: props.keepMounted, headingLevel: props.headingLevel, class: props.class, summaryClass: props.summaryClass, bodyClass: props.bodyClass, children: it.content }, ids[i]))) }));
59
+ }
60
+ function cx(...parts) {
61
+ return parts.filter(Boolean).join(' ');
62
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@tapestry-ui/accordion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "A nestable, WCAG 2.1 AA accordion (disclosure) for Preact \u2014 headless behavior, optional slide, skin via CSS. Forged inside a woven-tapestry game; ships with its container contract, not just its markup.",
5
+ "license": "MIT",
6
+ "author": "Brandon Minton",
7
+ "type": "module",
8
+ "sideEffects": [
9
+ "*.css"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/accordion.d.ts",
14
+ "default": "./dist/accordion.js"
15
+ },
16
+ "./styles.css": "./styles.css"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "styles.css",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.build.json",
28
+ "test": "vitest run",
29
+ "prepublishOnly": "npm run test && npm run build"
30
+ },
31
+ "peerDependencies": {
32
+ "preact": ">=10.24.0"
33
+ },
34
+ "devDependencies": {
35
+ "jsdom": "^25.0.1",
36
+ "preact": "^10.29.4",
37
+ "typescript": "^6.0.3",
38
+ "vitest": "^4.1.10"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/ryurage/brandonminton.git",
43
+ "directory": "packages/tapestry-ui/accordion"
44
+ },
45
+ "keywords": [
46
+ "preact",
47
+ "accordion",
48
+ "disclosure",
49
+ "squeezebox",
50
+ "collapse",
51
+ "a11y",
52
+ "wcag",
53
+ "headless",
54
+ "tapestry-ui"
55
+ ]
56
+ }
package/styles.css ADDED
@@ -0,0 +1,54 @@
1
+ /* @tapestry-ui/accordion — STRUCTURAL styles only. The skin is yours:
2
+ override the custom properties, or ignore this file entirely and
3
+ style [data-part] hooks from scratch. Nothing here picks a color. */
4
+ .tui-disclosure__summary {
5
+ display: flex;
6
+ align-items: center;
7
+ gap: var(--tui-disclosure-gap, 0.4em);
8
+ width: 100%;
9
+ text-align: left;
10
+ cursor: pointer;
11
+ background: none;
12
+ border: none;
13
+ padding: var(--tui-disclosure-summary-pad, 0.25em 0);
14
+ font: inherit;
15
+ color: inherit;
16
+ }
17
+ .tui-disclosure__summary:disabled { cursor: default; opacity: 0.5; }
18
+ .tui-disclosure__summary:focus-visible {
19
+ outline: var(--tui-disclosure-focus, 2px solid currentColor);
20
+ outline-offset: 2px;
21
+ }
22
+ .tui-disclosure__caret {
23
+ width: var(--tui-disclosure-caret-size, 0.6em);
24
+ flex: none;
25
+ opacity: 0.7;
26
+ }
27
+ .tui-disclosure__caret::before { content: var(--tui-disclosure-caret-closed, "▸"); }
28
+ [data-state="open"] > * > .tui-disclosure__caret::before,
29
+ [data-state="open"] > .tui-disclosure__summary .tui-disclosure__caret::before {
30
+ content: var(--tui-disclosure-caret-open, "▾");
31
+ }
32
+ .tui-disclosure__body { padding-inline-start: var(--tui-disclosure-indent, 1em); }
33
+ /* keepMounted: 0fr -> 1fr animates to the content's real height, and the closed
34
+ body is hidden as well as inert, so browsers without `inert` still keep it
35
+ out of the focus order and the a11y tree. */
36
+ .tui-disclosure__body[data-animated] {
37
+ display: grid;
38
+ grid-template-rows: 1fr;
39
+ transition: grid-template-rows var(--tui-disclosure-slide, 280ms) ease, visibility 0s;
40
+ }
41
+ .tui-disclosure[data-state="closed"] > .tui-disclosure__body[data-animated] {
42
+ grid-template-rows: 0fr;
43
+ visibility: hidden;
44
+ transition: grid-template-rows var(--tui-disclosure-slide, 280ms) ease,
45
+ visibility 0s linear var(--tui-disclosure-slide, 280ms);
46
+ }
47
+ .tui-disclosure__body[data-animated] > .tui-disclosure__body-inner { overflow: hidden; min-height: 0; }
48
+ @media (prefers-reduced-motion: reduce) {
49
+ .tui-disclosure__body[data-animated] { transition: none; }
50
+ }
51
+ .tui-disclosure__heading { margin: 0; font: inherit; }
52
+
53
+ /* the molecule adds nothing structural: a set of disclosures is a stack of them */
54
+ .tui-accordion { display: block; }