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