Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/dialog-forward-reopens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@dunky.dev/dom-navigation': minor
'@dunky.dev/dialog': minor
'@dunky.dev/react-dialog': minor
---

`closeOnBack` is now symmetric: the browser's Forward reopens what Back
closed. The history entry a Back press spends survives in the forward stack
and keeps marking the dialog's open ground — traversing forward into it
reopens the dialog, guarded again for the next Back. Reopening through the
trigger instead plants a fresh entry, exactly like navigating after a Back.
No new setting: back-close and forward-reopen are one behavior, so the
existing `closeOnBack` gates both.

The reopen follows the shared dismissal contract — a new
`onForwardNavigation` callback fires first and `preventDefault()` vetoes,
and a controlled dialog only records the intent:

```tsx
<Dialog
closeOnBack
onForwardNavigation={event => {
// e.g. decline the history-driven reopen while a form is mid-submit
if (submitting) event?.preventDefault?.()
}}
>
```

Under the hood, `interceptBackNavigation(onBack, onForward?)` grew the
optional second callback: a Back-closed guard parks instead of dropping, a
traversal re-entering its spent entry asks the layer to reopen, and the
guard re-arms on that entry in place. Layers that don't pass `onForward`
(the Vue dialog, for now) behave exactly as before.

Web-mechanics caveats, spec'd in the navigation util and the React dialog:
a controlled dialog's Back-close is completed by the consumer rather than
the press, so its entry is consumed and Forward has nothing to re-enter;
and the Forward watch lives in script, so it doesn't survive a reload.
38 changes: 22 additions & 16 deletions packages/core/dialog/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,16 @@ 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.
nested stack unwinds one layer per press. Back's mirror is Forward: on a host
whose forward navigation can re-enter what Back left (the web's forward
stack), traversing forward into the spent entry reopens the dialog — the
same `closeOnBack` setting gates it, `onForwardNavigation` fires first and
`preventDefault()` vetoes, and a controlled dialog only records the intent.
The substrate wires the host mechanics (the web plants a guard entry in the
session history; a native host wires its hardware back handler and has no
forward); a dialog closed any other way leaves no trace behind — its guard
entry is consumed, not left to swallow the next Back press, and there is
nothing for Forward to reopen.

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
Expand Down Expand Up @@ -206,14 +211,15 @@ 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`, `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. |
| 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. |
| `history.forward` is `history.back`'s mirror, gated by the same `closeOnBack` | Back-close and Forward-reopen are one feature — the openness tracking the history position — not two settings to drift apart. |
| History navigation reports through `backNavigate` / `forwardNavigate` on the api | The callback, veto, and controlled fork live once in the connect; only the host's traversal 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. |
35 changes: 25 additions & 10 deletions packages/core/dialog/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export interface DialogApi {
* only wires its host mechanics (a session-history guard entry on the web, a
* hardware back handler on native) to this call. */
backNavigate: () => void
/** Reports the host's Forward navigation re-entering the ground a
* Back-close left behind. `backNavigate`'s mirror, decided the same way:
* `onForwardNavigation` fires first (`preventDefault()` vetoes), the
* machine gates on `closeOnBack`, and the controlled contract applies. */
forwardNavigate: () => void
parts: {
trigger: DialogPartBindings
backdrop: DialogPartBindings
Expand Down Expand Up @@ -74,6 +79,22 @@ export const dialogConnect: Connect<
if (event?.defaultPrevented !== true) send({ type: 'interact.outside' })
}

// The host's traversal has no cancelable event — synthesize the veto
// payload so the callback contract matches the other dismissals.
const historyNavigate = (
callback: ((event?: BackNavigationPayload) => void) | undefined,
event: DialogMachineEvent,
): void => {
const payload: BackNavigationPayload = {
defaultPrevented: false,
preventDefault() {
payload.defaultPrevented = true
},
}
callback?.(payload)
if (payload.defaultPrevented !== true) send(event)
}

return {
open,
mounted: state !== 'closed',
Expand All @@ -84,16 +105,10 @@ export const dialogConnect: Connect<
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' })
historyNavigate(props.onBackNavigation, { type: 'history.back' })
},
forwardNavigate() {
historyNavigate(props.onForwardNavigation, { type: 'history.forward' })
},
parts: {
trigger: {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/dialog/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ export function dialogMachine(
on: {
open: intend('open', { target: 'open', value: true }),
toggle: intend('open', { target: 'open', value: true }),
// Forward re-enters the ground a Back-close left behind — the
// mirror of `history.back`, gated by the same setting.
'history.forward': intend('open', {
guard: canCloseOnBack,
target: 'open',
value: true,
}),
'controlled.sync': synced('open', { value: true, target: 'open' }),
},
},
Expand All @@ -89,6 +96,11 @@ export function dialogMachine(
on: {
open: intend('open', { target: 'open', value: true }),
toggle: intend('open', { target: 'open', value: true }),
'history.forward': intend('open', {
guard: canCloseOnBack,
target: 'open',
value: true,
}),
'exit.complete': { target: 'closed' },
'controlled.sync': synced('open', { value: true, target: 'open' }),
},
Expand Down
15 changes: 10 additions & 5 deletions packages/core/dialog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ export type DialogMachineEvent =
| { type: 'escape' }
| { type: 'interact.outside' }
| { type: 'history.back' }
| { type: 'history.forward' }
| { type: 'exit.complete' }
| ControlledSync<boolean>
| { 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. */
/** The payload for a history-navigation change — a Back dismissal or a
* Forward reopen. Synthesized by the connect — the host's traversal has no
* cancelable event of its own — carrying only the veto contract every
* dismissal callback shares. */
export interface BackNavigationPayload {
defaultPrevented?: boolean
preventDefault?: () => void
Expand All @@ -80,6 +82,8 @@ export interface DialogCallbacks {
onInteractOutside?: (event?: PointerPayload) => void
/** Fired before a back-navigation dismissal; `preventDefault()` vetoes it. */
onBackNavigation?: (event?: BackNavigationPayload) => void
/** Fired before a forward-navigation reopen; `preventDefault()` vetoes it. */
onForwardNavigation?: (event?: BackNavigationPayload) => void
}

/**
Expand Down Expand Up @@ -108,8 +112,9 @@ export interface DialogOptions extends DialogCallbacks {
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 */
* a nested stack — and, on a host with a forward stack, Forward reopens
* what Back closed. 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
Expand Down
43 changes: 43 additions & 0 deletions packages/core/dialog/tests/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,49 @@ describe('dialog machine — back navigation', () => {
})
})

describe('dialog machine — forward navigation', () => {
it('ignores history.forward without closeOnBack (the default)', () => {
const { service } = build()
service.send({ type: 'history.forward' })
expect(service.state).toBe('closed')
expect(service.context.open.intent).toBeNull()
})

it('reopens on history.forward when closeOnBack, interrupting the exit window too', () => {
const { service } = build({ closeOnBack: true })
service.send({ type: 'history.forward' })
expect(service.state).toBe('open')

const animated = build({ defaultOpen: true, closeOnBack: true, animated: true })
animated.service.send({ type: 'history.back' })
expect(animated.service.state).toBe('closing')
animated.service.send({ type: 'history.forward' })
expect(animated.service.state).toBe('open')
})

it('forwardNavigate fires the callback and reopens unless vetoed', () => {
const onForwardNavigation = vi.fn()
const { service, connection } = build({ closeOnBack: true, onForwardNavigation })
connection.snapshot.forwardNavigate()
expect(onForwardNavigation).toHaveBeenCalledTimes(1)
expect(service.state).toBe('open')

const vetoed = build({
closeOnBack: true,
onForwardNavigation: event => event?.preventDefault?.(),
})
vetoed.connection.snapshot.forwardNavigate()
expect(vetoed.service.state).toBe('closed')
})

it('a controlled dialog records the reopen intent and stays put', () => {
const { service, connection } = build({ open: false, closeOnBack: true })
connection.snapshot.forwardNavigate()
expect(service.state).toBe('closed')
expect(service.context.open.intent).toEqual({ value: true })
})
})

describe('dialog machine — animated exit', () => {
it('a close intent holds the exit window open until exit.complete', () => {
const { service } = build({ defaultOpen: true, animated: true })
Expand Down
Loading
Loading