@tapestry-ui/drawer 0.1.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 +85 -0
- package/dist/drawer.d.ts +42 -0
- package/dist/drawer.js +60 -0
- package/package.json +52 -0
- package/styles.css +33 -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
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @tapestry-ui/drawer
|
|
2
|
+
|
|
3
|
+
A nestable, accessible disclosure drawer for **Preact**. Headless
|
|
4
|
+
behavior, skin via CSS. Forged inside a woven-tapestry video game,
|
|
5
|
+
where it learned its most important lesson the hard way (see **The
|
|
6
|
+
container contract**).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm i @tapestry-ui/drawer
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Drawer, DrawerList } from '@tapestry-ui/drawer';
|
|
14
|
+
import '@tapestry-ui/drawer/styles.css'; // structural only — optional
|
|
15
|
+
|
|
16
|
+
<Drawer summary="SOUND">
|
|
17
|
+
<VolumeSliders />
|
|
18
|
+
</Drawer>
|
|
19
|
+
|
|
20
|
+
// nesting is just children
|
|
21
|
+
<Drawer summary="settings" defaultOpen>
|
|
22
|
+
<Drawer summary="advanced">…</Drawer>
|
|
23
|
+
</Drawer>
|
|
24
|
+
|
|
25
|
+
// the data-source layer; `exclusive` makes it an accordion
|
|
26
|
+
<DrawerList
|
|
27
|
+
exclusive
|
|
28
|
+
items={[
|
|
29
|
+
{ summary: 'one', content: <A /> },
|
|
30
|
+
{ summary: 'two', content: <B /> },
|
|
31
|
+
]}
|
|
32
|
+
/>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Accessibility (WCAG 2.1 AA by design, not by audit)
|
|
36
|
+
- The summary is a **real `<button>`** — Enter/Space, focus, and every
|
|
37
|
+
assistive tech behavior come free and honest.
|
|
38
|
+
- `aria-expanded` + `aria-controls` wire the WAI-ARIA disclosure
|
|
39
|
+
pattern; ids are auto-generated and collision-free under nesting.
|
|
40
|
+
- `headingLevel={2..6}` wraps the button in a real heading, so a drawer
|
|
41
|
+
that titles a section never lies to the document outline.
|
|
42
|
+
- The closed body is **removed from the DOM**, not visually hidden —
|
|
43
|
+
screen readers never wade through collapsed forests.
|
|
44
|
+
- `:focus-visible` outline ships in the structural CSS and is yours to
|
|
45
|
+
restyle, never to remove.
|
|
46
|
+
|
|
47
|
+
## ⭐ The container contract
|
|
48
|
+
**A drawer is only half a component. The other half is the scroll
|
|
49
|
+
contract of whatever contains it.** A Drawer never owns scrolling and
|
|
50
|
+
never constrains its container; when open drawers can exceed the
|
|
51
|
+
available space, some ancestor **you choose** must be the scroll
|
|
52
|
+
region:
|
|
53
|
+
|
|
54
|
+
```css
|
|
55
|
+
.my-modal-content { /* ONE scrollable middle… */
|
|
56
|
+
flex: 1 1 auto;
|
|
57
|
+
min-height: 0;
|
|
58
|
+
overflow-y: auto; /* …owns ALL the growth */
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Skip this and an open drawer will push its tail past your modal's
|
|
63
|
+
bounds — this exact bug is the reason this package exists. Put the
|
|
64
|
+
drawer INSIDE the region that scrolls, never as a sibling below it.
|
|
65
|
+
|
|
66
|
+
## Skin
|
|
67
|
+
Structural CSS only — nothing in `styles.css` picks a color. Hooks:
|
|
68
|
+
|
|
69
|
+
| hook | what |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `[data-part="root"]` `[data-state="open"\|"closed"]` | the drawer + its state |
|
|
72
|
+
| `[data-part="summary"]` / `"caret"` / `"body"` / `"heading"` | the parts |
|
|
73
|
+
| `class` / `summaryClass` / `bodyClass` props | merge your own classes |
|
|
74
|
+
| `--tui-drawer-gap` · `--tui-drawer-indent` · `--tui-drawer-summary-pad` | spacing |
|
|
75
|
+
| `--tui-drawer-caret-closed` / `-open` (content strings) · `--tui-drawer-caret-size` | the caret |
|
|
76
|
+
| `--tui-drawer-focus` | the focus ring |
|
|
77
|
+
|
|
78
|
+
## API
|
|
79
|
+
`<Drawer summary children defaultOpen? open? onOpenChange? disabled?
|
|
80
|
+
headingLevel? class? summaryClass? bodyClass? id?>` — controlled when
|
|
81
|
+
`open` is passed (the component then only reports intent through
|
|
82
|
+
`onOpenChange`), uncontrolled otherwise.
|
|
83
|
+
|
|
84
|
+
`<DrawerList items exclusive? headingLevel? onOpenChange? …classes>` —
|
|
85
|
+
sugar over composition; `exclusive` = accordion.
|
package/dist/drawer.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
disabled?: boolean;
|
|
13
|
+
/** wrap the summary button in a heading of this level (2–6) so the
|
|
14
|
+
* drawer can title a section without lying to the document outline */
|
|
15
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
16
|
+
/** skin hooks — tapestry-ui components style through CSS, never props */
|
|
17
|
+
class?: string;
|
|
18
|
+
summaryClass?: string;
|
|
19
|
+
bodyClass?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function Drawer(props: DrawerProps): JSX.Element;
|
|
23
|
+
export interface DrawerItem {
|
|
24
|
+
id?: string;
|
|
25
|
+
summary: ComponentChildren;
|
|
26
|
+
content: ComponentChildren;
|
|
27
|
+
defaultOpen?: boolean;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface DrawerListProps {
|
|
31
|
+
items: DrawerItem[];
|
|
32
|
+
/** at most one open at a time — the accordion behavior */
|
|
33
|
+
exclusive?: boolean;
|
|
34
|
+
headingLevel?: DrawerProps['headingLevel'];
|
|
35
|
+
class?: string;
|
|
36
|
+
summaryClass?: string;
|
|
37
|
+
bodyClass?: string;
|
|
38
|
+
onOpenChange?: (openIds: string[]) => void;
|
|
39
|
+
}
|
|
40
|
+
/** A list of drawers from data — and with `exclusive`, an accordion.
|
|
41
|
+
* Purely a layer over <Drawer/>: everything it can do, composition can. */
|
|
42
|
+
export declare function DrawerList(props: DrawerListProps): JSX.Element;
|
package/dist/drawer.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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 && (_jsx("div", { id: bodyId, class: cx('tui-drawer-body', props.bodyClass), "data-part": "body", role: "group", children: props.children }))] }));
|
|
41
|
+
}
|
|
42
|
+
/** A list of drawers from data — and with `exclusive`, an accordion.
|
|
43
|
+
* Purely a layer over <Drawer/>: everything it can do, composition can. */
|
|
44
|
+
export function DrawerList(props) {
|
|
45
|
+
const autoId = useId();
|
|
46
|
+
const ids = props.items.map((it, i) => it.id ?? `tui-dl-${autoId}-${i}`);
|
|
47
|
+
const [openIds, setOpenIds] = useState(props.items.flatMap((it, i) => (it.defaultOpen ? [ids[i]] : []))
|
|
48
|
+
.slice(0, props.exclusive ? 1 : undefined));
|
|
49
|
+
const setOpen = (itemId, open) => {
|
|
50
|
+
const next = open
|
|
51
|
+
? props.exclusive ? [itemId] : [...openIds, itemId]
|
|
52
|
+
: openIds.filter((x) => x !== itemId);
|
|
53
|
+
setOpenIds(next);
|
|
54
|
+
props.onOpenChange?.(next);
|
|
55
|
+
};
|
|
56
|
+
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, headingLevel: props.headingLevel, class: props.class, summaryClass: props.summaryClass, bodyClass: props.bodyClass, children: it.content }, ids[i]))) }));
|
|
57
|
+
}
|
|
58
|
+
function cx(...parts) {
|
|
59
|
+
return parts.filter(Boolean).join(' ');
|
|
60
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tapestry-ui/drawer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A nestable, WCAG 2.1 AA disclosure drawer for Preact \u2014 headless behavior, 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/drawer.d.ts",
|
|
14
|
+
"default": "./dist/drawer.js"
|
|
15
|
+
},
|
|
16
|
+
"./styles.css": "./styles.css"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"styles.css",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"preact": ">=10.24.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"jsdom": "^25.0.1",
|
|
33
|
+
"preact": "^10.29.4",
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vitest": "^4.1.10"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/ryurage/brandonminton.git",
|
|
40
|
+
"directory": "packages/tapestry-ui/drawer"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"preact",
|
|
44
|
+
"disclosure",
|
|
45
|
+
"accordion",
|
|
46
|
+
"drawer",
|
|
47
|
+
"a11y",
|
|
48
|
+
"wcag",
|
|
49
|
+
"headless",
|
|
50
|
+
"tapestry-ui"
|
|
51
|
+
]
|
|
52
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* @tapestry-ui/drawer — 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-drawer-summary {
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: var(--tui-drawer-gap, 0.4em);
|
|
8
|
+
width: 100%;
|
|
9
|
+
text-align: left;
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
background: none;
|
|
12
|
+
border: none;
|
|
13
|
+
padding: var(--tui-drawer-summary-pad, 0.25em 0);
|
|
14
|
+
font: inherit;
|
|
15
|
+
color: inherit;
|
|
16
|
+
}
|
|
17
|
+
.tui-drawer-summary:disabled { cursor: default; opacity: 0.5; }
|
|
18
|
+
.tui-drawer-summary:focus-visible {
|
|
19
|
+
outline: var(--tui-drawer-focus, 2px solid currentColor);
|
|
20
|
+
outline-offset: 2px;
|
|
21
|
+
}
|
|
22
|
+
.tui-drawer-caret {
|
|
23
|
+
width: var(--tui-drawer-caret-size, 0.6em);
|
|
24
|
+
flex: none;
|
|
25
|
+
opacity: 0.7;
|
|
26
|
+
}
|
|
27
|
+
.tui-drawer-caret::before { content: var(--tui-drawer-caret-closed, "▸"); }
|
|
28
|
+
[data-state="open"] > * > .tui-drawer-caret::before,
|
|
29
|
+
[data-state="open"] > .tui-drawer-summary .tui-drawer-caret::before {
|
|
30
|
+
content: var(--tui-drawer-caret-open, "▾");
|
|
31
|
+
}
|
|
32
|
+
.tui-drawer-body { padding-inline-start: var(--tui-drawer-indent, 1em); }
|
|
33
|
+
.tui-drawer-heading { margin: 0; font: inherit; }
|