/** * Shared modal Dialog primitive, adapted from Radix UI's Dialog contract. * * Why this module exists * ---------------------- * Blok grew three hand-rolled modal implementations (the file preview modal, * the image crop editor, and the notifier confirm/prompt dialogs). Each one * missed a different accessibility piece: * * - preview-modal declared `aria-modal` but never trapped focus or `inert`-ed * the background, so Tab/AT could reach the page behind it; * - crop-modal restored focus without checking `isConnected`, throwing when * the previously-focused node had been detached; * - the notifier's private `makeModal` trapped Tab but left the background * fully clickable, sat in the `z-[9999]` toast wrapper *below* the CSS Top * Layer, and its focusable-element query missed `a[href]`, `select`, and * `textarea`. * * `openModalDialog` centralizes the full contract on top of the Wave 1 * primitives (`promoteToTopLayer` + `registerLayer`): * * 1. mounts and promotes the dialog into the CSS Top Layer; * 2. applies `inert` to the sibling body subtrees while open (the same trick * that `src/tools/video/controls.ts` uses on its parked menu pane); * 3. traps Tab focus with a complete tabbable selector and pulls focus back * via a capture-phase `focusin` guard; * 4. captures `document.activeElement` on open — falling back to the control * the opening press landed on, since WebKit leaves `` focused after * a click on a button — and restores it on close when still connected; * 5. registers Escape / outside-pointer dismissal through the shared * dismissable-layer stack instead of a bespoke listener; * 6. runs one shared exit-animation settle before teardown. */ import { registerLayer } from './dismissable-layer'; import { promoteToTopLayer, removeFromTopLayer } from './top-layer'; /** * The ARIA role for the dialog surface. `alertdialog` is used for * confirm/prompt style interruptions; `dialog` for everything else. */ export type DialogRole = 'dialog' | 'alertdialog'; /** * Why the dialog is being dismissed. Escape and outside-pointer can map to * different actions (the alt-text popover commits on click-away but cancels on * Escape), so the reason is forwarded to the caller. */ export type ModalDismissReason = 'escape' | 'outside'; /** * Complete tabbable selector. Covers every natively focusable element type * plus explicit `tabindex` opt-ins, and excludes `tabindex="-1"`. */ const TABBABLE_SELECTOR = [ 'a[href]', 'button', 'input', 'select', 'textarea', '[tabindex]:not([tabindex="-1"])', ].join(', '); /** Time budget for the close animation before forcing teardown (ms). */ const CLOSE_ANIMATION_FALLBACK_MS = 260; /** * Element the user last pressed on, used to recover the dialog's opener when * `document.activeElement` cannot name it: WebKit does not focus a `