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) => (
-
+
+ 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 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 = {
/>
- CancelSign in
@@ -245,10 +245,10 @@ export const trigger: StoryType = {
-
+
+ Closed by defaultOnly 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 = () => {
Close all
- setInnermostOpen(false)}>
- Close
-
+ setInnermostOpen(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.
+
+
+ window.history.back()}>Simulate browser Back
+
+
+
+
+
+ ),
+}
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 }