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
48 changes: 48 additions & 0 deletions .changeset/dialog-forward-reopens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@dunky.dev/browser-navigation': minor
'@dunky.dev/dom-dialog': minor
'@dunky.dev/dialog': minor
'@dunky.dev/react-dialog': minor
'@dunky.dev/solid-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. Both DOM substrates get it — React and
Solid — from the same code.

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. A layer that passes no `onForward`
behaves exactly as before.

`guardBackNavigation` (`@dunky.dev/dom-dialog`) now returns
`{ sync, release }` rather than a bare disposer: the guard outlives the open
state — that is the whole point of the Forward watch — so a host reports
every change through `sync(open)` and ends the episode with `release()`.
Whether a close parks the registration or releases it stays a DOM-layer
decision, made once for every substrate.

Web-mechanics caveats, spec'd in the navigation util and both DOM bindings:
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.
3 changes: 0 additions & 3 deletions .lintstagedrc.json

This file was deleted.

20 changes: 20 additions & 0 deletions .lintstagedrc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Configuration } from 'lint-staged'

// oxlint and oxfmt both ignore `scripts/templates/**` (see their rc files — the
// placeholder files aren't valid TS on their own), and both treat a fully
// ignored file list as an error rather than a no-op. So a commit touching only
// templates would fail the hook on "no files to check": drop them here instead.
const IGNORED = '/scripts/templates/'

const quote = (paths: string[]): string => paths.map(path => JSON.stringify(path)).join(' ')

const config: Configuration = {
'*.{ts,tsx}': files => {
const checkable = files.filter(file => !file.includes(IGNORED))
if (checkable.length === 0) return []
const targets = quote(checkable)
return [`oxlint --fix ${targets}`, `oxfmt ${targets}`]
},
}

export default config
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ one per host environment — the **substrates**. Behavior cannot drift between
hosts because it exists in exactly one place.

A substrate is any environment a primitive is delivered to: a framework
(react), another framework (vue, solid), or a different host entirely
(react), another framework (solid), or a different host entirely
(native). Substrates are cheap by design; the expensive thing — the behavior
— is written once.

Expand Down Expand Up @@ -65,8 +65,8 @@ the dialog's Escape listener, the ordered sequence around its open and exit
edges — lives under `dom/components/` instead. A util is primitive-agnostic
and imports nothing from the repo; a component package is the opposite, and
may import the primitive's core package and any DOM util. Both are equally
framework-free. The split matters as substrates multiply: React, Solid, and
Vue differ in how they schedule an effect, not in what the effect does, so the
framework-free. The split matters as substrates multiply: React and Solid
differ in how they schedule an effect, not in what the effect does, so the
what is written once and each binding contributes only its lifecycle.

Machine logic that several primitives need — the controlled/uncontrolled
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pnpm test packages/core/dialog/tests/machine.test.ts

## Storybook

Each UI substrate (React, Vue, ...) is a self-contained package under
Each UI substrate (React, Solid, ...) is a self-contained package under
`packages/<substrate>` with its own Storybook — the fastest way to see a
change actually render. Every substrate gets an explicit `dev:<substrate>`
script:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ machine (its **core**) and delivered through a thin binding per host environment
v v v
+-----------+ +-----------+ +-----------+
| substrate | | substrate | | substrate | packages/<substrate>/<name>
| (react) | | (vue) | | (native) | render + host wiring
| (react) | | (solid) | | (native) | render + host wiring
+-----------+ +-----------+ +-----------+
same behavior, same a11y — only the render differs
```
Expand Down
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