@tapestry-ui/modal
v0.2.0
Published
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.
Maintainers
Readme
@tapestry-ui/modal
A WCAG 2.1 AA modal panel for Preact, built on the native
<dialog> element. Pinned title, pinned header, one scrollable
middle, pinned footer — @tapestry-ui/accordion's
container contract shipped as a component instead of a README warning.
npm i @tapestry-ui/modalimport { Modal } from '@tapestry-ui/modal';
import '@tapestry-ui/modal/styles.css'; // structural only — optional
const [open, setOpen] = useState(false);
<Modal
open={open}
onClose={() => setOpen(false)}
title="THE BUNDLE"
header={<WeaponPicker />} /* pinned above the scroll */
footer="I closes the bundle" /* pinned below it */
>
<TokenList /> {/* THE scrollable middle */}
<Disclosure summary="SOUND">…</Disclosure>
</Modal>The skeleton is the point
title / header / footer never scroll out of reach; children
render in the ONE region that grows and scrolls
([data-part="content"]: flex: 1 1 auto; min-height: 0;
overflow-y: auto). Drop an open drawer or a long list in there and
nothing escapes the panel's bounds — that exact escape is the shipped
bug this family of packages was born from.
Modal vs panel
modal(default) —dialog.showModal(): top layer,::backdrop, focus held inside, Escape via the platform's owncancelevent, backdrop click closes (closeOnBackdrop={false}to opt out).modal={false}—dialog.show(): a non-blocking panel. Still a dialog to assistive tech, but the page (or game) behind stays live; Escape is handled at the document (closeOnEscape={false}to own the key yourself). This is the mode a HUD wants.
Either way the component only reports intent through onClose —
it never closes itself. State stays yours.
Accessibility (by design, not by audit)
- Native
<dialog>: implicit role, honest platform semantics. titlewiresaria-labelledby; with no title,labelis the accessible name — a dialog here can never be nameless.- The × is a real
<button>whose name (closeLabel) you localize;showClose={false}if you bring your own affordance. - Closed means gone from the DOM, not visually hidden.
:focus-visibleoutlines ship structural and restylable (--tui-modal-focus), never removable by accident.
Skin
Structural CSS only — nothing picks a color. Hooks: every part carries
data-part (root · close · title · header · content ·
footer) and a merge-in class prop (class, titleClass,
headerClass, contentClass, footerClass, closeClass).
| custom property | what |
|---|---|
| --tui-modal-max-w / --tui-modal-max-h | panel bounds |
| --tui-modal-pad | panel padding |
| --tui-modal-backdrop | ::backdrop (modal mode) |
| --tui-modal-close-size / --tui-modal-close-inset | the × |
| --tui-modal-focus | the focus ring |
API
<Modal open onClose title? label? header? footer? modal?
closeOnEscape? closeOnBackdrop? closeLabel? showClose? …classes id?>
— open is required and controlled; children are the scrollable
content region.
