Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .changeset/dialog-backdrop-containment.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions .changeset/dialog-close-on-back.md
Original file line number Diff line number Diff line change
@@ -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
<Dialog closeOnBack onBackNavigation={event => /* preventDefault() vetoes */ {}}>
</Dialog>
```

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).
32 changes: 32 additions & 0 deletions .changeset/dialog-exit-animation.md
Original file line number Diff line number Diff line change
@@ -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
<Dialog animated>…</Dialog>
```

```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.
28 changes: 28 additions & 0 deletions .changeset/dom-dialog-package.md
Original file line number Diff line number Diff line change
@@ -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 })
```
54 changes: 40 additions & 14 deletions packages/core/dialog/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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. |
28 changes: 27 additions & 1 deletion packages/core/dialog/src/connect.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/core/dialog/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { dialogMachine, type DialogMachine } from './machine'
export { dialogConnect, type DialogApi, type DialogPartBindings } from './connect'
export type {
BackNavigationPayload,
DialogCallbacks,
DialogContext,
DialogIds,
Expand Down
Loading
Loading