@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 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.
@@ -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; }