@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 +21 -0
- package/README.md +111 -2
- package/dist/accordion.d.ts +54 -0
- package/dist/accordion.js +94 -0
- package/dist/drawer.d.ts +49 -0
- package/dist/drawer.js +62 -0
- package/package.json +54 -4
- package/styles.css +54 -0
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
|
-
#
|
|
1
|
+
# @tapestry-ui/accordion
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/drawer.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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; }
|