@tapestry-ui/modal 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 +78 -0
- package/dist/modal.d.ts +40 -0
- package/dist/modal.js +72 -0
- package/package.json +52 -0
- package/styles.css +53 -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,78 @@
|
|
|
1
|
+
# @tapestry-ui/modal
|
|
2
|
+
|
|
3
|
+
A WCAG 2.1 AA modal panel for **Preact**, built on the native
|
|
4
|
+
`<dialog>` element. Pinned title, pinned header, **one scrollable
|
|
5
|
+
middle**, pinned footer — [@tapestry-ui/drawer](https://www.npmjs.com/package/@tapestry-ui/drawer)'s
|
|
6
|
+
container contract shipped as a component instead of a README warning.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm i @tapestry-ui/modal
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { Modal } from '@tapestry-ui/modal';
|
|
14
|
+
import '@tapestry-ui/modal/styles.css'; // structural only — optional
|
|
15
|
+
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
|
|
18
|
+
<Modal
|
|
19
|
+
open={open}
|
|
20
|
+
onClose={() => setOpen(false)}
|
|
21
|
+
title="THE BUNDLE"
|
|
22
|
+
header={<WeaponPicker />} /* pinned above the scroll */
|
|
23
|
+
footer="I closes the bundle" /* pinned below it */
|
|
24
|
+
>
|
|
25
|
+
<TokenList /> {/* THE scrollable middle */}
|
|
26
|
+
<Drawer summary="SOUND">…</Drawer>
|
|
27
|
+
</Modal>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## The skeleton is the point
|
|
31
|
+
`title` / `header` / `footer` never scroll out of reach; `children`
|
|
32
|
+
render in the ONE region that grows and scrolls
|
|
33
|
+
(`[data-part="content"]`: `flex: 1 1 auto; min-height: 0;
|
|
34
|
+
overflow-y: auto`). Drop an open drawer or a long list in there and
|
|
35
|
+
nothing escapes the panel's bounds — that exact escape is the shipped
|
|
36
|
+
bug this family of packages was born from.
|
|
37
|
+
|
|
38
|
+
## Modal vs panel
|
|
39
|
+
- **`modal` (default)** — `dialog.showModal()`: top layer, `::backdrop`,
|
|
40
|
+
focus held inside, Escape via the platform's own `cancel` event,
|
|
41
|
+
backdrop click closes (`closeOnBackdrop={false}` to opt out).
|
|
42
|
+
- **`modal={false}`** — `dialog.show()`: a non-blocking **panel**. Still
|
|
43
|
+
a dialog to assistive tech, but the page (or game) behind stays live;
|
|
44
|
+
Escape is handled at the document (`closeOnEscape={false}` to own the
|
|
45
|
+
key yourself). This is the mode a HUD wants.
|
|
46
|
+
|
|
47
|
+
Either way the component **only reports intent** through `onClose` —
|
|
48
|
+
it never closes itself. State stays yours.
|
|
49
|
+
|
|
50
|
+
## Accessibility (by design, not by audit)
|
|
51
|
+
- Native `<dialog>`: implicit role, honest platform semantics.
|
|
52
|
+
- `title` wires `aria-labelledby`; with no title, `label` is the
|
|
53
|
+
accessible name — a dialog here can never be nameless.
|
|
54
|
+
- The × is a real `<button>` whose name (`closeLabel`) you localize;
|
|
55
|
+
`showClose={false}` if you bring your own affordance.
|
|
56
|
+
- Closed means **gone from the DOM**, not visually hidden.
|
|
57
|
+
- `:focus-visible` outlines ship structural and restylable
|
|
58
|
+
(`--tui-modal-focus`), never removable by accident.
|
|
59
|
+
|
|
60
|
+
## Skin
|
|
61
|
+
Structural CSS only — nothing picks a color. Hooks: every part carries
|
|
62
|
+
`data-part` (`root` · `close` · `title` · `header` · `content` ·
|
|
63
|
+
`footer`) and a merge-in class prop (`class`, `titleClass`,
|
|
64
|
+
`headerClass`, `contentClass`, `footerClass`, `closeClass`).
|
|
65
|
+
|
|
66
|
+
| custom property | what |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `--tui-modal-max-w` / `--tui-modal-max-h` | panel bounds |
|
|
69
|
+
| `--tui-modal-pad` | panel padding |
|
|
70
|
+
| `--tui-modal-backdrop` | `::backdrop` (modal mode) |
|
|
71
|
+
| `--tui-modal-close-size` / `--tui-modal-close-inset` | the × |
|
|
72
|
+
| `--tui-modal-focus` | the focus ring |
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
`<Modal open onClose title? label? header? footer? modal?
|
|
76
|
+
closeOnEscape? closeOnBackdrop? closeLabel? showClose? …classes id?>`
|
|
77
|
+
— `open` is required and controlled; `children` are the scrollable
|
|
78
|
+
content region.
|
package/dist/modal.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ComponentChildren, type JSX } from 'preact';
|
|
2
|
+
export interface ModalProps {
|
|
3
|
+
/** controlled visibility — the component follows it and reports intent
|
|
4
|
+
* through onClose (Escape, ×, backdrop); it never closes itself */
|
|
5
|
+
open: boolean;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
/** pinned title line; wires aria-labelledby */
|
|
8
|
+
title?: ComponentChildren;
|
|
9
|
+
/** accessible name when there is no title (required then — a dialog
|
|
10
|
+
* must never be nameless to assistive tech) */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** pinned region between the title and the scrollable content */
|
|
13
|
+
header?: ComponentChildren;
|
|
14
|
+
/** THE scrollable middle — the one region that grows and scrolls */
|
|
15
|
+
children?: ComponentChildren;
|
|
16
|
+
/** pinned footer */
|
|
17
|
+
footer?: ComponentChildren;
|
|
18
|
+
/** true (default) = dialog.showModal(): top layer, backdrop, focus held.
|
|
19
|
+
* false = dialog.show(): a non-blocking panel; the page stays live */
|
|
20
|
+
modal?: boolean;
|
|
21
|
+
/** close on Escape (default true). In modal mode Escape arrives as the
|
|
22
|
+
* platform's own `cancel`; in non-modal mode the component listens */
|
|
23
|
+
closeOnEscape?: boolean;
|
|
24
|
+
/** modal mode only: clicking the backdrop closes (default true) */
|
|
25
|
+
closeOnBackdrop?: boolean;
|
|
26
|
+
/** accessible name for the × button (default "Close" — pass your own
|
|
27
|
+
* locale's word; the default exists so the name can never be empty) */
|
|
28
|
+
closeLabel?: string;
|
|
29
|
+
/** omit the × entirely (the consumer provides its own affordance) */
|
|
30
|
+
showClose?: boolean;
|
|
31
|
+
/** skin hooks — tapestry-ui components style through CSS, never props */
|
|
32
|
+
class?: string;
|
|
33
|
+
titleClass?: string;
|
|
34
|
+
headerClass?: string;
|
|
35
|
+
contentClass?: string;
|
|
36
|
+
footerClass?: string;
|
|
37
|
+
closeClass?: string;
|
|
38
|
+
id?: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function Modal(props: ModalProps): JSX.Element | null;
|
package/dist/modal.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useRef } from 'preact/hooks';
|
|
3
|
+
export function Modal(props) {
|
|
4
|
+
const dialogRef = useRef(null);
|
|
5
|
+
const autoId = useId();
|
|
6
|
+
const id = props.id ?? `tui-modal-${autoId}`;
|
|
7
|
+
const titleId = `${id}-title`;
|
|
8
|
+
const modal = props.modal !== false;
|
|
9
|
+
const closeOnEscape = props.closeOnEscape !== false;
|
|
10
|
+
// follow `open` with the platform's own verbs; a runtime without
|
|
11
|
+
// showModal (old browsers, some test DOMs) falls back to the open
|
|
12
|
+
// attribute, which keeps rendering honest even where the top layer
|
|
13
|
+
// does not exist
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const dialog = dialogRef.current;
|
|
16
|
+
if (!dialog)
|
|
17
|
+
return;
|
|
18
|
+
if (props.open && !dialog.open) {
|
|
19
|
+
const show = modal ? dialog.showModal : dialog.show;
|
|
20
|
+
if (typeof show === 'function') {
|
|
21
|
+
try {
|
|
22
|
+
show.call(dialog);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
dialog.setAttribute('open', '');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
dialog.setAttribute('open', '');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (!props.open && dialog.open) {
|
|
33
|
+
if (typeof dialog.close === 'function')
|
|
34
|
+
dialog.close();
|
|
35
|
+
else
|
|
36
|
+
dialog.removeAttribute('open');
|
|
37
|
+
}
|
|
38
|
+
}, [props.open, modal]);
|
|
39
|
+
// non-modal Escape: the platform only fires `cancel` for showModal(),
|
|
40
|
+
// so a panel listens for itself — while open, at the document, so the
|
|
41
|
+
// key works wherever focus sits (a game canvas, the page behind)
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!props.open || modal || !closeOnEscape)
|
|
44
|
+
return;
|
|
45
|
+
const onKey = (e) => {
|
|
46
|
+
if (e.key === 'Escape')
|
|
47
|
+
props.onClose();
|
|
48
|
+
};
|
|
49
|
+
document.addEventListener('keydown', onKey);
|
|
50
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
51
|
+
}, [props.open, modal, closeOnEscape, props.onClose]);
|
|
52
|
+
// modal Escape arrives as `cancel`: report intent, never self-close
|
|
53
|
+
const onCancel = (e) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
if (closeOnEscape)
|
|
56
|
+
props.onClose();
|
|
57
|
+
};
|
|
58
|
+
// a click on the <dialog> itself (not its parts) is the backdrop —
|
|
59
|
+
// every real part of the panel is inside [data-part] children
|
|
60
|
+
const onBackdropClick = (e) => {
|
|
61
|
+
if (!modal || props.closeOnBackdrop === false)
|
|
62
|
+
return;
|
|
63
|
+
if (e.target === dialogRef.current)
|
|
64
|
+
props.onClose();
|
|
65
|
+
};
|
|
66
|
+
if (!props.open)
|
|
67
|
+
return null;
|
|
68
|
+
return (_jsxs("dialog", { ref: dialogRef, id: id, class: cx('tui-modal', props.class), "data-part": "root", "data-modal": modal ? '' : undefined, "aria-labelledby": props.title != null ? titleId : undefined, "aria-label": props.title == null ? props.label : undefined, onCancel: onCancel, onClick: onBackdropClick, children: [props.showClose !== false && (_jsx("button", { type: "button", class: cx('tui-modal-close', props.closeClass), "data-part": "close", "aria-label": props.closeLabel ?? 'Close', onClick: () => props.onClose(), children: "\u00D7" })), props.title != null && (_jsx("div", { id: titleId, class: cx('tui-modal-title', props.titleClass), "data-part": "title", children: props.title })), props.header != null && (_jsx("div", { class: cx('tui-modal-header', props.headerClass), "data-part": "header", children: props.header })), _jsx("div", { class: cx('tui-modal-content', props.contentClass), "data-part": "content", children: props.children }), props.footer != null && (_jsx("footer", { class: cx('tui-modal-footer', props.footerClass), "data-part": "footer", children: props.footer }))] }));
|
|
69
|
+
}
|
|
70
|
+
function cx(...parts) {
|
|
71
|
+
return parts.filter(Boolean).join(' ');
|
|
72
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tapestry-ui/modal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A WCAG 2.1 AA modal panel for Preact on the native <dialog> element — pinned header, ONE scrollable middle, pinned footer. The drawer's container contract, shipped as a component instead of a README warning.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Brandon Minton",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"*.css"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/modal.d.ts",
|
|
14
|
+
"default": "./dist/modal.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/modal"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"preact",
|
|
44
|
+
"modal",
|
|
45
|
+
"dialog",
|
|
46
|
+
"panel",
|
|
47
|
+
"a11y",
|
|
48
|
+
"wcag",
|
|
49
|
+
"headless",
|
|
50
|
+
"tapestry-ui"
|
|
51
|
+
]
|
|
52
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* @tapestry-ui/modal — 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
|
+
(the dialog's UA background is the browser's, not ours).
|
|
5
|
+
|
|
6
|
+
THE SKELETON IS THE POINT: title, header and footer are pinned;
|
|
7
|
+
.tui-modal-content is the ONE region that grows and scrolls. That
|
|
8
|
+
flex/min-height/overflow trio is the container contract the drawer's
|
|
9
|
+
README warns about — here it is load-bearing, not advice. */
|
|
10
|
+
.tui-modal {
|
|
11
|
+
border: none;
|
|
12
|
+
padding: var(--tui-modal-pad, 1em 1.5em);
|
|
13
|
+
max-width: var(--tui-modal-max-w, min(36em, 90vw));
|
|
14
|
+
max-height: var(--tui-modal-max-h, min(80vh, 40em));
|
|
15
|
+
}
|
|
16
|
+
.tui-modal[open] {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
}
|
|
20
|
+
.tui-modal::backdrop {
|
|
21
|
+
background: var(--tui-modal-backdrop, rgba(0, 0, 0, 0.4));
|
|
22
|
+
}
|
|
23
|
+
.tui-modal-content {
|
|
24
|
+
flex: 1 1 auto; /* the ONE scrollable middle… */
|
|
25
|
+
min-height: 0; /* …allowed to actually shrink… */
|
|
26
|
+
overflow-y: auto; /* …owns ALL the growth. THE CONTRACT. */
|
|
27
|
+
overscroll-behavior: contain;
|
|
28
|
+
}
|
|
29
|
+
.tui-modal-close {
|
|
30
|
+
position: absolute;
|
|
31
|
+
top: var(--tui-modal-close-inset, 0.4em);
|
|
32
|
+
right: var(--tui-modal-close-inset, 0.4em);
|
|
33
|
+
width: var(--tui-modal-close-size, 1.6em);
|
|
34
|
+
height: var(--tui-modal-close-size, 1.6em);
|
|
35
|
+
display: grid;
|
|
36
|
+
place-items: center;
|
|
37
|
+
background: none;
|
|
38
|
+
border: none;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
font: inherit;
|
|
41
|
+
color: inherit;
|
|
42
|
+
opacity: 0.7;
|
|
43
|
+
line-height: 1;
|
|
44
|
+
}
|
|
45
|
+
.tui-modal-close:hover { opacity: 1; }
|
|
46
|
+
.tui-modal-close:focus-visible,
|
|
47
|
+
.tui-modal :focus-visible {
|
|
48
|
+
outline: var(--tui-modal-focus, 2px solid currentColor);
|
|
49
|
+
outline-offset: 2px;
|
|
50
|
+
}
|
|
51
|
+
.tui-modal-footer { flex: none; }
|
|
52
|
+
.tui-modal-title,
|
|
53
|
+
.tui-modal-header { flex: none; }
|