From f0d5ca4432774f5f88c1f0cc54ad7410a3c7d2fb Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 12:41:17 +0200 Subject: [PATCH 01/10] =?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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 61cc700fd111e6aaa41e567c6846b9a021f2b43c Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 16:10:02 +0200 Subject: [PATCH 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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 }