diff --git a/.changeset/dialog-backdrop-containment.md b/.changeset/dialog-backdrop-containment.md new file mode 100644 index 0000000..f2f57d2 --- /dev/null +++ b/.changeset/dialog-backdrop-containment.md @@ -0,0 +1,14 @@ +--- +'@dunky.dev/react-dialog': patch +--- + +A modal dialog no longer marks its own backdrop `aria-hidden` + `inert`. The +assistive-tech containment walks up from the dialog window and hides every +sibling along the way — and the backdrop is portalled alongside the viewport, +outside the window's subtree yet part of the same layer, so the topmost +dialog was hiding its own backdrop. `inert` blocks pointer hit-testing, so +pressing the backdrop to dismiss silently did nothing in a real browser +(test-runner `.click()` bypasses hit-testing, which is why suites never +caught it). A dialog's layer now excepts its own backdrop from the +containment; everything beneath the topmost layer — lower dialogs' backdrops +included — stays hidden and inert as before. diff --git a/.changeset/dialog-close-on-back.md b/.changeset/dialog-close-on-back.md new file mode 100644 index 0000000..b38ed08 --- /dev/null +++ b/.changeset/dialog-close-on-back.md @@ -0,0 +1,32 @@ +--- +'@dunky.dev/dialog': minor +'@dunky.dev/react-dialog': minor +'@dunky.dev/dom-navigation': minor +--- + +Add `closeOnBack` — the host's Back navigation closes the open dialog instead +of leaving the page, the pattern mobile users expect from a full-screen +overlay. Off by default. + +```tsx + /* preventDefault() vetoes */ {}}> + … + +``` + +It follows the shared dismissal contract: `onBackNavigation` fires first and +`preventDefault()` vetoes, a controlled dialog only records the intent (close +it from your own state as usual), a nested stack unwinds one layer per Back +press, and it composes with `animated` (Back plays the exit animation). The +decision — gate, veto, controlled — lives once in the core's `backNavigate`; +substrates only wire their host's mechanics to it. + +The web mechanics ship as their own framework-free util, +`@dunky.dev/dom-navigation` (`interceptBackNavigation`) — a session +history guard any overlaid layer can use, not just the dialog: opening +plants a guard entry in the session history and Back consumes it. A dialog +closed any other way consumes its own entry too, so no leftover ever swallows +a later Back press — including across reopen races (React StrictMode's +double-invoked effects adopt the entry in place rather than queueing a +history traversal, which browsers don't reliably deliver once another entry +is pushed). diff --git a/.changeset/dialog-exit-animation.md b/.changeset/dialog-exit-animation.md new file mode 100644 index 0000000..f93770f --- /dev/null +++ b/.changeset/dialog-exit-animation.md @@ -0,0 +1,32 @@ +--- +'@dunky.dev/dialog': minor +'@dunky.dev/react-dialog': minor +'@dunky.dev/dom-dialog': minor +--- + +Add exit-animation support via a new `animated` option. An animated dialog +closes through a `closing` state — every part carries it as +`data-state="closing"`, the styling hook for the exit — and unmounts when its +transition or animation on Content ends (with a fallback ceiling, and skipped +entirely under `prefers-reduced-motion`). + +```tsx + +``` + +```css +[data-state='closing'] { + opacity: 0; + transition: opacity 150ms; +} +``` + +The exit window lives in the core machine, not in per-substrate unmount +deferral, so reopening mid-exit is a named transition instead of a timing +race, and every substrate inherits identical behavior. The exit is cosmetic +by design: the close is reported, focus returns, and the page becomes +interactive the moment closing starts — the still-painting layer is made +`inert` until it leaves. Enter animations need no option: parts mount +straight into `data-state="open"`, so CSS animations (or transitions via +`@starting-style`) play from mount. Default (`animated: false`) behavior is +unchanged. diff --git a/.changeset/dom-dialog-package.md b/.changeset/dom-dialog-package.md new file mode 100644 index 0000000..6657df0 --- /dev/null +++ b/.changeset/dom-dialog-package.md @@ -0,0 +1,28 @@ +--- +'@dunky.dev/dom-dialog': minor +'@dunky.dev/react-dialog': patch +--- + +Add `@dunky.dev/dom-dialog` — the dialog's framework-free DOM behavior, +extracted from the React binding: the shared layer stack (`registerDialog` / +`isTopmostDialog`) with its assistive-tech containment, and `getInitialFocus`. +These were React-free already but lived inside `@dunky.dev/react-dialog`, so +any other substrate would have had to copy them — forking behavior that must +stay identical everywhere. One module is also one stack: dialogs from +different substrates on the same page now stack, hide, and unwind correctly +against each other. `@dunky.dev/react-dialog` consumes it; its public API and +behavior are unchanged. + +```ts +import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' + +// On open: join the stack, then move focus in. +const unregister = registerDialog({ + id, + depth, // nesting level, 1 = top-level + element: content, // the dialog window + modal: true, + backdrop: () => backdropElement, // the layer's own backdrop stays pressable +}) +getInitialFocus(content).focus({ preventScroll: true }) +``` diff --git a/packages/core/dialog/SPEC.md b/packages/core/dialog/SPEC.md index 97afa7e..18fe891 100644 --- a/packages/core/dialog/SPEC.md +++ b/packages/core/dialog/SPEC.md @@ -59,8 +59,12 @@ Using the dialog is a walkthrough of intent, not a prop list: requires an accessible name); when it genuinely can't, an accessible label goes on the content instead. - **Close** dismisses from inside — the visible close affordance the APG - strongly recommends alongside Escape. In a nested stack it can be scoped: - its own dialog by default, or the whole stack (see [Nesting](#nesting)). + strongly recommends alongside Escape. It is singular by design: the one + dismissal affordance (typically a corner `×`), kept the focus cycle's last + stop. Action buttons that happen to dismiss (Cancel, Confirm) are the + consumer's own controls, keeping their natural Tab order. In a nested stack + Close can be scoped: its own dialog by default, or the whole stack (see + [Nesting](#nesting)). Dismissal is configurable at the root: Escape closing and outside-press closing can each be toggled off, and the consumer can veto a single occurrence of @@ -70,16 +74,35 @@ Opting into the alert-dialog role changes the defaults for urgent, destructive interruptions — modality is inherent and outside presses don't dismiss by default, so the user must choose an action. +The host's Back navigation can be a dismissal too (`closeOnBack`, off by +default): while the dialog is open, Back closes it instead of leaving the +page — the pattern mobile users expect from a full-screen overlay. It follows +the shared dismissal contract: `onBackNavigation` fires first and +`preventDefault()` vetoes, a controlled dialog only records the intent, and a +nested stack unwinds one layer per press. The substrate wires the host +mechanics (the web plants a guard entry in the session history; a native host +wires its hardware back handler); a dialog closed any other way leaves no +trace behind — its guard entry is consumed, not left to swallow the next +Back press. + Dialogs can be nested — a dialog opened from within another stacks on top of it, and the stack unwinds one layer at a time. The full contract is [Nesting](#nesting) under Accessibility. ## States -| State | Behavior | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `closed` | Nothing is shown beyond the trigger. Open intents (trigger press, imperative open) move to `open`. | -| `open` | Backdrop and content are shown. Close intents are never gated; Escape and outside-press pass only if their settings allow it. Whether an allowed intent actually moves the dialog follows the controlled contract above. | +| State | Behavior | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `closed` | Nothing is shown beyond the trigger. Open intents (trigger press, imperative open) move to `open`. | +| `open` | Backdrop and content are shown. Close intents are never gated; Escape and outside-press pass only if their settings allow it. Whether an allowed intent actually moves the dialog follows the controlled contract above. | +| `closing` | The exit window, entered from `open` only when `animated` — the visual is still leaving the screen, but the dialog is already logically closed: the change is reported, focus and interaction have moved on, and dismissal intents no longer apply. An open intent interrupts the exit and returns to `open`; the substrate's `exit.complete` — the report that the visual finished — closes it. | + +An `animated` dialog (off by default) closes through `closing` so a departure +animation has time to play; every part carries the state as `data-state` +(`open` / `closing` / `closed`), which is the styling hook for both +directions. Entry needs no dedicated state: the parts mount straight into +`open`, so mount itself is the enter edge (CSS animations, or transitions via +`@starting-style`, fire from there). ### Title/Description presence @@ -183,11 +206,14 @@ choice, not the behavior it produces (that's spec'd above). The dialog ships headless: parts carry behavior and ARIA wiring plus a `data-state` attribute (`open` / `closed`) for styling and animation; visuals belong to the consumer. -| Position | Why | -| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `open` delegates to `@dunky.dev/controllable`; `onOpenChange` reacts to the state, not to intents | One shared mechanic across primitives, and the callback structurally can't drift from the controlled contract. | -| Dismissal intents are distinct events (`escape`, `interact.outside`) | Their gating lives in core guards — no substrate re-implements the settings. | -| One base id, per-part ids derived from it | The cross-part ARIA references (controls / labelledby / describedby) can never disagree. | -| Part presence lives in machine context (`part.presence` events) | The rendered-parts rule holds in every substrate with no substrate bookkeeping. | -| This contract owns modality, dismissal, and focus | A substrate must not hand authority to host built-ins (e.g. `showModal()`) — behavior can't fork per host. | -| The `intent` slot records every declared intent, drives no callback | Reserved as the request channel a stack-scoped close needs to traverse controlled layers. | +| Position | Why | +| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `open` delegates to `@dunky.dev/controllable`; `onOpenChange` reacts to the state, not to intents | One shared mechanic across primitives, and the callback structurally can't drift from the controlled contract. | +| Dismissal intents are distinct events (`escape`, `interact.outside`, `history.back`) | Their gating lives in core guards — no substrate re-implements the settings. | +| Back navigation reports through one `backNavigate` on the api | The callback, veto, and controlled fork live once in the connect; only the host's back mechanics differ per substrate. | +| One base id, per-part ids derived from it | The cross-part ARIA references (controls / labelledby / describedby) can never disagree. | +| Part presence lives in machine context (`part.presence` events) | The rendered-parts rule holds in every substrate with no substrate bookkeeping. | +| This contract owns modality, dismissal, and focus | A substrate must not hand authority to host built-ins (e.g. `showModal()`) — behavior can't fork per host. | +| The exit window is a machine state; `exit.complete` comes from the substrate | Reopen-during-exit is a named transition, not a substrate-side unmount race; only the host knows when paint finished. | +| A `closing` dialog has already left the stack — focus, Escape, containment move on immediately | The exit is purely cosmetic; the layer beneath must not wait on an animation to become interactive again. | +| The `intent` slot records every declared intent, drives no callback | Reserved as the request channel a stack-scoped close needs to traverse controlled layers. | diff --git a/packages/core/dialog/src/connect.ts b/packages/core/dialog/src/connect.ts index 78d855e..1adbfc2 100644 --- a/packages/core/dialog/src/connect.ts +++ b/packages/core/dialog/src/connect.ts @@ -1,6 +1,7 @@ import { makeReaction, type Connect } from '@dunky.dev/state-machine' import type { AttrBindings, EventBindings, PointerPayload } from '@dunky.dev/state-machine-bindings' import type { + BackNavigationPayload, DialogContext, DialogIds, DialogMachineEvent, @@ -29,9 +30,19 @@ function dialogIds(id: string): DialogIds { /** The view-facing surface a driver reads from the running dialog machine. */ export interface DialogApi { open: boolean + /** Whether the dialog occupies the tree: open or mid-exit (`closing`). The + * substrate's portal/unmount gate — an animated dialog stays mounted while + * its exit plays, everything else already keyed off `open`. */ + mounted: boolean role: DialogRole ids: DialogIds setOpen: (open: boolean) => void + /** Reports the host's Back navigation. The whole decision lives here, once: + * `onBackNavigation` fires first (`preventDefault()` vetoes), the machine + * gates on `closeOnBack`, and the controlled contract applies — a substrate + * only wires its host mechanics (a session-history guard entry on the web, a + * hardware back handler on native) to this call. */ + backNavigate: () => void parts: { trigger: DialogPartBindings backdrop: DialogPartBindings @@ -51,7 +62,9 @@ export const dialogConnect: Connect< DialogApi > = ({ state, context, props, send }) => { const open = state === 'open' - const dataState: DialogStateName = open ? 'open' : 'closed' + // The state names double as the styling vocabulary — `closing` is the + // exit-animation hook. + const dataState: DialogStateName = state const ids = dialogIds(context.id) // An outside press is an intent, not a close command: the consumer may veto @@ -63,12 +76,25 @@ export const dialogConnect: Connect< return { open, + mounted: state !== 'closed', role: context.role, ids, setOpen(next) { if (open === next) return send({ type: next ? 'open' : 'close' }) }, + backNavigate() { + // The host's back has no cancelable event — synthesize the veto payload + // so the callback contract matches the other dismissals. + const payload: BackNavigationPayload = { + defaultPrevented: false, + preventDefault() { + payload.defaultPrevented = true + }, + } + props.onBackNavigation?.(payload) + if (payload.defaultPrevented !== true) send({ type: 'history.back' }) + }, parts: { trigger: { hasPopup: 'dialog', diff --git a/packages/core/dialog/src/index.ts b/packages/core/dialog/src/index.ts index c1f7e99..bfac106 100644 --- a/packages/core/dialog/src/index.ts +++ b/packages/core/dialog/src/index.ts @@ -1,6 +1,7 @@ export { dialogMachine, type DialogMachine } from './machine' export { dialogConnect, type DialogApi, type DialogPartBindings } from './connect' export type { + BackNavigationPayload, DialogCallbacks, DialogContext, DialogIds, diff --git a/packages/core/dialog/src/machine.ts b/packages/core/dialog/src/machine.ts index 3b4a686..ad27a49 100644 --- a/packages/core/dialog/src/machine.ts +++ b/packages/core/dialog/src/machine.ts @@ -16,6 +16,7 @@ type DialogGuard = Guard const canEscape: DialogGuard = ({ context }) => context.closeOnEscape const canDismissOutside: DialogGuard = ({ context }) => context.closeOnInteractOutside +const canCloseOnBack: DialogGuard = ({ context }) => context.closeOnBack // Every open/close intent is recorded in `open.intent`; whether it also // transitions is intent's controlled/uncontrolled fork. `synced` is the full @@ -32,6 +33,10 @@ export function dialogMachine( options: DialogOptions, ): TransitionConfig { const role = options.role ?? 'dialog' + // Where a close lands, resolved at build like every seeded option: an + // animated dialog holds an exit window open in `closing` until the substrate + // reports the visual finished; otherwise closing is immediate. + const exitTo: DialogStateName = options.animated === true ? 'closing' : 'closed' // Annotated so createMachine infers Context as DialogContext, not the narrowed literal. const context: DialogContext = { role, @@ -40,6 +45,7 @@ export function dialogMachine( // An alert dialog interrupts for a response — an outside press must not // dismiss it unless explicitly opted in. closeOnInteractOutside: options.closeOnInteractOutside ?? role === 'dialog', + closeOnBack: options.closeOnBack ?? false, open: controllable(options.open), // The substrate supplies a unique id; `dialog` is only a bare fallback. id: options.id ?? 'dialog', @@ -63,15 +69,28 @@ export function dialogMachine( }, open: { on: { - close: intend('open', { target: 'closed', value: false }), - toggle: intend('open', { target: 'closed', value: false }), - escape: intend('open', { guard: canEscape, target: 'closed', value: false }), + close: intend('open', { target: exitTo, value: false }), + toggle: intend('open', { target: exitTo, value: false }), + escape: intend('open', { guard: canEscape, target: exitTo, value: false }), 'interact.outside': intend('open', { guard: canDismissOutside, - target: 'closed', + target: exitTo, value: false, }), - 'controlled.sync': synced('open', { value: false, target: 'closed' }), + 'history.back': intend('open', { guard: canCloseOnBack, target: exitTo, value: false }), + 'controlled.sync': synced('open', { value: false, target: exitTo }), + }, + }, + // The exit window (animated only; unreachable otherwise). The dialog is + // already logically closed here — reported, released, yielded — so + // dismissal intents don't apply; reopening interrupts the exit as a + // named transition instead of a substrate-side race. + closing: { + on: { + open: intend('open', { target: 'open', value: true }), + toggle: intend('open', { target: 'open', value: true }), + 'exit.complete': { target: 'closed' }, + 'controlled.sync': synced('open', { value: true, target: 'open' }), }, }, }, diff --git a/packages/core/dialog/src/types.ts b/packages/core/dialog/src/types.ts index aea0b49..34b72df 100644 --- a/packages/core/dialog/src/types.ts +++ b/packages/core/dialog/src/types.ts @@ -4,7 +4,11 @@ import type { Controllable, ControlledSync } from '@dunky.dev/controllable' import type { KeyboardPayload, PointerPayload } from '@dunky.dev/state-machine-bindings' -export type DialogStateName = 'closed' | 'open' +// `closing` exists only for an `animated` dialog: the exit window between the +// close intent and the visual leaving the screen. The dialog is already +// logically closed there — it reports, releases, and yields immediately; only +// unmounting waits. +export type DialogStateName = 'closed' | 'open' | 'closing' export type DialogRole = 'dialog' | 'alertdialog' @@ -29,6 +33,7 @@ export interface DialogContext { modal: boolean closeOnEscape: boolean closeOnInteractOutside: boolean + closeOnBack: boolean // The consumer-ownable open value. A controlled machine never moves on its // own — only `controlled.sync` (the prop echo) transitions it, and the // controlled flag tracks the prop's presence live. @@ -44,16 +49,28 @@ export interface DialogContext { // Dismissal intents (`escape` / `interact.outside`) are distinct from `close` // so the machine can gate them. `controlled.sync` is the controlled driver: // the substrate sends it when the `open` prop changes, and it is the only -// event that moves a controlled machine. +// event that moves a controlled machine. `exit.complete` is the substrate's +// report that the exit visual finished — the machine can't know when paint is +// done, so the substrate owns that one edge. export type DialogMachineEvent = | { type: 'open' } | { type: 'close' } | { type: 'toggle' } | { type: 'escape' } | { type: 'interact.outside' } + | { type: 'history.back' } + | { type: 'exit.complete' } | ControlledSync | { type: 'part.presence'; part: DialogPart; present: boolean } +/** The payload for a back-navigation dismissal. Synthesized by the connect — + * the host's back has no cancelable event of its own — carrying only the veto + * contract every dismissal callback shares. */ +export interface BackNavigationPayload { + defaultPrevented?: boolean + preventDefault?: () => void +} + export interface DialogCallbacks { /** Fired on every open/close transition with the new value. */ onOpenChange?: (open: boolean) => void @@ -61,6 +78,8 @@ export interface DialogCallbacks { onEscapeKeyDown?: (event: KeyboardPayload) => void /** Fired before an outside-press dismissal; `preventDefault()` vetoes it. */ onInteractOutside?: (event?: PointerPayload) => void + /** Fired before a back-navigation dismissal; `preventDefault()` vetoes it. */ + onBackNavigation?: (event?: BackNavigationPayload) => void } /** @@ -87,4 +106,13 @@ export interface DialogOptions extends DialogCallbacks { /** Whether pressing the backdrop closes the dialog. * @default true — false when `role="alertdialog"` */ closeOnInteractOutside?: boolean + /** Treats the host's Back navigation as a dismissal: while the dialog is + * open, Back closes it instead of leaving the page — one layer per press in + * a nested stack. The substrate wires the host mechanics (the web plants a + * guard entry in the session history). @default false */ + closeOnBack?: boolean + /** Reserves an exit window for a close animation: closing passes through the + * `closing` state (`data-state="closing"` styles the exit) and the dialog + * unmounts on `exit.complete` instead of immediately. @default false */ + animated?: boolean } diff --git a/packages/core/dialog/tests/machine.test.ts b/packages/core/dialog/tests/machine.test.ts index a0b5501..3be44f6 100644 --- a/packages/core/dialog/tests/machine.test.ts +++ b/packages/core/dialog/tests/machine.test.ts @@ -242,6 +242,120 @@ describe('dialog connect — reactions', () => { }) }) +describe('dialog machine — back navigation', () => { + it('ignores history.back without closeOnBack (the default)', () => { + const { service } = build({ defaultOpen: true }) + service.send({ type: 'history.back' }) + expect(service.state).toBe('open') + expect(service.context.open.intent).toBeNull() + }) + + it('closes on history.back when closeOnBack, through the exit window when animated', () => { + const { service } = build({ defaultOpen: true, closeOnBack: true }) + service.send({ type: 'history.back' }) + expect(service.state).toBe('closed') + + const animated = build({ defaultOpen: true, closeOnBack: true, animated: true }) + animated.service.send({ type: 'history.back' }) + expect(animated.service.state).toBe('closing') + }) + + it('backNavigate fires the callback and dismisses unless vetoed', () => { + const onBackNavigation = vi.fn() + const { service, connection } = build({ + defaultOpen: true, + closeOnBack: true, + onBackNavigation, + }) + connection.snapshot.backNavigate() + expect(onBackNavigation).toHaveBeenCalledTimes(1) + expect(service.state).toBe('closed') + + const vetoed = build({ + defaultOpen: true, + closeOnBack: true, + onBackNavigation: event => event?.preventDefault?.(), + }) + vetoed.connection.snapshot.backNavigate() + expect(vetoed.service.state).toBe('open') + }) + + it('a controlled dialog records the intent and stays put', () => { + const { service, connection } = build({ open: true, closeOnBack: true }) + connection.snapshot.backNavigate() + expect(service.state).toBe('open') + expect(service.context.open.intent).toEqual({ value: false }) + }) +}) + +describe('dialog machine — animated exit', () => { + it('a close intent holds the exit window open until exit.complete', () => { + const { service } = build({ defaultOpen: true, animated: true }) + service.send({ type: 'close' }) + expect(service.state).toBe('closing') + + service.send({ type: 'exit.complete' }) + expect(service.state).toBe('closed') + }) + + it('every dismissal source enters the same exit window, still gated', () => { + const escaped = build({ defaultOpen: true, animated: true }) + escaped.service.send({ type: 'escape' }) + expect(escaped.service.state).toBe('closing') + + const gated = build({ defaultOpen: true, animated: true, closeOnEscape: false }) + gated.service.send({ type: 'escape' }) + expect(gated.service.state).toBe('open') + }) + + it('reopening interrupts the exit', () => { + const { service } = build({ defaultOpen: true, animated: true }) + service.send({ type: 'toggle' }) + expect(service.state).toBe('closing') + + service.send({ type: 'toggle' }) + expect(service.state).toBe('open') + }) + + it('reports the close when the exit starts, the reopen when it interrupts', () => { + const onOpenChange = vi.fn() + const { service } = build({ defaultOpen: true, animated: true, onOpenChange }) + + service.send({ type: 'close' }) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + + service.send({ type: 'open' }) + expect(onOpenChange).toHaveBeenLastCalledWith(true) + + service.send({ type: 'close' }) + service.send({ type: 'exit.complete' }) // no report: nothing changed for the consumer + expect(onOpenChange).toHaveBeenCalledTimes(3) + }) + + it('a controlled dialog enters and interrupts the exit through the prop echo alone', () => { + const { service } = build({ open: true, animated: true }) + service.send({ type: 'close' }) // intent only — controlled never moves on its own + expect(service.state).toBe('open') + + service.send({ type: 'controlled.sync', value: false }) + expect(service.state).toBe('closing') + + service.send({ type: 'controlled.sync', value: true }) + expect(service.state).toBe('open') + }) + + it('connect exposes the exit window: mounted while closing, data-state="closing"', () => { + const { service, connection } = build({ defaultOpen: true, animated: true }) + service.send({ type: 'close' }) + expect(connection.snapshot.open).toBe(false) + expect(connection.snapshot.mounted).toBe(true) + expect(connection.snapshot.parts.content['data-state']).toBe('closing') + + service.send({ type: 'exit.complete' }) + expect(connection.snapshot.mounted).toBe(false) + }) +}) + describe('dialog machine — controlled', () => { it('a dismissal intent moves nothing and reports nothing', () => { const onOpenChange = vi.fn() diff --git a/packages/dom/utils/dialog/README.md b/packages/dom/utils/dialog/README.md new file mode 100644 index 0000000..0d40efd --- /dev/null +++ b/packages/dom/utils/dialog/README.md @@ -0,0 +1,58 @@ +# @dunky.dev/dom-dialog + +Framework-free DOM behavior for dialog substrates. One shared module owns +what every substrate's dialog must agree on: + +- **The layer stack** — `registerDialog` / `isTopmostDialog`. Every open + dialog registers a layer; the topmost (deepest nesting, open order breaking + ties) is the one Escape and the focus trap act on. While the topmost layer + is modal, everything outside its content is marked `aria-hidden` + `inert`, + except the layer's own backdrop — rendered outside the content's subtree + yet part of the layer, it must stay pressable for outside-press dismissal. + Unregistering restores exactly what was hidden and re-syncs for the layer + beneath. +- **Initial focus** — `getInitialFocus` resolves where focus moves on open: a + dialog that collects input starts at its first form field; any other + content keeps focus on the dialog window itself. +- **The exit window** — an animated dialog leaves the stack the moment it + starts closing, but keeps painting until its exit visual finishes. + `hideExitingLayer` takes the still-painting layer out of the page's + interaction (`inert`: pointer, tab order, assistive tech) for that window, + and `watchExitAnimation` reports when the visual finished — on the + element's own `transitionend`/`animationend`, immediately under + `prefers-reduced-motion`, or at a fallback ceiling so a missing exit style + can't hang the close. Both return a cancel/undo for the reopen interrupt. + +Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog` — so every +framework shares one stack: dialogs from different substrates on the same +page stack, hide, and unwind correctly against each other. + +## Install + +```sh +npm install @dunky.dev/dom-dialog +``` + +## Usage + +```ts +import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' + +// On open: join the stack, then move focus in. +const unregister = registerDialog({ + id, + depth, // nesting level, 1 = top-level + element: content, // the dialog window + modal: true, + backdrop: () => backdropElement, // stays pressable while topmost +}) +getInitialFocus(content).focus({ preventScroll: true }) + +// Escape, outside-press, focus trapping: only the topmost layer answers. +if (isTopmostDialog(id)) { + // ... +} + +// On close: leave the stack; the layer beneath is restored. +unregister() +``` diff --git a/packages/dom/utils/dialog/package.json b/packages/dom/utils/dialog/package.json new file mode 100644 index 0000000..a63f969 --- /dev/null +++ b/packages/dom/utils/dialog/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dunky.dev/dom-dialog", + "version": "0.0.0", + "description": "Framework-free DOM behavior for dialog substrates: the shared layer stack, assistive-tech containment, and initial focus.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/utils/dialog" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + } +} diff --git a/packages/react/dialog/src/utils/get-initial-focus.ts b/packages/dom/utils/dialog/src/get-initial-focus.ts similarity index 100% rename from packages/react/dialog/src/utils/get-initial-focus.ts rename to packages/dom/utils/dialog/src/get-initial-focus.ts diff --git a/packages/dom/utils/dialog/src/hide-exiting-layer.ts b/packages/dom/utils/dialog/src/hide-exiting-layer.ts new file mode 100644 index 0000000..5b3a0c9 --- /dev/null +++ b/packages/dom/utils/dialog/src/hide-exiting-layer.ts @@ -0,0 +1,38 @@ +/** + * A closing dialog has already left the stack — the page beneath is live + * again — but its layer keeps painting until the exit visual finishes. Take + * the layer out of the page's interaction for that window (`inert` covers + * pointer, tab order, and assistive tech): the content's outermost portalled + * ancestor below `boundary` (the viewport, or the content itself when + * portalled bare), plus the backdrop portalled alongside it. Elements the + * author already hides stay theirs, mirroring the containment walk. Returns + * an undo for the reopen interrupt. + */ +export function hideExitingLayer( + content: HTMLElement, + boundary: HTMLElement, + backdrop?: Element | null, +): () => void { + let root: HTMLElement = content + while (root.parentElement !== null && root.parentElement !== boundary) { + root = root.parentElement + } + + const targets: Element[] = [root] + if (backdrop != null && !root.contains(backdrop)) targets.push(backdrop) + + const hidden: Element[] = [] + for (const element of targets) { + if (element.hasAttribute('aria-hidden') || element.hasAttribute('inert')) continue + element.setAttribute('aria-hidden', 'true') + element.setAttribute('inert', '') + hidden.push(element) + } + + return () => { + for (const element of hidden) { + element.removeAttribute('aria-hidden') + element.removeAttribute('inert') + } + } +} diff --git a/packages/react/dialog/src/utils/hide-outside.ts b/packages/dom/utils/dialog/src/hide-outside.ts similarity index 51% rename from packages/react/dialog/src/utils/hide-outside.ts rename to packages/dom/utils/dialog/src/hide-outside.ts index 8283e6a..703afd9 100644 --- a/packages/react/dialog/src/utils/hide-outside.ts +++ b/packages/dom/utils/dialog/src/hide-outside.ts @@ -1,21 +1,28 @@ // Never hide these: they carry no rendered content, or must stay announced. const HIDE_SKIP = /^(SCRIPT|STYLE|LINK|TEMPLATE)$/ -// The containment trick: walk from `target` up to the document root and mark -// every sibling along the way `aria-hidden` + `inert`, so assistive tech sees -// only the target's subtree and nothing outside it can be reached — by pointer, -// find-in-page, or programmatic focus. Returns a function that removes exactly -// what it added. Callers hide one target at a time. -export function hideOutside(target: HTMLElement): () => void { +/** + * The containment trick: walks from `target` up to the document root and marks + * every sibling along the way `aria-hidden` + `inert`, so assistive tech sees + * only the target's subtree and nothing outside it can be reached — by pointer, + * find-in-page, or programmatic focus. `exclude` names the one same-layer + * element rendered outside the target's subtree (the dialog's own backdrop, + * portalled alongside its viewport) that must stay pressable. Returns a + * function that removes exactly what it added. Callers hide one target at a + * time. + */ +export function hideOutside(target: HTMLElement, exclude?: Element | null): () => void { const hidden: Element[] = [] let node: HTMLElement | null = target while (node !== null && node !== document.body && node.parentElement !== null) { for (const sibling of Array.from(node.parentElement.children)) { - // Skip the path itself, content-less tags, and anything the author already - // controls — an existing `aria-hidden` (any value) or `inert` is theirs. + // Skip the path itself, the layer's own excluded element, content-less + // tags, and anything the author already controls — an existing + // `aria-hidden` (any value) or `inert` is theirs. if ( sibling === node || + sibling === exclude || HIDE_SKIP.test(sibling.tagName) || sibling.hasAttribute('aria-hidden') || sibling.hasAttribute('inert') diff --git a/packages/dom/utils/dialog/src/index.ts b/packages/dom/utils/dialog/src/index.ts new file mode 100644 index 0000000..e07bf03 --- /dev/null +++ b/packages/dom/utils/dialog/src/index.ts @@ -0,0 +1,4 @@ +export { registerDialog, isTopmostDialog, type DialogLayer } from './stack' +export { getInitialFocus } from './get-initial-focus' +export { watchExitAnimation } from './watch-exit-animation' +export { hideExitingLayer } from './hide-exiting-layer' diff --git a/packages/react/dialog/src/utils/stack.ts b/packages/dom/utils/dialog/src/stack.ts similarity index 62% rename from packages/react/dialog/src/utils/stack.ts rename to packages/dom/utils/dialog/src/stack.ts index 5fc3982..e4b89f6 100644 --- a/packages/react/dialog/src/utils/stack.ts +++ b/packages/dom/utils/dialog/src/stack.ts @@ -4,22 +4,30 @@ import { hideOutside } from './hide-outside' // focus trap act on, and the one whose content stays visible to assistive tech — // is the deepest-nested dialog, with open order breaking ties between siblings. // -// Depth (not DOM order) decides it: React inserts a nested dialog's portal into -// the body *before* its parent's, so document order is the inverse of nesting. -interface Layer { +// Depth (not DOM order) decides it: a substrate may insert a nested dialog's +// portal into the body *before* its parent's (React does), so document order +// can be the inverse of nesting. +export interface DialogLayer { id: string depth: number order: number element: HTMLElement modal: boolean + /** + * Resolves the layer's own backdrop — rendered outside the content's subtree + * yet part of the layer, so it must stay pressable while its dialog is + * topmost. A getter (not a snapshot) so a re-hide — when a layer above + * closes — sees the element current at that moment. + */ + backdrop?: () => Element | null } -const layers: Layer[] = [] +const layers: DialogLayer[] = [] let nextOrder = 0 let undoHide: (() => void) | undefined -function topmost(): Layer | undefined { - let top: Layer | undefined +function topmost(): DialogLayer | undefined { + let top: DialogLayer | undefined for (const layer of layers) { if ( top === undefined || @@ -41,11 +49,11 @@ function syncAriaHidden(): void { const top = topmost() if (top?.modal !== true) return // `isConnected` guards teardown, when the content is already detached. - if (top.element.isConnected) undoHide = hideOutside(top.element) + if (top.element.isConnected) undoHide = hideOutside(top.element, top.backdrop?.() ?? null) } -export function registerDialog(layer: Omit): () => void { - const entry: Layer = { ...layer, order: nextOrder++ } +export function registerDialog(layer: Omit): () => void { + const entry: DialogLayer = { ...layer, order: nextOrder++ } layers.push(entry) syncAriaHidden() return () => { diff --git a/packages/dom/utils/dialog/src/watch-exit-animation.ts b/packages/dom/utils/dialog/src/watch-exit-animation.ts new file mode 100644 index 0000000..301403c --- /dev/null +++ b/packages/dom/utils/dialog/src/watch-exit-animation.ts @@ -0,0 +1,49 @@ +// The ceiling on waiting for the exit visual: if the author styled no +// transition/animation for `data-state="closing"` (or it never ends on this +// element), the dialog must still reach `closed` rather than hang mid-exit. +const EXIT_FALLBACK_MS = 500 + +/** + * Watches `element` for the end of its exit visual and reports it once — + * the substrate forwards that to the machine as `exit.complete`. Completion + * fires on the element's own `transitionend` / `animationend` (bubbled ends + * from descendants don't count: the exit belongs to the element carrying + * `data-state`), immediately when the user prefers reduced motion, or at a + * fallback ceiling so a missing exit style can't hang the close. Returns a + * cancel for the reopen interrupt. + */ +export function watchExitAnimation(element: HTMLElement, onComplete: () => void): () => void { + // Reduced motion: the exit should not play — skip straight to done. + // (`matchMedia` is feature-checked for non-browser DOM environments.) + if ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + onComplete() + return () => {} + } + + let done = false + const complete = (): void => { + if (done) return + done = true + cancel() + onComplete() + } + // A transition ends once per property — the first end on the element itself + // is the completion signal, so the exit style should finish as one piece. + const onEnd = (event: Event): void => { + if (event.target === element) complete() + } + + element.addEventListener('transitionend', onEnd) + element.addEventListener('animationend', onEnd) + const fallback = setTimeout(complete, EXIT_FALLBACK_MS) + + const cancel = (): void => { + element.removeEventListener('transitionend', onEnd) + element.removeEventListener('animationend', onEnd) + clearTimeout(fallback) + } + return cancel +} diff --git a/packages/dom/utils/dialog/tests/exit.test.ts b/packages/dom/utils/dialog/tests/exit.test.ts new file mode 100644 index 0000000..841e975 --- /dev/null +++ b/packages/dom/utils/dialog/tests/exit.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { hideExitingLayer, watchExitAnimation } from '@dunky.dev/dom-dialog' + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('watchExitAnimation', () => { + const mount = (): HTMLElement => { + document.body.innerHTML = '' + return document.getElementById('content') as HTMLElement + } + + it("completes once on the element's own transition/animation end", () => { + const element = mount() + const onComplete = vi.fn() + watchExitAnimation(element, onComplete) + + element.dispatchEvent(new Event('transitionend')) + element.dispatchEvent(new Event('animationend')) + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it("ignores ends bubbling from descendants — the exit is the element's own", () => { + const element = mount() + const onComplete = vi.fn() + watchExitAnimation(element, onComplete) + + element.querySelector('button')?.dispatchEvent(new Event('transitionend', { bubbles: true })) + expect(onComplete).not.toHaveBeenCalled() + }) + + it('falls back to a timeout so a missing exit style cannot hang the close', () => { + vi.useFakeTimers() + const element = mount() + const onComplete = vi.fn() + watchExitAnimation(element, onComplete) + + vi.runAllTimers() + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it('cancelling stops both the listeners and the fallback', () => { + vi.useFakeTimers() + const element = mount() + const onComplete = vi.fn() + const cancel = watchExitAnimation(element, onComplete) + + cancel() + element.dispatchEvent(new Event('transitionend')) + vi.runAllTimers() + expect(onComplete).not.toHaveBeenCalled() + }) + + it('completes immediately when the user prefers reduced motion', () => { + const element = mount() + vi.stubGlobal( + 'matchMedia', + vi.fn().mockReturnValue({ matches: true } as unknown as MediaQueryList), + ) + + const onComplete = vi.fn() + watchExitAnimation(element, onComplete) + expect(onComplete).toHaveBeenCalledTimes(1) + vi.unstubAllGlobals() + }) +}) + +describe('hideExitingLayer', () => { + it('hides the outermost portalled ancestor and the backdrop, and undoes exactly that', () => { + document.body.innerHTML = + '
' + const backdrop = document.getElementById('backdrop') as HTMLElement + const viewport = document.getElementById('viewport') as HTMLElement + const content = document.getElementById('content') as HTMLElement + + const undo = hideExitingLayer(content, document.body, backdrop) + expect(viewport.hasAttribute('inert')).toBe(true) + expect(viewport.getAttribute('aria-hidden')).toBe('true') + expect(backdrop.hasAttribute('inert')).toBe(true) + expect(content.hasAttribute('inert')).toBe(false) // covered by the viewport + + undo() + expect(viewport.hasAttribute('inert')).toBe(false) + expect(viewport.hasAttribute('aria-hidden')).toBe(false) + expect(backdrop.hasAttribute('inert')).toBe(false) + }) + + it('hides the content itself when it is portalled bare', () => { + document.body.innerHTML = '' + const content = document.getElementById('content') as HTMLElement + + hideExitingLayer(content, document.body) + expect(content.hasAttribute('inert')).toBe(true) + }) + + it('skips a backdrop already inside the hidden root, and author-hidden elements', () => { + document.body.innerHTML = + '
' + const viewport = document.getElementById('viewport') as HTMLElement + const backdrop = document.getElementById('backdrop') as HTMLElement + viewport.setAttribute('aria-hidden', 'false') + + const undo = hideExitingLayer( + document.getElementById('content') as HTMLElement, + document.body, + backdrop, + ) + expect(backdrop.hasAttribute('inert')).toBe(false) // inside the root — covered + expect(viewport.hasAttribute('inert')).toBe(false) // the author's aria-hidden is theirs + + undo() + expect(viewport.getAttribute('aria-hidden')).toBe('false') + }) +}) diff --git a/packages/dom/utils/dialog/tests/stack.test.ts b/packages/dom/utils/dialog/tests/stack.test.ts new file mode 100644 index 0000000..dbe793a --- /dev/null +++ b/packages/dom/utils/dialog/tests/stack.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' +import type { DialogLayer } from '@dunky.dev/dom-dialog' + +interface MountedLayer { + backdrop: HTMLElement + viewport: HTMLElement + content: HTMLElement +} + +// The anatomy every substrate portals to the body: backdrop and viewport as +// flat siblings, the dialog window inside the viewport. +const mountLayer = (): MountedLayer => { + const backdrop = document.createElement('div') + const viewport = document.createElement('div') + const content = document.createElement('dialog') + viewport.append(content) + document.body.append(backdrop, viewport) + return { backdrop, viewport, content } +} + +const registered: Array<() => void> = [] + +const register = (layer: Omit): (() => void) => { + const unregister = registerDialog(layer) + registered.push(unregister) + return unregister +} + +const hiddenFrom = (element: Element): boolean => + element.getAttribute('aria-hidden') === 'true' && element.hasAttribute('inert') + +afterEach(() => { + for (const unregister of registered) unregister() + registered.length = 0 + document.body.innerHTML = '' +}) + +describe('registerDialog containment', () => { + it('hides everything outside the topmost modal layer and restores it on unregister', () => { + const outside = document.createElement('main') + document.body.append(outside) + const layer = mountLayer() + + const unregister = register({ id: 'a', depth: 1, element: layer.content, modal: true }) + expect(hiddenFrom(outside)).toBe(true) + + unregister() + expect(outside.hasAttribute('aria-hidden')).toBe(false) + expect(outside.hasAttribute('inert')).toBe(false) + }) + + it("keeps the layer's own backdrop pressable", () => { + const layer = mountLayer() + register({ + id: 'a', + depth: 1, + element: layer.content, + modal: true, + backdrop: () => layer.backdrop, + }) + + expect(layer.backdrop.hasAttribute('aria-hidden')).toBe(false) + expect(layer.backdrop.hasAttribute('inert')).toBe(false) + }) + + it('leaves pre-hidden elements and content-less tags to their author', () => { + const authored = document.createElement('div') + authored.setAttribute('aria-hidden', 'false') + const script = document.createElement('script') + document.body.append(authored, script) + const layer = mountLayer() + + const unregister = register({ id: 'a', depth: 1, element: layer.content, modal: true }) + expect(authored.hasAttribute('inert')).toBe(false) + expect(script.hasAttribute('inert')).toBe(false) + + unregister() + expect(authored.getAttribute('aria-hidden')).toBe('false') + }) + + it('hides nothing for a non-modal layer', () => { + const outside = document.createElement('main') + document.body.append(outside) + const layer = mountLayer() + + register({ id: 'a', depth: 1, element: layer.content, modal: false }) + expect(outside.hasAttribute('aria-hidden')).toBe(false) + expect(outside.hasAttribute('inert')).toBe(false) + }) + + it("re-excepts the lower layer's backdrop the moment it becomes topmost again", () => { + const outer = mountLayer() + const inner = mountLayer() + register({ + id: 'outer', + depth: 1, + element: outer.content, + modal: true, + backdrop: () => outer.backdrop, + }) + const unregisterInner = register({ + id: 'inner', + depth: 2, + element: inner.content, + modal: true, + backdrop: () => inner.backdrop, + }) + + // While the inner layer is topmost, the outer layer is hidden whole — + // backdrop included; only the topmost's own backdrop is excepted. + expect(hiddenFrom(outer.backdrop)).toBe(true) + expect(hiddenFrom(outer.viewport)).toBe(true) + expect(inner.backdrop.hasAttribute('inert')).toBe(false) + + unregisterInner() + expect(outer.backdrop.hasAttribute('inert')).toBe(false) + expect(hiddenFrom(inner.viewport)).toBe(true) + }) +}) + +describe('isTopmostDialog', () => { + it('deeper nesting wins regardless of registration order', () => { + const shallow = mountLayer() + const deep = mountLayer() + register({ id: 'deep', depth: 2, element: deep.content, modal: true }) + register({ id: 'shallow', depth: 1, element: shallow.content, modal: true }) + + expect(isTopmostDialog('deep')).toBe(true) + expect(isTopmostDialog('shallow')).toBe(false) + }) + + it('open order breaks ties between layers at the same depth', () => { + const first = mountLayer() + const second = mountLayer() + register({ id: 'first', depth: 1, element: first.content, modal: true }) + const unregisterSecond = register({ + id: 'second', + depth: 1, + element: second.content, + modal: true, + }) + expect(isTopmostDialog('second')).toBe(true) + + unregisterSecond() + expect(isTopmostDialog('first')).toBe(true) + }) +}) + +describe('getInitialFocus', () => { + it('resolves the first form field that can take focus', () => { + const content = document.createElement('div') + content.innerHTML = + '' + + '' + + '' + + '' + + expect(getInitialFocus(content).id).toBe('field') + }) + + it('falls back to the content itself without form fields', () => { + const content = document.createElement('div') + content.innerHTML = '' + + expect(getInitialFocus(content)).toBe(content) + }) +}) diff --git a/packages/dom/utils/navigation/README.md b/packages/dom/utils/navigation/README.md new file mode 100644 index 0000000..88fb70f --- /dev/null +++ b/packages/dom/utils/navigation/README.md @@ -0,0 +1,57 @@ +# @dunky.dev/dom-navigation + +Framework-free browser-navigation helpers. + +## Install + +```sh +npm install @dunky.dev/dom-navigation +``` + +## interceptBackNavigation + +Plants a guard entry in the session history so the host's Back dismisses an +overlaid layer — a dialog, drawer, sheet — instead of leaving the page. + +```ts +import { interceptBackNavigation } from '@dunky.dev/dom-navigation' + +// On open — arm the guard. `onBack` returns whether the layer closed. +const release = interceptBackNavigation(() => { + close() + return true // return false to veto: the guard re-arms for the next Back +}) + +// On close by any other means — release the guard. +release() +``` + +Guards stack, so a Back press unwinds one layer per press. Substrate bindings +wrap this — e.g. `@dunky.dev/react-dialog`'s `closeOnBack`. + +## Reload + +The guard entry survives a reload; the layer's open-state doesn't. A layer +opened transiently and then reloaded boots closed, leaving a dead same-URL +entry — so the first Back appears to do nothing. + +The interceptor can't fix this: on reload only the host knows whether the layer +should reopen. So when a layer must survive reload (or be shareable, or reopen +on Forward), keep its open-state in the URL and derive the layer from it. Back +closes for free, and reload restores the layer because the URL says so. + +```ts +// The URL is the source of truth — survives reload, no orphan to step over. +const isOpen = () => location.hash === '#dialog' + +const open = () => history.pushState(null, '', '#dialog') +const close = () => { + if (isOpen()) history.back() +} + +// Re-render from isOpen() on every history change: Back, Forward, and reload. +window.addEventListener('popstate', render) +``` + +For a dialog that need not outlive a reload, `interceptBackNavigation` is +exactly right and needs none of this. diff --git a/packages/dom/utils/navigation/package.json b/packages/dom/utils/navigation/package.json new file mode 100644 index 0000000..06af306 --- /dev/null +++ b/packages/dom/utils/navigation/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dunky.dev/dom-navigation", + "version": "0.0.0", + "description": "Framework-free browser-navigation helpers: a session-history guard so the host's Back dismisses a layer instead of leaving the page.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/utils/navigation" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + } +} diff --git a/packages/dom/utils/navigation/src/index.ts b/packages/dom/utils/navigation/src/index.ts new file mode 100644 index 0000000..9918093 --- /dev/null +++ b/packages/dom/utils/navigation/src/index.ts @@ -0,0 +1 @@ +export { interceptBackNavigation } from './intercept-back-navigation' diff --git a/packages/dom/utils/navigation/src/intercept-back-navigation.ts b/packages/dom/utils/navigation/src/intercept-back-navigation.ts new file mode 100644 index 0000000..a12cf33 --- /dev/null +++ b/packages/dom/utils/navigation/src/intercept-back-navigation.ts @@ -0,0 +1,116 @@ +// Marks a layer's guard entry in the session history; the value says which +// interceptor owns the entry. +const STATE_KEY = '@dunky.back' + +interface BackGuard { + id: number + onBack: () => boolean +} + +// One shared registry + one popstate listener across every layer: a Back +// press pops exactly one entry, so only the interceptor whose guard entry +// vanished may answer — the ones beneath see their entry still current and +// stay armed. That ordering is what makes stacked layers (nested dialogs, +// a drawer under a sheet) unwind one per press with no cross-layer +// bookkeeping. +const guards: BackGuard[] = [] +let nextGuardId = 0 +// Pops this module caused itself (consuming a guard entry on release). The +// browser reports them through the same popstate as a user's Back — count +// them so they are never read as one and unwind another layer. +let swallow = 0 + +function currentGuardId(): number | undefined { + const state: unknown = history.state + if (typeof state !== 'object' || state === null) return undefined + const id = (state as Record)[STATE_KEY] + return typeof id === 'number' ? id : undefined +} + +function isRegistered(id: number): boolean { + for (const guard of guards) if (guard.id === id) return true + return false +} + +// The listener detaches only when nothing is left to hear: an in-flight +// self-caused pop (swallow) still needs it even with every guard released. +function detachWhenIdle(): void { + if (guards.length === 0 && swallow === 0) { + window.removeEventListener('popstate', onPopState) + } +} + +function onPopState(): void { + if (swallow > 0) { + swallow-- + // Self-heal: if our own pop consumed an entry a live guard still needs + // (it adopted the entry while the traversal was in flight), re-arm it. + const top = guards[guards.length - 1] + if (top !== undefined && top.id !== currentGuardId()) { + history.pushState({ [STATE_KEY]: top.id }, '') + } + detachWhenIdle() + return + } + // Unwind every guard the traversal jumped over, topmost first — a Back + // press covers one; a multi-entry jump (history.go(-n)) covers several. + const current = currentGuardId() + while (guards.length > 0) { + const top = guards[guards.length - 1] as BackGuard + if (top.id === current) break + if (top.onBack()) { + guards.pop() + continue + } + // Declined — vetoed, or a controlled layer that hasn't followed yet: + // re-arm the guard entry so the next Back reaches this layer again. + history.pushState({ [STATE_KEY]: top.id }, '') + break + } + detachWhenIdle() +} + +/** + * Plants a guard entry in the session history so the host's Back dismisses a + * layer (a dialog, drawer, sheet — anything overlaid) instead of leaving the + * page. `onBack` fires when the user pops the entry and returns whether the + * layer actually closed — a decline re-arms the guard. The returned release + * (for a layer closed by any other means) consumes a still-current guard + * entry so it can't swallow the next Back; an entry buried under later + * navigation is unreachable and left alone. + * + * Consumption is deferred a microtask so a release immediately followed by a + * re-register in the same synchronous turn nets out to zero traversals: the + * re-register finds the entry still current but no longer owned and adopts it + * in place (rewrites the marker), so when the deferred consumption runs the + * entry is no longer this guard's and no `history.back()` is queued. That + * matters because a traversal queued by `history.back()` is not reliably + * delivered once another entry is pushed before it lands; not queuing one in + * that window removes the race instead of compensating for it. + */ +export function interceptBackNavigation(onBack: () => boolean): () => void { + const guard: BackGuard = { id: ++nextGuardId, onBack } + // Identical (type, listener) pairs dedupe, so attaching is idempotent. + window.addEventListener('popstate', onPopState) + const current = currentGuardId() + const adoptable = current !== undefined && !isRegistered(current) + guards.push(guard) + if (adoptable) history.replaceState({ [STATE_KEY]: guard.id }, '') + else history.pushState({ [STATE_KEY]: guard.id }, '') + + return () => { + const index = guards.indexOf(guard) + if (index === -1) return // already unwound by the Back press itself + guards.splice(index, 1) + queueMicrotask(() => { + // Still ours and still current: nobody adopted it and no Back popped + // it — consume the entry. The listener stays until the pop lands. + if (currentGuardId() === guard.id) { + swallow++ + history.back() + } else { + detachWhenIdle() + } + }) + } +} diff --git a/packages/dom/utils/navigation/tests/intercept-back-navigation.test.ts b/packages/dom/utils/navigation/tests/intercept-back-navigation.test.ts new file mode 100644 index 0000000..64d2432 --- /dev/null +++ b/packages/dom/utils/navigation/tests/intercept-back-navigation.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { interceptBackNavigation } from '@dunky.dev/dom-navigation' + +// jsdom's history traversal is asynchronous: back() returns immediately and +// the state change + popstate land on a later task — await the event itself. +const nextPop = (): Promise => + new Promise(resolve => { + window.addEventListener('popstate', () => resolve(), { once: true }) + }) + +const pressBack = async (): Promise => { + const pop = nextPop() + history.back() + await pop +} + +describe('interceptBackNavigation', () => { + it('plants a guard entry; Back pops it and fires onBack once', async () => { + const before: unknown = history.state + const onBack = vi.fn(() => true) + interceptBackNavigation(onBack) + expect(history.state).not.toEqual(before) + + await pressBack() + expect(onBack).toHaveBeenCalledTimes(1) + expect(history.state).toEqual(before) + }) + + it('release consumes a still-current guard entry without firing onBack', async () => { + const before: unknown = history.state + const onBack = vi.fn(() => true) + const release = interceptBackNavigation(onBack) + + const pop = nextPop() + release() + await pop + expect(onBack).not.toHaveBeenCalled() + expect(history.state).toEqual(before) + }) + + it('a declined close re-arms the guard so the next Back reaches it again', async () => { + const before: unknown = history.state + let accept = false + const onBack = vi.fn(() => accept) + interceptBackNavigation(onBack) + + await pressBack() + expect(onBack).toHaveBeenCalledTimes(1) + expect(history.state).not.toEqual(before) // re-armed + + accept = true + await pressBack() + expect(onBack).toHaveBeenCalledTimes(2) + expect(history.state).toEqual(before) + }) + + it('stacked guards unwind topmost-first, one layer per press', async () => { + const lower = vi.fn(() => true) + const upper = vi.fn(() => true) + interceptBackNavigation(lower) + interceptBackNavigation(upper) + + await pressBack() + expect(upper).toHaveBeenCalledTimes(1) + expect(lower).not.toHaveBeenCalled() + + await pressBack() + expect(lower).toHaveBeenCalledTimes(1) + }) + + // The StrictMode shape: a synchronous release -> re-register (double-invoked + // effect, same-commit reopen) must adopt the still-current entry in place — + // zero traversals, so there is no self-caused pop to race or misread. + it('a synchronous release + re-register adopts the entry with no traversal', async () => { + const before: unknown = history.state + const first = vi.fn(() => true) + const second = vi.fn(() => true) + + const release = interceptBackNavigation(first) + const lengthAfterFirst = history.length + release() + interceptBackNavigation(second) + expect(history.length).toBe(lengthAfterFirst) // rewritten in place, not pushed + + // Flush the deferred consume — adoption must have cancelled it. + await new Promise(resolve => queueMicrotask(resolve)) + expect(second).not.toHaveBeenCalled() + + await pressBack() + expect(second).toHaveBeenCalledTimes(1) // the user's Back still lands + expect(first).not.toHaveBeenCalled() + expect(history.state).toEqual(before) + }) +}) diff --git a/packages/react/dialog/SPEC.md b/packages/react/dialog/SPEC.md index 37c5d67..a23d558 100644 --- a/packages/react/dialog/SPEC.md +++ b/packages/react/dialog/SPEC.md @@ -34,7 +34,9 @@ import { Dialog } from '@dunky.dev/react-dialog' React-specific notes on top of the core contract: - **`Portal`** teleports the layers to `document.body`, or to a `container` - you supply. Nothing is kept mounted while closed. When scoped to a + you supply. Nothing is kept mounted while closed; an `animated` dialog + stays mounted through the core contract's `closing` state so its exit can + play — see the exit-animation note below. When scoped to a `container`, the scroll lock applies to that container instead of the page, and the backdrop/viewport must be positioned `absolute` (not `fixed`) so the overlay pins to the container. Because an `absolute` overlay can't stay fixed @@ -49,6 +51,22 @@ React-specific notes on top of the core contract: with the browser's built-in dialog behavior. - **`Backdrop`** renders nothing when the dialog is non-modal (`modal={false}`), per the core parts contract. +- **Exit animation** (`animated`): style the exit on the parts' + `data-state="closing"` — a CSS transition or animation on **Content** (the + element carrying the state, not a descendant) is what signals completion; + a missing exit style falls back to a short ceiling, and + `prefers-reduced-motion` skips the wait entirely. The exit is cosmetic: + focus, the dialog stack, and page interaction release the moment closing + starts, and the still-painting layer is made `inert` until it unmounts. + Enter needs no state — the parts mount straight into `data-state="open"`, + so a CSS animation (or a transition via `@starting-style`) plays from + mount. +- **Back navigation** (`closeOnBack`): opening plants a guard entry in the + session history, so the browser's Back closes the dialog instead of leaving + the page — one layer per press in a nested stack, per the core contract. A + dialog closed any other way consumes its entry, leaving nothing to swallow + a later Back; an entry buried under in-app navigation while the dialog is + open is left alone (Back then both navigates and closes the dialog). - Everything ships headless, per the core contract's [Internals](../../core/dialog/SPEC.md#internals). @@ -69,6 +87,9 @@ The root: owns open/close state, renders no DOM. Accepts the core | `closeOnEscape` | `boolean` | `true` | Whether Escape closes the dialog. | | `escapeScope` | `'layer' \| 'stack'` | `'layer'` | How far an allowed Escape reaches: this dialog, or its whole stack. | | `closeOnInteractOutside` | `boolean` | `true` — `false` for `role="alertdialog"` | Whether pressing the backdrop/viewport closes the dialog. | +| `animated` | `boolean` | `false` | Keeps the dialog mounted through `data-state="closing"` while its exit animation plays. | +| `closeOnBack` | `boolean` | `false` | The browser's Back closes the open dialog instead of navigating (a guard entry in the session history). | +| `onBackNavigation` | `(event?) => void` | — | Fired before a back-navigation dismissal; `preventDefault()` vetoes. | | `onEscapeKeyDown` | `(event) => void` | — | Fired before an Escape dismissal; `preventDefault()` vetoes. | | `onInteractOutside` | `(event?) => void` | — | Fired before an outside-press dismissal; `preventDefault()` vetoes. | | `id` | `string` | auto (`useId`) | Base id for the parts; per-part ids are derived from it. | @@ -134,7 +155,10 @@ Describes the dialog (wires `aria-describedby` on Content). ### `Dialog.Close` -Dismisses the dialog from inside. +Dismisses the dialog from inside — the single dismissal affordance (the +corner `×`), rendered once per dialog and kept the focus cycle's last stop per +the core contract. Action buttons (Cancel/Confirm) are your own ` + - ), + ) +} + +export const alertDialog: StoryType = { + render: () => , } export const longContent: StoryType = { @@ -152,7 +175,8 @@ export const longContent: StoryType = { - + + Terms of service Content taller than the screen scrolls within the viewport layer. @@ -163,30 +187,6 @@ export const longContent: StoryType = { tempor incididunt ut labore et dolore magna aliqua.

))} - -
-
-
- - ), -} - -export const withCloseButton: StoryType = { - render: () => ( - - Open dialog - - - - - - × - - Share board - - Anyone with the link can view this board. The corner button and Escape both dismiss. - - @@ -201,7 +201,8 @@ export const loginForm: StoryType = { - + + Sign in Focus moves to the first field on open, and stays trapped inside while the dialog is @@ -227,7 +228,6 @@ export const loginForm: StoryType = { />
- Cancel
@@ -245,10 +245,10 @@ export const trigger: StoryType = { - + + Closed by default Only the trigger renders until it is pressed. - @@ -275,14 +275,14 @@ const ScopedDialog = () => { - + + Scoped dialog Portaled into the panel boundary; the backdrop and viewport are `absolute`, so the overlay fills the panel's visible box and stays put while the background scrolls behind it. - @@ -298,7 +298,12 @@ export const scoped: StoryType = { } // "Close all" is consumer-side for now — `Close scope="stack"` is spec-only, so -// the three layers are controlled and one handler drops them together. +// the three layers are controlled and one handler drops them together. And a +// controlled dialog never moves on its own: each layer decides its dismissals +// at the source — its Trigger handler, its own action buttons, and the +// dismissal callbacks (`onEscapeKeyDown` / `onInteractOutside`) — per the +// controlled contract; `onOpenChange` only reports changes that actually +// happened. const NestedDialogs = () => { const [outerOpen, setOuterOpen] = useState(true) const [innerOpen, setInnerOpen] = useState(false) @@ -309,8 +314,13 @@ const NestedDialogs = () => { setOuterOpen(false) } return ( - - Open outer + setOuterOpen(false)} + onInteractOutside={() => setOuterOpen(false)} + > + setOuterOpen(true)}>Open outer @@ -320,8 +330,13 @@ const NestedDialogs = () => { Escape and outside presses dismiss the topmost dialog only — the stack unwinds one layer at a time.
- - Open inner + setInnerOpen(false)} + onInteractOutside={() => setInnerOpen(false)} + > + setInnerOpen(true)}>Open inner @@ -331,8 +346,15 @@ const NestedDialogs = () => { While open, everything beneath — including the outer dialog — is inert and hidden from assistive tech. - - Open innermost + setInnermostOpen(false)} + onInteractOutside={() => setInnermostOpen(false)} + > + setInnermostOpen(true)}> + Open innermost + @@ -344,18 +366,22 @@ const NestedDialogs = () => {
- Close +
- +
+ +
- +
+ +
@@ -366,3 +392,33 @@ const NestedDialogs = () => { export const nested: StoryType = { render: () => , } + +// closeOnBack turns the host's Back into a dismissal: while the dialog is open, +// a guard entry sits in the session history, so the browser's Back closes the +// dialog instead of leaving the page — what mobile users expect from a +// full-screen overlay. The canvas has no browser chrome, so the in-dialog +// button stands in for a real Back press by calling `history.back()`. +export const closeOnBack: StoryType = { + render: () => ( + + Open dialog + + + + + + Rename board + + The browser's Back closes this dialog instead of navigating away. Press Back — or + the button below, which stands in for it here — and the dialog dismisses while the + page stays put. + +
+ +
+
+
+
+
+ ), +} diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx index 97da1c1..d82dbe3 100644 --- a/packages/react/dialog/tests/dialog.test.tsx +++ b/packages/react/dialog/tests/dialog.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // The React edge of the Dialog — behavior only; the machine's own contract is // covered in @dunky.dev/dialog's tests. -import { useRef } from 'react' +import { useRef, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Dialog, type DialogProps } from '@dunky.dev/react-dialog' @@ -93,6 +93,19 @@ describe('Dialog', () => { expect(screen.queryByRole('dialog')).toBeNull() }) + // The backdrop is portalled alongside the viewport, outside the content's + // subtree — the containment walk must except it, or `inert` would swallow + // real pointer presses on it (jsdom's .click() bypasses hit-testing, so + // only the attributes can assert this). + it('keeps its own backdrop pressable while the page around it is inert', () => { + const { container } = render() + expect(container.hasAttribute('inert')).toBe(true) + + const backdrop = screen.getByTestId('backdrop') + expect(backdrop.hasAttribute('aria-hidden')).toBe(false) + expect(backdrop.hasAttribute('inert')).toBe(false) + }) + it('stays open when closeOnInteractOutside=false', () => { render() act(() => screen.getByTestId('backdrop').click()) @@ -167,6 +180,60 @@ describe('Dialog', () => { expect(onOpenChange).toHaveBeenCalledTimes(1) }) + // The controlled contract's consumer side: the dialog never moves on its + // own, so the consumer's own handlers on the parts and the dismissal + // callbacks are what drive the prop. + it('a controlled stack closes through handlers wired at the source', () => { + const ControlledStack = () => { + const [outerOpen, setOuterOpen] = useState(true) + const [innerOpen, setInnerOpen] = useState(false) + return ( + setOuterOpen(false)} + > + + + + Outer + setInnerOpen(false)} + > + setInnerOpen(true)}>Open inner + + + + Inner + setInnerOpen(false)}> + Close inner + + + + + + + + + + ) + } + + render() + act(() => screen.getByText('Open inner').click()) + expect(screen.queryByText('Inner')).not.toBeNull() + + act(() => screen.getByText('Close inner').click()) + expect(screen.queryByText('Inner')).toBeNull() + + act(() => screen.getByText('Open inner').click()) + act(pressEscape) // reaches the topmost layer only + expect(screen.queryByText('Inner')).toBeNull() + expect(screen.queryByText('Outer')).not.toBeNull() + }) + it('dropping the open prop rewires the dialog uncontrolled where it stands', () => { const onOpenChange = vi.fn() const { rerender } = render() @@ -377,17 +444,111 @@ describe('Dialog', () => { }) }) + describe('back navigation', () => { + // jsdom's history traversal is asynchronous — await the popstate itself. + const nextPop = (): Promise => + new Promise(resolve => { + window.addEventListener('popstate', () => resolve(), { once: true }) + }) + + it('closes on the browser Back instead of navigating', async () => { + const before: unknown = window.history.state + render() + openDialog() + expect(window.history.state).not.toEqual(before) // the guard entry is planted + + const pop = nextPop() + await act(async () => { + window.history.back() + await pop + }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(window.history.state).toEqual(before) // consumed by the press itself + }) + + it('closing any other way consumes the guard entry', async () => { + const before: unknown = window.history.state + render() + expect(window.history.state).not.toEqual(before) + + const pop = nextPop() + act(pressEscape) + await act(async () => { + await pop + }) + expect(window.history.state).toEqual(before) // no leftover to swallow a Back + }) + + it('plants no history entry without the flag', () => { + const before: unknown = window.history.state + render() + expect(window.history.state).toEqual(before) + }) + }) + + describe('exit animation', () => { + const fireTransitionEnd = (element: Element): void => { + act(() => { + element.dispatchEvent(new Event('transitionend', { bubbles: true })) + }) + } + + it('stays mounted through the exit and unmounts when its transition ends', () => { + render() + act(pressEscape) + + // Mid-exit: still in the tree, styled by data-state, hidden from AT. + const dialog = screen.getByRole('dialog', { hidden: true }) + expect(dialog.getAttribute('data-state')).toBe('closing') + + fireTransitionEnd(dialog) + expect(screen.queryByRole('dialog', { hidden: true })).toBeNull() + }) + + it('releases focus, containment, and interaction the moment the exit starts', () => { + const { container } = render() + const trigger = screen.getByText('Trigger') + act(() => trigger.focus()) + openDialog() + expect(container.hasAttribute('inert')).toBe(true) + + act(pressEscape) + // The page is live and focus is home before the visual finishes… + expect(container.hasAttribute('inert')).toBe(false) + expect(document.activeElement).toBe(trigger) + // …while the still-painting layer is out of the interaction instead. + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('backdrop').hasAttribute('inert')).toBe(true) + }) + + it('reopening mid-exit interrupts it and restores the layer', () => { + render() + openDialog() + act(pressEscape) + openDialog() + + const dialog = screen.getByRole('dialog') + expect(dialog.getAttribute('data-state')).toBe('open') + expect(screen.getByTestId('viewport').hasAttribute('inert')).toBe(false) + expect(document.activeElement).toBe(dialog) + + // The interrupted exit's end must not close the reopened dialog. + fireTransitionEnd(dialog) + expect(screen.queryByRole('dialog')).not.toBeNull() + }) + }) + describe('nesting', () => { const NestedDialog = (props: DialogProps) => ( - + Outer - + Inner @@ -425,6 +586,15 @@ describe('Dialog', () => { expect(inner.hasAttribute('inert')).toBe(false) }) + it("hides the lower dialog's backdrop but never the topmost's own", () => { + render() + expect(screen.getByTestId('outer-backdrop').hasAttribute('inert')).toBe(true) + expect(screen.getByTestId('inner-backdrop').hasAttribute('inert')).toBe(false) + + act(pressEscape) // the outer dialog is topmost again — its backdrop re-excepted + expect(screen.getByTestId('outer-backdrop').hasAttribute('inert')).toBe(false) + }) + it('restores the layer beneath once the top dialog closes', () => { render() expect(screen.getByTestId('outer-viewport').getAttribute('aria-hidden')).toBe('true') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0362cac..9f59242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,12 @@ importers: specifier: ^0.1.0 version: 0.1.0 + packages/dom/utils/dialog: {} + packages/dom/utils/focus-trap: {} + packages/dom/utils/navigation: {} + packages/dom/utils/scroll-lock: {} packages/react: @@ -99,6 +103,12 @@ importers: '@dunky.dev/dialog': specifier: workspace:* version: link:../../core/dialog + '@dunky.dev/dom-dialog': + specifier: workspace:* + version: link:../../dom/utils/dialog + '@dunky.dev/dom-navigation': + specifier: workspace:* + version: link:../../dom/utils/navigation '@dunky.dev/react-state-machine': specifier: ^0.1.0 version: 0.1.0(react@19.2.7) diff --git a/tsconfig.json b/tsconfig.json index 08ddb94..3ebacee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,9 @@ "@dunky.dev/controllable": ["./packages/core/utils/controllable/src"], "@dunky.dev/dialog": ["./packages/core/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], + "@dunky.dev/dom-dialog": ["./packages/dom/utils/dialog/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], + "@dunky.dev/dom-navigation": ["./packages/dom/utils/navigation/src"], "@dunky.dev/dom-scroll-lock": ["./packages/dom/utils/scroll-lock/src"], "@dunky.dev/react-use-focus-trap": ["./packages/react/hooks/use-focus-trap/src"], "@dunky.dev/react-use-scroll-lock": ["./packages/react/hooks/use-scroll-lock/src"] diff --git a/tsdown.config.ts b/tsdown.config.ts index e2e164e..d64d16c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,7 +12,9 @@ export default defineConfig({ workspace: [ 'packages/core/dialog', 'packages/core/utils/controllable', + 'packages/dom/utils/dialog', 'packages/dom/utils/focus-trap', + 'packages/dom/utils/navigation', 'packages/dom/utils/scroll-lock', 'packages/react/dialog', 'packages/react/hooks/use-focus-trap',