From 0e259c6a7e6fa0e032ecce094820db6dc4319734 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 10:26:27 +0200 Subject: [PATCH 01/13] fix(dialog): keep the topmost dialog's own backdrop pressable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the dialog's framework-free DOM behavior — the layer stack, assistive-tech containment, and initial focus — into a new @dunky.dev/dom-dialog package so every substrate shares one implementation (and one stack), and except a layer's own backdrop from the containment walk: the topmost modal dialog was marking its own backdrop aria-hidden + inert, silently breaking backdrop-press dismissal in real browsers (test-runner .click() bypasses hit-testing, so suites never caught it). Co-Authored-By: Claude Fable 5 --- .changeset/dialog-backdrop-containment.md | 14 ++ .changeset/dom-dialog-package.md | 28 +++ ARCHITECTURE.md | 1 + packages/dom/utils/dialog/README.md | 50 ++++++ packages/dom/utils/dialog/package.json | 36 ++++ .../utils/dialog/src}/get-initial-focus.ts | 0 .../utils/dialog/src}/hide-outside.ts | 23 ++- packages/dom/utils/dialog/src/index.ts | 2 + .../utils => dom/utils/dialog/src}/stack.ts | 26 ++- packages/dom/utils/dialog/tests/stack.test.ts | 169 ++++++++++++++++++ packages/react/dialog/package.json | 1 + packages/react/dialog/src/context.ts | 6 +- packages/react/dialog/src/dialog.tsx | 16 +- packages/react/dialog/src/effects.ts | 3 +- packages/react/dialog/tests/dialog.test.tsx | 26 ++- pnpm-lock.yaml | 5 + tsconfig.json | 1 + tsdown.config.ts | 1 + 18 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 .changeset/dialog-backdrop-containment.md create mode 100644 .changeset/dom-dialog-package.md create mode 100644 packages/dom/utils/dialog/README.md create mode 100644 packages/dom/utils/dialog/package.json rename packages/{react/dialog/src/utils => dom/utils/dialog/src}/get-initial-focus.ts (100%) rename packages/{react/dialog/src/utils => dom/utils/dialog/src}/hide-outside.ts (51%) create mode 100644 packages/dom/utils/dialog/src/index.ts rename packages/{react/dialog/src/utils => dom/utils/dialog/src}/stack.ts (62%) create mode 100644 packages/dom/utils/dialog/tests/stack.test.ts 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/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/ARCHITECTURE.md b/ARCHITECTURE.md index 8c30a8f..aa273e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,6 +28,7 @@ packages/ | +- dom/ | +- utils/ framework-free DOM utilities, one package per util +| +- dialog/ @dunky.dev/dom-dialog | +- focus-trap/ @dunky.dev/dom-focus-trap | +- scroll-lock/ @dunky.dev/dom-scroll-lock | +- ... diff --git a/packages/dom/utils/dialog/README.md b/packages/dom/utils/dialog/README.md new file mode 100644 index 0000000..3db0e7a --- /dev/null +++ b/packages/dom/utils/dialog/README.md @@ -0,0 +1,50 @@ +# @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. + +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/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..c985a29 --- /dev/null +++ b/packages/dom/utils/dialog/src/index.ts @@ -0,0 +1,2 @@ +export { registerDialog, isTopmostDialog, type DialogLayer } from './stack' +export { getInitialFocus } from './get-initial-focus' 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/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/react/dialog/package.json b/packages/react/dialog/package.json index f683914..33a9002 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@dunky.dev/dialog": "workspace:*", + "@dunky.dev/dom-dialog": "workspace:*", "@dunky.dev/react-state-machine": "^0.1.0", "@dunky.dev/react-use-focus-trap": "workspace:*", "@dunky.dev/react-use-scroll-lock": "workspace:*" diff --git a/packages/react/dialog/src/context.ts b/packages/react/dialog/src/context.ts index eec5b66..149635a 100644 --- a/packages/react/dialog/src/context.ts +++ b/packages/react/dialog/src/context.ts @@ -1,4 +1,4 @@ -import { createContext, useContext, type Context } from 'react' +import { createContext, useContext, type Context, type RefObject } from 'react' import type { DialogApi, DialogMachine } from '@dunky.dev/dialog' export interface DialogContextValue { @@ -11,6 +11,10 @@ export interface DialogContextValue { // Content scopes the scroll lock to it. The root provides null; Portal // re-provides the context with the field filled in. container: HTMLElement | null + // The rendered Backdrop element, shared because Backdrop and Content are + // sibling parts: Content's stack entry excepts its own backdrop from the + // containment so it stays pressable while its dialog is topmost. + backdropRef: RefObject } export const DialogContext: Context = createContext< diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index 5d2a7e6..c2502b2 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -16,10 +16,9 @@ import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' import type { DialogOptions } from '@dunky.dev/dialog' +import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' import { mergeProps, normalize } from '@dunky.dev/react-state-machine' import { DialogContext, useDialogContext } from './context' -import { getInitialFocus } from './utils/get-initial-focus' -import { isTopmostDialog, registerDialog } from './utils/stack' import { useDialog } from './use-dialog' // Explicit so the exports satisfy --isolatedDeclarations (a bare forwardRef @@ -38,8 +37,9 @@ export const Dialog: ((props: DialogProps) => ReactNode) & Parts = ({ children, // Nesting derives from the parent dialog's context (undefined = top-level). const depth = (useContext(DialogContext)?.depth ?? 0) + 1 const { api, machine } = useDialog(options) + const backdropRef = useRef(null) return ( - + {children} ) @@ -93,7 +93,8 @@ export const Backdrop: PartComponent = forw HTMLDivElement, DialogBackdropProps >((props, forwardedRef) => { - const { api, machine } = useDialogContext() + const { api, machine, backdropRef } = useDialogContext() + useImperativeHandle(forwardedRef, () => backdropRef.current as HTMLDivElement) const { onClick, ...bindings } = normalize(api.parts.backdrop) as { onClick?: (event: MouseEvent) => void } & Record @@ -109,7 +110,7 @@ export const Backdrop: PartComponent = forw // Only a modal dialog dims the page — non-modal coexists with it. if (!machine.context.modal) return null - return
+ return
}) // ============================================================================= @@ -156,7 +157,7 @@ export const Content: PartComponent = for HTMLDialogElement, DialogContentProps >(({ initialFocus, ...props }, forwardedRef) => { - const { api, machine, depth, container } = useDialogContext() + const { api, machine, depth, container, backdropRef } = useDialogContext() const contentRef = useRef(null) useImperativeHandle(forwardedRef, () => contentRef.current as HTMLDialogElement) const initialFocusRef = useRef(initialFocus) @@ -176,6 +177,7 @@ export const Content: PartComponent = for depth, element: content, modal: machine.context.modal, + backdrop: () => backdropRef.current, }) // preventScroll everywhere: the scroll lock already froze the surface, so @@ -190,7 +192,7 @@ export const Content: PartComponent = for unregister() if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) } - }, [machine, depth]) + }, [machine, depth, backdropRef]) // Content only mounts while open, so the lock spans exactly the open state. // A scoped dialog locks its portal container; a page dialog locks the body. diff --git a/packages/react/dialog/src/effects.ts b/packages/react/dialog/src/effects.ts index 69d3b30..1f49690 100644 --- a/packages/react/dialog/src/effects.ts +++ b/packages/react/dialog/src/effects.ts @@ -1,7 +1,6 @@ import type { ComponentEffect } from '@dunky.dev/react-state-machine' import type { DialogMachine, DialogOptions } from '@dunky.dev/dialog' - -import { isTopmostDialog } from './utils/stack' +import { isTopmostDialog } from '@dunky.dev/dom-dialog' // Substrate effects: prop-driven or document-level work the machine can't own. // useMachine runs one useEffect per entry, keyed on the listed prop deps. diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx index 97da1c1..ee55bfd 100644 --- a/packages/react/dialog/tests/dialog.test.tsx +++ b/packages/react/dialog/tests/dialog.test.tsx @@ -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()) @@ -381,13 +394,13 @@ describe('Dialog', () => { const NestedDialog = (props: DialogProps) => ( - + Outer - + Inner @@ -425,6 +438,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..5085e1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,8 @@ importers: specifier: ^0.1.0 version: 0.1.0 + packages/dom/utils/dialog: {} + packages/dom/utils/focus-trap: {} packages/dom/utils/scroll-lock: {} @@ -99,6 +101,9 @@ importers: '@dunky.dev/dialog': specifier: workspace:* version: link:../../core/dialog + '@dunky.dev/dom-dialog': + specifier: workspace:* + version: link:../../dom/utils/dialog '@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..828b25c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "@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-scroll-lock": ["./packages/dom/utils/scroll-lock/src"], "@dunky.dev/react-use-focus-trap": ["./packages/react/hooks/use-focus-trap/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index e2e164e..fde0dbf 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ workspace: [ 'packages/core/dialog', 'packages/core/utils/controllable', + 'packages/dom/utils/dialog', 'packages/dom/utils/focus-trap', 'packages/dom/utils/scroll-lock', 'packages/react/dialog', From f4628e733f657695099b54991bd29c0487293557 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 11:24:11 +0200 Subject: [PATCH 02/13] feat(dialog): exit-animation support via a closing state in the core machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `animated` dialog closes through a new `closing` state — the exit window between the close intent and the visual leaving the screen. The state lives in the shared machine so reopening mid-exit is a named transition instead of a substrate-side unmount 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. The DOM half ships framework-free in @dunky.dev/dom-dialog: watchExitAnimation (transitionend/animationend on the element carrying data-state, prefers-reduced-motion skip, fallback ceiling) and hideExitingLayer (inert on the exiting layer). The React binding gates Portal on the new api.mounted and keys the stack/focus lifecycle on the machine's open state instead of mount/unmount. Co-Authored-By: Claude Fable 5 --- .changeset/dialog-exit-animation.md | 32 +++++ packages/core/dialog/SPEC.md | 34 +++-- packages/core/dialog/src/connect.ts | 9 +- packages/core/dialog/src/machine.ts | 26 +++- packages/core/dialog/src/types.ts | 15 ++- packages/core/dialog/tests/machine.test.ts | 68 ++++++++++ packages/dom/utils/dialog/README.md | 8 ++ .../utils/dialog/src/hide-exiting-layer.ts | 38 ++++++ packages/dom/utils/dialog/src/index.ts | 2 + .../utils/dialog/src/watch-exit-animation.ts | 49 ++++++++ packages/dom/utils/dialog/tests/exit.test.ts | 118 ++++++++++++++++++ packages/react/dialog/SPEC.md | 15 ++- packages/react/dialog/src/dialog.tsx | 46 +++++-- packages/react/dialog/tests/dialog.test.tsx | 52 ++++++++ 14 files changed, 483 insertions(+), 29 deletions(-) create mode 100644 .changeset/dialog-exit-animation.md create mode 100644 packages/dom/utils/dialog/src/hide-exiting-layer.ts create mode 100644 packages/dom/utils/dialog/src/watch-exit-animation.ts create mode 100644 packages/dom/utils/dialog/tests/exit.test.ts 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/packages/core/dialog/SPEC.md b/packages/core/dialog/SPEC.md index 97afa7e..337a6f9 100644 --- a/packages/core/dialog/SPEC.md +++ b/packages/core/dialog/SPEC.md @@ -76,10 +76,18 @@ it, and the stack unwinds one layer at a time. The full contract is ## 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 +191,13 @@ 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`) | 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 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..d809a99 100644 --- a/packages/core/dialog/src/connect.ts +++ b/packages/core/dialog/src/connect.ts @@ -29,6 +29,10 @@ 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 @@ -51,7 +55,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,6 +69,7 @@ export const dialogConnect: Connect< return { open, + mounted: state !== 'closed', role: context.role, ids, setOpen(next) { diff --git a/packages/core/dialog/src/machine.ts b/packages/core/dialog/src/machine.ts index 3b4a686..6fd5e53 100644 --- a/packages/core/dialog/src/machine.ts +++ b/packages/core/dialog/src/machine.ts @@ -32,6 +32,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, @@ -63,15 +67,27 @@ 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' }), + '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..b217424 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' @@ -44,13 +48,16 @@ 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: 'exit.complete' } | ControlledSync | { type: 'part.presence'; part: DialogPart; present: boolean } @@ -87,4 +94,8 @@ export interface DialogOptions extends DialogCallbacks { /** Whether pressing the backdrop closes the dialog. * @default true — false when `role="alertdialog"` */ closeOnInteractOutside?: 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..eca2327 100644 --- a/packages/core/dialog/tests/machine.test.ts +++ b/packages/core/dialog/tests/machine.test.ts @@ -242,6 +242,74 @@ describe('dialog connect — reactions', () => { }) }) +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 index 3db0e7a..0d40efd 100644 --- a/packages/dom/utils/dialog/README.md +++ b/packages/dom/utils/dialog/README.md @@ -14,6 +14,14 @@ what every substrate's dialog must agree on: - **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 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/dom/utils/dialog/src/index.ts b/packages/dom/utils/dialog/src/index.ts index c985a29..e07bf03 100644 --- a/packages/dom/utils/dialog/src/index.ts +++ b/packages/dom/utils/dialog/src/index.ts @@ -1,2 +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/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/react/dialog/SPEC.md b/packages/react/dialog/SPEC.md index 37c5d67..53e045c 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,16 @@ 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. - Everything ships headless, per the core contract's [Internals](../../core/dialog/SPEC.md#internals). @@ -69,6 +81,7 @@ 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. | | `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. | diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index c2502b2..1ffa0a0 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -16,7 +16,13 @@ import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' import type { DialogOptions } from '@dunky.dev/dialog' -import { getInitialFocus, isTopmostDialog, registerDialog } from '@dunky.dev/dom-dialog' +import { + getInitialFocus, + hideExitingLayer, + isTopmostDialog, + registerDialog, + watchExitAnimation, +} from '@dunky.dev/dom-dialog' import { mergeProps, normalize } from '@dunky.dev/react-state-machine' import { DialogContext, useDialogContext } from './context' import { useDialog } from './use-dialog' @@ -72,7 +78,9 @@ export interface DialogPortalProps { export const Portal = ({ children, container }: DialogPortalProps): ReactNode => { const context = useDialogContext() - if (!context.api.open || typeof document === 'undefined') return null + // `mounted`, not `open`: an animated dialog stays in the tree through + // `closing` so its exit visual can play before everything unmounts. + if (!context.api.mounted || typeof document === 'undefined') return null // Re-provide the context with the scoped container (null = page body) so // Content locks the right scroll surface. return createPortal( @@ -163,13 +171,15 @@ export const Content: PartComponent = for const initialFocusRef = useRef(initialFocus) initialFocusRef.current = initialFocus - // Content only mounts while open, so mount/unmount ARE the open/close edges. + // The machine's `open` state is the edge, not mount/unmount — an animated + // dialog stays mounted through `closing`, and the stack, containment, and + // focus must release the moment the exit starts, not when it finishes. // One effect keeps the ordering right both ways: the stack joins before focus // moves in, and on close it must release the layers beneath (un-inert them) // before focus can move back out to one of them. useEffect(() => { const content = contentRef.current - if (content === null) return + if (!api.open || content === null) return const previous = document.activeElement const unregister = registerDialog({ @@ -192,10 +202,29 @@ export const Content: PartComponent = for unregister() if (previous instanceof HTMLElement) previous.focus({ preventScroll: true }) } - }, [machine, depth, backdropRef]) + }, [api.open, machine, depth, backdropRef]) + + // The exit window: Content rendered while not open only happens in + // `closing`. The layer has already released everything above, so hide the + // still-painting layer from interaction and report when its visual is done; + // the effect's cleanup is the reopen interrupt (and final unmount) undoing + // both. + useEffect(() => { + const content = contentRef.current + if (api.open || content === null) return + + const undoHide = hideExitingLayer(content, container ?? document.body, backdropRef.current) + const cancelWatch = watchExitAnimation(content, () => machine.send({ type: 'exit.complete' })) + return () => { + cancelWatch() + undoHide() + } + }, [api.open, machine, container, backdropRef]) - // Content only mounts while open, so the lock spans exactly the open state. - // A scoped dialog locks its portal container; a page dialog locks the body. + // The lock spans the whole mount — through `closing` too: releasing it + // mid-exit would bring the scrollbar back and reflow the page under the + // still-painting layer. A scoped dialog locks its portal container; a page + // dialog locks the body. useScrollLock(machine.context.modal, container) useFocusTrap(contentRef, { @@ -210,7 +239,8 @@ export const Content: PartComponent = for const merged = mergeProps(props as Record, { ...normalize(api.parts.content), // The native is display:none without `open`; Content only mounts - // while the dialog is open, so the attribute is unconditionally true. + // while the dialog occupies the tree (open or mid-exit), so the attribute + // is unconditionally true. open: true, }) diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx index ee55bfd..9dd3fd1 100644 --- a/packages/react/dialog/tests/dialog.test.tsx +++ b/packages/react/dialog/tests/dialog.test.tsx @@ -390,6 +390,58 @@ describe('Dialog', () => { }) }) + 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) => ( From f0d5ca4432774f5f88c1f0cc54ad7410a3c7d2fb Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 12:41:17 +0200 Subject: [PATCH 03/13] =?UTF-8?q?feat(dialog):=20closeOnBack=20=E2=80=94?= =?UTF-8?q?=20the=20host's=20Back=20navigation=20closes=20the=20open=20dia?= =?UTF-8?q?log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An opt-in flag (default false): 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: a new onBackNavigation callback fires first (preventDefault vetoes), a controlled dialog only records the intent, a nested stack unwinds one layer per press, and it composes with `animated`. The decision (gate, veto, controlled fork) lives once in the core connect's backNavigate; substrates wire only their host mechanics. The web half ships framework-free in @dunky.dev/dom-dialog: interceptBackNavigation plants a guard entry in the session history, re-arms it on a declined close, and consumes a still-current entry when the dialog closes any other way — deferring the consume a microtask and adopting an orphaned entry in place on synchronous release + re-register (StrictMode's double-invoked effects), because a history traversal queued by history.back() is not reliably delivered once another entry is pushed before it lands. Co-Authored-By: Claude Fable 5 --- .changeset/dialog-close-on-back.md | 30 +++++ packages/core/dialog/SPEC.md | 32 +++-- packages/core/dialog/src/connect.ts | 19 +++ packages/core/dialog/src/index.ts | 1 + packages/core/dialog/src/machine.ts | 3 + packages/core/dialog/src/types.ts | 17 +++ packages/core/dialog/tests/machine.test.ts | 46 +++++++ packages/dom/utils/dialog/README.md | 7 ++ packages/dom/utils/dialog/src/index.ts | 1 + .../dialog/src/intercept-back-navigation.ts | 113 ++++++++++++++++++ .../tests/intercept-back-navigation.test.ts | 95 +++++++++++++++ packages/react/dialog/SPEC.md | 8 ++ packages/react/dialog/src/dialog.tsx | 21 ++++ packages/react/dialog/tests/dialog.test.tsx | 42 +++++++ 14 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 .changeset/dialog-close-on-back.md create mode 100644 packages/dom/utils/dialog/src/intercept-back-navigation.ts create mode 100644 packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts diff --git a/.changeset/dialog-close-on-back.md b/.changeset/dialog-close-on-back.md new file mode 100644 index 0000000..84abc67 --- /dev/null +++ b/.changeset/dialog-close-on-back.md @@ -0,0 +1,30 @@ +--- +'@dunky.dev/dialog': minor +'@dunky.dev/react-dialog': minor +'@dunky.dev/dom-dialog': 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. + +On the web (`@dunky.dev/dom-dialog`'s `interceptBackNavigation`), 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/packages/core/dialog/SPEC.md b/packages/core/dialog/SPEC.md index 337a6f9..7d5b991 100644 --- a/packages/core/dialog/SPEC.md +++ b/packages/core/dialog/SPEC.md @@ -70,6 +70,17 @@ 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. @@ -191,13 +202,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 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. | +| 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 d809a99..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, @@ -36,6 +37,12 @@ export interface DialogApi { 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 @@ -76,6 +83,18 @@ export const dialogConnect: Connect< 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 6fd5e53..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 @@ -44,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', @@ -75,6 +77,7 @@ export function dialogMachine( target: exitTo, value: false, }), + 'history.back': intend('open', { guard: canCloseOnBack, target: exitTo, value: false }), 'controlled.sync': synced('open', { value: false, target: exitTo }), }, }, diff --git a/packages/core/dialog/src/types.ts b/packages/core/dialog/src/types.ts index b217424..34b72df 100644 --- a/packages/core/dialog/src/types.ts +++ b/packages/core/dialog/src/types.ts @@ -33,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. @@ -57,10 +58,19 @@ export type DialogMachineEvent = | { 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 @@ -68,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 } /** @@ -94,6 +106,11 @@ 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 */ diff --git a/packages/core/dialog/tests/machine.test.ts b/packages/core/dialog/tests/machine.test.ts index eca2327..3be44f6 100644 --- a/packages/core/dialog/tests/machine.test.ts +++ b/packages/core/dialog/tests/machine.test.ts @@ -242,6 +242,52 @@ 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 }) diff --git a/packages/dom/utils/dialog/README.md b/packages/dom/utils/dialog/README.md index 0d40efd..5bcb887 100644 --- a/packages/dom/utils/dialog/README.md +++ b/packages/dom/utils/dialog/README.md @@ -14,6 +14,13 @@ what every substrate's dialog must agree on: - **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. +- **Back navigation** — `interceptBackNavigation` plants a guard entry in the + session history so the host's Back closes the dialog instead of leaving the + page. Guards stack in open order, so a nested stack unwinds one layer per + press; a declined close (veto, controlled) re-arms the entry; releasing + consumes a still-current entry so it can't swallow the next Back, and a + synchronous release + re-register adopts the entry in place — no traversal, + no race. - **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 diff --git a/packages/dom/utils/dialog/src/index.ts b/packages/dom/utils/dialog/src/index.ts index e07bf03..9ce7f89 100644 --- a/packages/dom/utils/dialog/src/index.ts +++ b/packages/dom/utils/dialog/src/index.ts @@ -2,3 +2,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' +export { interceptBackNavigation } from './intercept-back-navigation' diff --git a/packages/dom/utils/dialog/src/intercept-back-navigation.ts b/packages/dom/utils/dialog/src/intercept-back-navigation.ts new file mode 100644 index 0000000..a589d85 --- /dev/null +++ b/packages/dom/utils/dialog/src/intercept-back-navigation.ts @@ -0,0 +1,113 @@ +// Marks a dialog's guard entry in the session history; the value says which +// interceptor owns the entry. +const STATE_KEY = 'dunky.dialog.back' + +interface BackGuard { + id: number + onBack: () => boolean +} + +// One shared registry + one popstate listener across every dialog (mirroring +// the layer stack): 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 a +// nested stack unwind one layer per press with no cross-dialog 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 dialog 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 closes the + * dialog instead of leaving the page. `onBack` fires when the user pops the + * entry and returns whether the dialog actually closed — a decline re-arms + * the guard. The returned release (for a dialog 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 and an orphaned-but-current entry is + * adopted (rewritten in place) by the next interceptor: a synchronous + * release -> re-register — StrictMode's double-invoked effect, a reopen in + * the same commit — nets out to zero traversals. That matters because a + * traversal queued by `history.back()` is not reliably delivered once + * another entry is pushed before it lands; never 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/dialog/tests/intercept-back-navigation.test.ts b/packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts new file mode 100644 index 0000000..2d5f530 --- /dev/null +++ b/packages/dom/utils/dialog/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-dialog' + +// 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 53e045c..918b5e8 100644 --- a/packages/react/dialog/SPEC.md +++ b/packages/react/dialog/SPEC.md @@ -61,6 +61,12 @@ React-specific notes on top of the core contract: 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). @@ -82,6 +88,8 @@ The root: owns open/close state, renders no DOM. Accepts the core | `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. | diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index 1ffa0a0..ad94e9f 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -19,6 +19,7 @@ import type { DialogOptions } from '@dunky.dev/dialog' import { getInitialFocus, hideExitingLayer, + interceptBackNavigation, isTopmostDialog, registerDialog, watchExitAnimation, @@ -44,6 +45,26 @@ export const Dialog: ((props: DialogProps) => ReactNode) & Parts = ({ children, const depth = (useContext(DialogContext)?.depth ?? 0) + 1 const { api, machine } = useDialog(options) const backdropRef = useRef(null) + + // Read through a ref so the history guard's lifecycle follows the open + // state alone: re-arming on every render (fresh callback identities) would + // churn real session-history entries. + const apiRef = useRef(api) + apiRef.current = api + + // closeOnBack: while open, a guard entry in the session history turns the + // host's Back into a dismissal instead of a navigation. Every decision + // (gate, veto, controlled) lives in the core's backNavigate; this effect + // only wires the web mechanics. It lives on the root — the guard concerns + // the dialog's openness, not any rendered part. + useEffect(() => { + if (!api.open || !machine.context.closeOnBack) return + return interceptBackNavigation(() => { + apiRef.current.backNavigate() + return !machine.matches('open') + }) + }, [api.open, machine]) + return ( {children} diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx index 9dd3fd1..48fd63f 100644 --- a/packages/react/dialog/tests/dialog.test.tsx +++ b/packages/react/dialog/tests/dialog.test.tsx @@ -390,6 +390,48 @@ 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(() => { From a53256560df06e52ed0554a4d78ed5900296a813 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 13:40:51 +0200 Subject: [PATCH 04/13] docs(dialog): nested story drives its controlled layers at the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nested story predates the controlled-open rework (onOpenChange reports actual changes only): its three layers are controlled but wired no dismissal handlers, so under the reworked contract Trigger, Close, and Escape only recorded intent and the stack never moved. Wire each layer the way the contract prescribes — the consumer's own handlers on Trigger/Close, plus onEscapeKeyDown/onInteractOutside driving the prop — and cover that consumer pattern with a test so the story's shape can't silently rot again. Co-Authored-By: Claude Fable 5 --- .../react/dialog/stories/dialog.stories.tsx | 47 ++++++++++++---- packages/react/dialog/tests/dialog.test.tsx | 56 ++++++++++++++++++- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/react/dialog/stories/dialog.stories.tsx b/packages/react/dialog/stories/dialog.stories.tsx index 7607a85..1e91422 100644 --- a/packages/react/dialog/stories/dialog.stories.tsx +++ b/packages/react/dialog/stories/dialog.stories.tsx @@ -298,7 +298,11 @@ 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 own handlers on Trigger/Close, 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 +313,13 @@ const NestedDialogs = () => { setOuterOpen(false) } return ( - - Open outer + setOuterOpen(false)} + onInteractOutside={() => setOuterOpen(false)} + > + setOuterOpen(true)}>Open outer @@ -320,8 +329,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 +345,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 +365,24 @@ const NestedDialogs = () => {
- Close + setInnermostOpen(false)}> + Close +
- +
+ setInnerOpen(false)}>Close +
- +
+ setOuterOpen(false)}>Close +
diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx index 48fd63f..f24ba93 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' @@ -180,6 +180,60 @@ describe('Dialog', () => { expect(onOpenChange).toHaveBeenCalledTimes(1) }) + // The controlled contract's consumer side (the nested story's pattern): the + // dialog never moves on its own, so the consumer's own handlers on + // Trigger/Close 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() From a66ff004d167b0ac047145bd14beda9fa92ea81e Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 13:43:14 +0200 Subject: [PATCH 05/13] refactor(dialog): extract the back guard into @dunky.dev/dom-back-navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-history guard is not dialog behavior — any overlaid layer (drawer, sheet, menu) can let Back dismiss it — and it imports nothing from the dialog's DOM package, so it moves to its own util per the one-package-per-util rule: packages/dom/utils/back-navigation. The state key and wording drop their dialog references; behavior is unchanged, and @dunky.dev/react-dialog consumes the new package. Co-Authored-By: Claude Fable 5 --- .changeset/dialog-close-on-back.md | 6 ++- ARCHITECTURE.md | 1 + packages/dom/utils/back-navigation/README.md | 41 +++++++++++++++++++ .../dom/utils/back-navigation/package.json | 36 ++++++++++++++++ .../dom/utils/back-navigation/src/index.ts | 1 + .../src/intercept-back-navigation.ts | 30 +++++++------- .../tests/intercept-back-navigation.test.ts | 2 +- packages/dom/utils/dialog/README.md | 7 ---- packages/dom/utils/dialog/src/index.ts | 1 - packages/react/dialog/package.json | 1 + packages/react/dialog/src/dialog.tsx | 2 +- pnpm-lock.yaml | 5 +++ tsconfig.json | 1 + tsdown.config.ts | 1 + 14 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 packages/dom/utils/back-navigation/README.md create mode 100644 packages/dom/utils/back-navigation/package.json create mode 100644 packages/dom/utils/back-navigation/src/index.ts rename packages/dom/utils/{dialog => back-navigation}/src/intercept-back-navigation.ts (77%) rename packages/dom/utils/{dialog => back-navigation}/tests/intercept-back-navigation.test.ts (97%) diff --git a/.changeset/dialog-close-on-back.md b/.changeset/dialog-close-on-back.md index 84abc67..622a309 100644 --- a/.changeset/dialog-close-on-back.md +++ b/.changeset/dialog-close-on-back.md @@ -1,7 +1,7 @@ --- '@dunky.dev/dialog': minor '@dunky.dev/react-dialog': minor -'@dunky.dev/dom-dialog': minor +'@dunky.dev/dom-back-navigation': minor --- Add `closeOnBack` — the host's Back navigation closes the open dialog instead @@ -21,7 +21,9 @@ 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. -On the web (`@dunky.dev/dom-dialog`'s `interceptBackNavigation`), opening +The web mechanics ship as their own framework-free util, +`@dunky.dev/dom-back-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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa273e2..f995a70 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,6 +28,7 @@ packages/ | +- dom/ | +- utils/ framework-free DOM utilities, one package per util +| +- back-navigation/ @dunky.dev/dom-back-navigation | +- dialog/ @dunky.dev/dom-dialog | +- focus-trap/ @dunky.dev/dom-focus-trap | +- scroll-lock/ @dunky.dev/dom-scroll-lock diff --git a/packages/dom/utils/back-navigation/README.md b/packages/dom/utils/back-navigation/README.md new file mode 100644 index 0000000..681dcb2 --- /dev/null +++ b/packages/dom/utils/back-navigation/README.md @@ -0,0 +1,41 @@ +# @dunky.dev/dom-back-navigation + +Framework-free session-history guard. `interceptBackNavigation` plants a +guard entry in the session history so the host's Back dismisses a layer — a +dialog, drawer, sheet, anything overlaid on the page — instead of leaving it, +the pattern mobile users expect from a full-screen overlay. + +Guards stack in open order and one shared popstate listener arbitrates: a +Back press pops exactly one entry, so stacked layers unwind one per press +with no cross-layer bookkeeping. A declined close (a veto, or a controlled +layer that decides later) re-arms the entry; releasing consumes a +still-current entry so it can't swallow the next Back; and a synchronous +release + re-register (StrictMode's double-invoked effects, a same-commit +reopen) adopts the entry in place — no history traversal is queued, so there +is no race to compensate for. An entry buried under later in-app navigation +is unreachable and left alone. + +Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog`'s +`closeOnBack` — so every framework inherits identical Back semantics. + +## Install + +```sh +npm install @dunky.dev/dom-back-navigation +``` + +## Usage + +```ts +import { interceptBackNavigation } from '@dunky.dev/dom-back-navigation' + +// On open: plant the guard. `onBack` returns whether the layer closed — +// returning false (vetoed, deferred) re-arms the guard for the next press. +const release = interceptBackNavigation(() => { + requestClose() + return isClosed() +}) + +// On close by any other means: consume the guard entry. +release() +``` diff --git a/packages/dom/utils/back-navigation/package.json b/packages/dom/utils/back-navigation/package.json new file mode 100644 index 0000000..1624deb --- /dev/null +++ b/packages/dom/utils/back-navigation/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dunky.dev/dom-back-navigation", + "version": "0.0.0", + "description": "Framework-free session-history guard: 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/back-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/back-navigation/src/index.ts b/packages/dom/utils/back-navigation/src/index.ts new file mode 100644 index 0000000..9918093 --- /dev/null +++ b/packages/dom/utils/back-navigation/src/index.ts @@ -0,0 +1 @@ +export { interceptBackNavigation } from './intercept-back-navigation' diff --git a/packages/dom/utils/dialog/src/intercept-back-navigation.ts b/packages/dom/utils/back-navigation/src/intercept-back-navigation.ts similarity index 77% rename from packages/dom/utils/dialog/src/intercept-back-navigation.ts rename to packages/dom/utils/back-navigation/src/intercept-back-navigation.ts index a589d85..ff102bf 100644 --- a/packages/dom/utils/dialog/src/intercept-back-navigation.ts +++ b/packages/dom/utils/back-navigation/src/intercept-back-navigation.ts @@ -1,17 +1,18 @@ -// Marks a dialog's guard entry in the session history; the value says which +// Marks a layer's guard entry in the session history; the value says which // interceptor owns the entry. -const STATE_KEY = 'dunky.dialog.back' +const STATE_KEY = 'dunky.back' interface BackGuard { id: number onBack: () => boolean } -// One shared registry + one popstate listener across every dialog (mirroring -// the layer stack): 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 a -// nested stack unwind one layer per press with no cross-dialog bookkeeping. +// 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 @@ -61,7 +62,7 @@ function onPopState(): void { guards.pop() continue } - // Declined — vetoed, or a controlled dialog that hasn't followed yet: + // 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 @@ -70,12 +71,13 @@ function onPopState(): void { } /** - * Plants a guard entry in the session history so the host's Back closes the - * dialog instead of leaving the page. `onBack` fires when the user pops the - * entry and returns whether the dialog actually closed — a decline re-arms - * the guard. The returned release (for a dialog 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. + * 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 and an orphaned-but-current entry is * adopted (rewritten in place) by the next interceptor: a synchronous diff --git a/packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts b/packages/dom/utils/back-navigation/tests/intercept-back-navigation.test.ts similarity index 97% rename from packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts rename to packages/dom/utils/back-navigation/tests/intercept-back-navigation.test.ts index 2d5f530..c5f977b 100644 --- a/packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts +++ b/packages/dom/utils/back-navigation/tests/intercept-back-navigation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest' -import { interceptBackNavigation } from '@dunky.dev/dom-dialog' +import { interceptBackNavigation } from '@dunky.dev/dom-back-navigation' // jsdom's history traversal is asynchronous: back() returns immediately and // the state change + popstate land on a later task — await the event itself. diff --git a/packages/dom/utils/dialog/README.md b/packages/dom/utils/dialog/README.md index 5bcb887..0d40efd 100644 --- a/packages/dom/utils/dialog/README.md +++ b/packages/dom/utils/dialog/README.md @@ -14,13 +14,6 @@ what every substrate's dialog must agree on: - **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. -- **Back navigation** — `interceptBackNavigation` plants a guard entry in the - session history so the host's Back closes the dialog instead of leaving the - page. Guards stack in open order, so a nested stack unwinds one layer per - press; a declined close (veto, controlled) re-arms the entry; releasing - consumes a still-current entry so it can't swallow the next Back, and a - synchronous release + re-register adopts the entry in place — no traversal, - no race. - **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 diff --git a/packages/dom/utils/dialog/src/index.ts b/packages/dom/utils/dialog/src/index.ts index 9ce7f89..e07bf03 100644 --- a/packages/dom/utils/dialog/src/index.ts +++ b/packages/dom/utils/dialog/src/index.ts @@ -2,4 +2,3 @@ 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' -export { interceptBackNavigation } from './intercept-back-navigation' diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json index 33a9002..b878628 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@dunky.dev/dialog": "workspace:*", + "@dunky.dev/dom-back-navigation": "workspace:*", "@dunky.dev/dom-dialog": "workspace:*", "@dunky.dev/react-state-machine": "^0.1.0", "@dunky.dev/react-use-focus-trap": "workspace:*", diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index ad94e9f..d22cacf 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -16,10 +16,10 @@ import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' import type { DialogOptions } from '@dunky.dev/dialog' +import { interceptBackNavigation } from '@dunky.dev/dom-back-navigation' import { getInitialFocus, hideExitingLayer, - interceptBackNavigation, isTopmostDialog, registerDialog, watchExitAnimation, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5085e1c..c1744d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,8 @@ importers: specifier: ^0.1.0 version: 0.1.0 + packages/dom/utils/back-navigation: {} + packages/dom/utils/dialog: {} packages/dom/utils/focus-trap: {} @@ -101,6 +103,9 @@ importers: '@dunky.dev/dialog': specifier: workspace:* version: link:../../core/dialog + '@dunky.dev/dom-back-navigation': + specifier: workspace:* + version: link:../../dom/utils/back-navigation '@dunky.dev/dom-dialog': specifier: workspace:* version: link:../../dom/utils/dialog diff --git a/tsconfig.json b/tsconfig.json index 828b25c..989a1c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "@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-back-navigation": ["./packages/dom/utils/back-navigation/src"], "@dunky.dev/dom-dialog": ["./packages/dom/utils/dialog/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], "@dunky.dev/dom-scroll-lock": ["./packages/dom/utils/scroll-lock/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index fde0dbf..464e480 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ workspace: [ 'packages/core/dialog', 'packages/core/utils/controllable', + 'packages/dom/utils/back-navigation', 'packages/dom/utils/dialog', 'packages/dom/utils/focus-trap', 'packages/dom/utils/scroll-lock', From e0c9fa578cd2fa83d4e91077c7846327287c9fec Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 13:51:13 +0200 Subject: [PATCH 06/13] refactor(dialog): rename the back-guard package to @dunky.dev/dom-navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/dom/utils/navigation — a home for browser-navigation helpers in general, so future utils (traversal, unload guards, Navigation API) land beside the back guard instead of forcing another package. Co-Authored-By: Claude Fable 5 --- .changeset/dialog-close-on-back.md | 4 ++-- ARCHITECTURE.md | 2 +- .../utils/{back-navigation => navigation}/README.md | 12 ++++++++---- .../{back-navigation => navigation}/package.json | 6 +++--- .../{back-navigation => navigation}/src/index.ts | 0 .../src/intercept-back-navigation.ts | 0 .../tests/intercept-back-navigation.test.ts | 2 +- packages/react/dialog/package.json | 2 +- packages/react/dialog/src/dialog.tsx | 2 +- pnpm-lock.yaml | 10 +++++----- tsconfig.json | 2 +- tsdown.config.ts | 2 +- 12 files changed, 24 insertions(+), 20 deletions(-) rename packages/dom/utils/{back-navigation => navigation}/README.md (85%) rename packages/dom/utils/{back-navigation => navigation}/package.json (72%) rename packages/dom/utils/{back-navigation => navigation}/src/index.ts (100%) rename packages/dom/utils/{back-navigation => navigation}/src/intercept-back-navigation.ts (100%) rename packages/dom/utils/{back-navigation => navigation}/tests/intercept-back-navigation.test.ts (97%) diff --git a/.changeset/dialog-close-on-back.md b/.changeset/dialog-close-on-back.md index 622a309..b38ed08 100644 --- a/.changeset/dialog-close-on-back.md +++ b/.changeset/dialog-close-on-back.md @@ -1,7 +1,7 @@ --- '@dunky.dev/dialog': minor '@dunky.dev/react-dialog': minor -'@dunky.dev/dom-back-navigation': minor +'@dunky.dev/dom-navigation': minor --- Add `closeOnBack` — the host's Back navigation closes the open dialog instead @@ -22,7 +22,7 @@ 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-back-navigation` (`interceptBackNavigation`) — a session +`@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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f995a70..38e7991 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,9 +28,9 @@ packages/ | +- dom/ | +- utils/ framework-free DOM utilities, one package per util -| +- back-navigation/ @dunky.dev/dom-back-navigation | +- dialog/ @dunky.dev/dom-dialog | +- focus-trap/ @dunky.dev/dom-focus-trap +| +- navigation/ @dunky.dev/dom-navigation | +- scroll-lock/ @dunky.dev/dom-scroll-lock | +- ... | diff --git a/packages/dom/utils/back-navigation/README.md b/packages/dom/utils/navigation/README.md similarity index 85% rename from packages/dom/utils/back-navigation/README.md rename to packages/dom/utils/navigation/README.md index 681dcb2..8445bdc 100644 --- a/packages/dom/utils/back-navigation/README.md +++ b/packages/dom/utils/navigation/README.md @@ -1,6 +1,10 @@ -# @dunky.dev/dom-back-navigation +# @dunky.dev/dom-navigation -Framework-free session-history guard. `interceptBackNavigation` plants a +Framework-free browser-navigation helpers. + +## Back navigation + +`interceptBackNavigation` plants a guard entry in the session history so the host's Back dismisses a layer — a dialog, drawer, sheet, anything overlaid on the page — instead of leaving it, the pattern mobile users expect from a full-screen overlay. @@ -21,13 +25,13 @@ Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog`'s ## Install ```sh -npm install @dunky.dev/dom-back-navigation +npm install @dunky.dev/dom-navigation ``` ## Usage ```ts -import { interceptBackNavigation } from '@dunky.dev/dom-back-navigation' +import { interceptBackNavigation } from '@dunky.dev/dom-navigation' // On open: plant the guard. `onBack` returns whether the layer closed — // returning false (vetoed, deferred) re-arms the guard for the next press. diff --git a/packages/dom/utils/back-navigation/package.json b/packages/dom/utils/navigation/package.json similarity index 72% rename from packages/dom/utils/back-navigation/package.json rename to packages/dom/utils/navigation/package.json index 1624deb..06af306 100644 --- a/packages/dom/utils/back-navigation/package.json +++ b/packages/dom/utils/navigation/package.json @@ -1,12 +1,12 @@ { - "name": "@dunky.dev/dom-back-navigation", + "name": "@dunky.dev/dom-navigation", "version": "0.0.0", - "description": "Framework-free session-history guard: the host's Back dismisses a layer instead of leaving the page.", + "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/back-navigation" + "directory": "packages/dom/utils/navigation" }, "files": [ "dist" diff --git a/packages/dom/utils/back-navigation/src/index.ts b/packages/dom/utils/navigation/src/index.ts similarity index 100% rename from packages/dom/utils/back-navigation/src/index.ts rename to packages/dom/utils/navigation/src/index.ts diff --git a/packages/dom/utils/back-navigation/src/intercept-back-navigation.ts b/packages/dom/utils/navigation/src/intercept-back-navigation.ts similarity index 100% rename from packages/dom/utils/back-navigation/src/intercept-back-navigation.ts rename to packages/dom/utils/navigation/src/intercept-back-navigation.ts diff --git a/packages/dom/utils/back-navigation/tests/intercept-back-navigation.test.ts b/packages/dom/utils/navigation/tests/intercept-back-navigation.test.ts similarity index 97% rename from packages/dom/utils/back-navigation/tests/intercept-back-navigation.test.ts rename to packages/dom/utils/navigation/tests/intercept-back-navigation.test.ts index c5f977b..64d2432 100644 --- a/packages/dom/utils/back-navigation/tests/intercept-back-navigation.test.ts +++ b/packages/dom/utils/navigation/tests/intercept-back-navigation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest' -import { interceptBackNavigation } from '@dunky.dev/dom-back-navigation' +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. diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json index b878628..4fa628b 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -35,8 +35,8 @@ }, "dependencies": { "@dunky.dev/dialog": "workspace:*", - "@dunky.dev/dom-back-navigation": "workspace:*", "@dunky.dev/dom-dialog": "workspace:*", + "@dunky.dev/dom-navigation": "workspace:*", "@dunky.dev/react-state-machine": "^0.1.0", "@dunky.dev/react-use-focus-trap": "workspace:*", "@dunky.dev/react-use-scroll-lock": "workspace:*" diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index d22cacf..98bf9f7 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -16,7 +16,7 @@ import { useFocusTrap } from '@dunky.dev/react-use-focus-trap' import { useScrollLock } from '@dunky.dev/react-use-scroll-lock' import type { DialogOptions } from '@dunky.dev/dialog' -import { interceptBackNavigation } from '@dunky.dev/dom-back-navigation' +import { interceptBackNavigation } from '@dunky.dev/dom-navigation' import { getInitialFocus, hideExitingLayer, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1744d7..9f59242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,12 +66,12 @@ importers: specifier: ^0.1.0 version: 0.1.0 - packages/dom/utils/back-navigation: {} - packages/dom/utils/dialog: {} packages/dom/utils/focus-trap: {} + packages/dom/utils/navigation: {} + packages/dom/utils/scroll-lock: {} packages/react: @@ -103,12 +103,12 @@ importers: '@dunky.dev/dialog': specifier: workspace:* version: link:../../core/dialog - '@dunky.dev/dom-back-navigation': - specifier: workspace:* - version: link:../../dom/utils/back-navigation '@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 989a1c0..3ebacee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,9 +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-back-navigation": ["./packages/dom/utils/back-navigation/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 464e480..d64d16c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,9 +12,9 @@ export default defineConfig({ workspace: [ 'packages/core/dialog', 'packages/core/utils/controllable', - 'packages/dom/utils/back-navigation', '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', From 904956fd07c28aaf0dd4833473488ffb7337d930 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 14:03:13 +0200 Subject: [PATCH 07/13] docs(dialog): Close is the corner x only; action rows are consumer buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Dialog.Close parts in one dialog collide with the contract: every instance carries the same derived id, and the focus trap moves "the" Close (the first match — Cancel) to the cycle's end, so Tab entered the alert dialog at Delete before Cancel. Settle the position instead of patching around it: Close is the dialog's single dismissal affordance — the corner x, kept the cycle's last stop — and buttons that act (Cancel/Confirm/Delete) are the consumer's own, driving state in their natural Tab order. Both SPECs record it; the stories now model it, with the alert story controlled and starting focus on Cancel per the APG's least-destructive-action guidance. The separate close-button story folded into `standard`, which now carries the corner x itself. Co-Authored-By: Claude Fable 5 --- packages/core/dialog/SPEC.md | 8 +- packages/react/dialog/SPEC.md | 5 +- .../react/dialog/stories/dialog.stories.tsx | 111 +++++++++--------- packages/react/dialog/tests/dialog.test.tsx | 6 +- 4 files changed, 68 insertions(+), 62 deletions(-) diff --git a/packages/core/dialog/SPEC.md b/packages/core/dialog/SPEC.md index 7d5b991..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 diff --git a/packages/react/dialog/SPEC.md b/packages/react/dialog/SPEC.md index 918b5e8..a23d558 100644 --- a/packages/react/dialog/SPEC.md +++ b/packages/react/dialog/SPEC.md @@ -155,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. - @@ -300,9 +300,10 @@ 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. And a // controlled dialog never moves on its own: each layer decides its dismissals -// at the source — its own handlers on Trigger/Close, and the dismissal -// callbacks (`onEscapeKeyDown` / `onInteractOutside`) — per the controlled -// contract; `onOpenChange` only reports changes that actually happened. +// 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) @@ -365,23 +366,21 @@ const NestedDialogs = () => {
- setInnermostOpen(false)}> - Close - +
- setInnerOpen(false)}>Close +
- setOuterOpen(false)}>Close +
diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx index f24ba93..d82dbe3 100644 --- a/packages/react/dialog/tests/dialog.test.tsx +++ b/packages/react/dialog/tests/dialog.test.tsx @@ -180,9 +180,9 @@ describe('Dialog', () => { expect(onOpenChange).toHaveBeenCalledTimes(1) }) - // The controlled contract's consumer side (the nested story's pattern): the - // dialog never moves on its own, so the consumer's own handlers on - // Trigger/Close and the dismissal callbacks are what drive the prop. + // 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) From 65991ceac54956c4f8726d1b1010eb2cdaa2cc6d Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 16:05:04 +0200 Subject: [PATCH 08/13] Update ARCHITECTURE.md --- ARCHITECTURE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa273e2..8c30a8f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,7 +28,6 @@ packages/ | +- dom/ | +- utils/ framework-free DOM utilities, one package per util -| +- dialog/ @dunky.dev/dom-dialog | +- focus-trap/ @dunky.dev/dom-focus-trap | +- scroll-lock/ @dunky.dev/dom-scroll-lock | +- ... From 61cc700fd111e6aaa41e567c6846b9a021f2b43c Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 16:10:02 +0200 Subject: [PATCH 09/13] Update ARCHITECTURE.md --- ARCHITECTURE.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 38e7991..8c30a8f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -28,9 +28,7 @@ packages/ | +- dom/ | +- utils/ framework-free DOM utilities, one package per util -| +- dialog/ @dunky.dev/dom-dialog | +- focus-trap/ @dunky.dev/dom-focus-trap -| +- navigation/ @dunky.dev/dom-navigation | +- scroll-lock/ @dunky.dev/dom-scroll-lock | +- ... | From 888266d24379eb70075f46e15b283622824c945b Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 17:39:13 +0200 Subject: [PATCH 10/13] refactor(dialog): namespace the back-guard history state key The session-history marker becomes @dunky.back (was dunky.back), matching the @dunky namespace used elsewhere. Pre-release, so no compatibility concern. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dom/utils/navigation/src/intercept-back-navigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dom/utils/navigation/src/intercept-back-navigation.ts b/packages/dom/utils/navigation/src/intercept-back-navigation.ts index ff102bf..84d3651 100644 --- a/packages/dom/utils/navigation/src/intercept-back-navigation.ts +++ b/packages/dom/utils/navigation/src/intercept-back-navigation.ts @@ -1,6 +1,6 @@ // Marks a layer's guard entry in the session history; the value says which // interceptor owns the entry. -const STATE_KEY = 'dunky.back' +const STATE_KEY = '@dunky.back' interface BackGuard { id: number From f58ab104c915c5c47fbdb4dbca4d2599ddf88309 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 17:39:14 +0200 Subject: [PATCH 11/13] docs(dialog): document the reload caveat in the navigation README The guard entry survives a reload but the layer's open-state does not, so a transiently-opened layer leaves a dead same-URL entry and the first Back appears to do nothing. Document the URL-as-source-of-truth recipe for layers that must survive reload, and slim the README prose. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dom/utils/navigation/README.md | 64 +++++++++++++++---------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/packages/dom/utils/navigation/README.md b/packages/dom/utils/navigation/README.md index 8445bdc..88fb70f 100644 --- a/packages/dom/utils/navigation/README.md +++ b/packages/dom/utils/navigation/README.md @@ -2,44 +2,56 @@ Framework-free browser-navigation helpers. -## Back navigation - -`interceptBackNavigation` plants a -guard entry in the session history so the host's Back dismisses a layer — a -dialog, drawer, sheet, anything overlaid on the page — instead of leaving it, -the pattern mobile users expect from a full-screen overlay. - -Guards stack in open order and one shared popstate listener arbitrates: a -Back press pops exactly one entry, so stacked layers unwind one per press -with no cross-layer bookkeeping. A declined close (a veto, or a controlled -layer that decides later) re-arms the entry; releasing consumes a -still-current entry so it can't swallow the next Back; and a synchronous -release + re-register (StrictMode's double-invoked effects, a same-commit -reopen) adopts the entry in place — no history traversal is queued, so there -is no race to compensate for. An entry buried under later in-app navigation -is unreachable and left alone. - -Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog`'s -`closeOnBack` — so every framework inherits identical Back semantics. - ## Install ```sh npm install @dunky.dev/dom-navigation ``` -## Usage +## 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: plant the guard. `onBack` returns whether the layer closed — -// returning false (vetoed, deferred) re-arms the guard for the next press. +// On open — arm the guard. `onBack` returns whether the layer closed. const release = interceptBackNavigation(() => { - requestClose() - return isClosed() + close() + return true // return false to veto: the guard re-arms for the next Back }) -// On close by any other means: consume the guard entry. +// 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. From 0bc1253376fe5c9b3f9a4fe3ded67e4468738a00 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 17:39:15 +0200 Subject: [PATCH 12/13] docs(dialog): add a closeOnBack Storybook story A defaultOpen dialog with closeOnBack; an in-dialog button stands in for the browser's Back so the dismissal is demonstrable in the chrome-less canvas. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../react/dialog/stories/dialog.stories.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/react/dialog/stories/dialog.stories.tsx b/packages/react/dialog/stories/dialog.stories.tsx index 214d3c4..a44a56a 100644 --- a/packages/react/dialog/stories/dialog.stories.tsx +++ b/packages/react/dialog/stories/dialog.stories.tsx @@ -392,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. + +
+ +
+
+
+
+
+ ), +} From 966f4d84ff7a1f348b312423d6c121767608aa82 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 17:50:47 +0200 Subject: [PATCH 13/13] docs(dialog): drop the React reference from the agnostic back-guard doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The util is framework-free; its doc explained the deferred-consumption race in terms of StrictMode's double-invoked effect and a same-commit reopen. Restate it agnostically — a synchronous release then re-register in the same turn — so the substrate concern no longer leaks into the agnostic package. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../navigation/src/intercept-back-navigation.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/dom/utils/navigation/src/intercept-back-navigation.ts b/packages/dom/utils/navigation/src/intercept-back-navigation.ts index 84d3651..a12cf33 100644 --- a/packages/dom/utils/navigation/src/intercept-back-navigation.ts +++ b/packages/dom/utils/navigation/src/intercept-back-navigation.ts @@ -79,13 +79,14 @@ function onPopState(): void { * 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 and an orphaned-but-current entry is - * adopted (rewritten in place) by the next interceptor: a synchronous - * release -> re-register — StrictMode's double-invoked effect, a reopen in - * the same commit — nets out to zero traversals. That matters because a - * traversal queued by `history.back()` is not reliably delivered once - * another entry is pushed before it lands; never queuing one in that window - * removes the race instead of compensating for it. + * 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 }