Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6b51d8d
feat(dialog): the browser Forward reopens what Back closed
ivanbanov Aug 14, 2026
0f41cde
Merge branch 'main' into fix-dialog-forward-navigation
ivanbanov Aug 22, 2026
3fe5d54
docs: name React and Solid as the substrates, not Vue
ivanbanov Aug 22, 2026
e460e05
chore: keep the pre-commit hook alive on template-only commits
ivanbanov Aug 23, 2026
5901360
Merge branch 'main' into fix-dialog-forward-navigation
ivanbanov Aug 24, 2026
95c6b4a
feat(overlay): below(id) — the layers beneath one, in unwinding order
ivanbanov Aug 25, 2026
6b0b11d
feat(dom-overlay): layersBelow(id) and an opt-in Layer.dismiss
ivanbanov Aug 25, 2026
a33e149
feat(browser-navigation): batched release + spent-entry claims
ivanbanov Aug 25, 2026
ffa4fad
feat(dialog): escapeScope option — one layer per press, or the whole …
ivanbanov Aug 25, 2026
ea429d9
feat(dom-dialog): stack-scoped Escape + the guard episode reclaims it…
ivanbanov Aug 25, 2026
9233253
docs(native-dialog): escapeScope parity row; Forward stays unwired
ivanbanov Aug 25, 2026
5650f03
feat(react-dialog): escapeScope, the nested Forward round-trip, and t…
ivanbanov Aug 25, 2026
55c7682
feat(solid-dialog): escapeScope, the nested Forward round-trip, and t…
ivanbanov Aug 25, 2026
9d93cfc
fix(dom-scroll-lock): save, hide, and restore overflow per axis
ivanbanov Aug 25, 2026
560d539
fix(dom-focus-trap): rendered check without checkVisibility; iframe a…
ivanbanov Aug 25, 2026
4698e0c
fix(overlay,dom-overlay): containment follows the topmost modal layer…
ivanbanov Aug 25, 2026
8ae32b4
fix(browser-navigation): chain sibling-release consumption one pop at…
ivanbanov Aug 25, 2026
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
23 changes: 23 additions & 0 deletions .changeset/back-guard-batched-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@dunky.dev/browser-navigation': patch
---

Fix: releasing a whole guarded stack in one turn (close-all, a route change,
an unmounting subtree) only consumed the topmost guard's entry — each entry
beneath stayed behind and silently swallowed a later browser Back.

```ts
const releaseOuter = interceptBackNavigation(() => closeOuter())
const releaseInner = interceptBackNavigation(() => closeInner())

// "close all" — both released in the same turn
releaseInner()
releaseOuter()

history.back()
// before: ❌ nothing happens — spent on outer's leftover entry
// after: ✅ leaves the page — every freed entry was consumed
```

Release order doesn't matter, and an entry genuinely buried under later
in-app navigation is still left alone.
16 changes: 16 additions & 0 deletions .changeset/back-guard-chained-consumption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@dunky.dev/browser-navigation': patch
---

Two `interceptBackNavigation` hardenings:

- Released entries are now consumed one traversal at a time — a chain of
single pops — instead of one `history.go(-n)` jump. Entries below the
current one are opaque, so a multi-step jump could cross history entries
the app planted itself; the chain stops at the first entry that isn't the
guard's to spend. A released entry buried beneath a live layer is also no
longer able to swallow a Back: the press that surfaces it unwinds the live
layer and consumes the dead entry in one go.
- An `onBack` that throws now counts as a decline: the guard re-arms so the
next Back still reaches the layer, and the error propagates instead of
aborting the unwind in an inconsistent state.
33 changes: 33 additions & 0 deletions .changeset/dialog-escape-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@dunky.dev/overlay': minor
'@dunky.dev/dom-overlay': minor
'@dunky.dev/dom-dialog': minor
'@dunky.dev/dialog': minor
'@dunky.dev/react-dialog': minor
'@dunky.dev/solid-dialog': minor
---

`escapeScope` now exists. It was documented in the dialog specs — one layer per
Escape by default, or the whole stack — but no package implemented it, so
passing it did nothing.

```tsx
// One press closes this dialog and every layer it was opened from.
<Dialog escapeScope='stack'>
```

Only the dialog that receives the Escape gates and vetoes it: its
`closeOnEscape` and `onEscapeKeyDown` decide, exactly as before. Once allowed,
the layers beneath receive a plain close — their own dismissal settings are not
consulted again — unwinding top-down, so focus lands where it was before the
bottom-most dialog opened. A vetoed Escape leaves the whole stack standing.

The mechanics are shared rather than per-dialog: the layer stack gained
`below(id)` (`@dunky.dev/overlay`) and `layersBelow(id)` plus an optional
`Layer.dismiss` (`@dunky.dev/dom-overlay`), so any overlay family can offer a
stack-scoped dismissal on the same stack. A layer that registers no `dismiss`
opts out and stays open, which is what keeps a stack that mixes primitives from
being closed out from under them.

The specs also described a stack-scoped Close _press_; nothing implements that,
so the claim is removed rather than left standing.
60 changes: 60 additions & 0 deletions .changeset/dialog-forward-reopens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
'@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?.()
}}
>
```

A nested dialog comes back too. Closing the layer it was opened from unmounts
it — machine and all — so the ground it lost to a Back press has no owner left
to reopen it. It reopens anyway: the ground belongs to the dialog's place in
the stack rather than to the instance that planted it, so the dialog that
comes back with its parent recognizes it. Two dialogs at the same place can't
be told apart, and then neither reopens. The same recognition survives a
reload, so a traversal back into that ground reopens the dialog even after the
page went away.

Under the hood, `interceptBackNavigation(onBack, options?)` takes its optional
callbacks as an object and grew `claim`, the name for that ground, plus a
`watchSpentEntry(claim, reopen)` for a closed layer waiting to be recognized.
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 neither option behaves exactly as before. A layer
that _closed_ gave its ground up on purpose and nothing reopens from it —
Forward never undoes a dismissal the user made deliberately.

`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.

One web-mechanics caveat, spec'd in the navigation util and both DOM
bindings: a controlled dialog's Back-close is completed by the consumer
rather than by the press, so its entry is consumed and Forward has nothing to
re-enter.
17 changes: 17 additions & 0 deletions .changeset/focus-trap-rendered-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@dunky.dev/dom-focus-trap': patch
---

Two fixes to which elements the trap's Tab cycle visits:

- **Rendered-ness is now decided by a computed-style walk instead of
`Element.checkVisibility()`.** The API is recent (Chrome/Edge 105+,
Firefox 106+, Safari 17.4+), and the trap resolves focusables after the Tab
keydown's `preventDefault()` — on a browser without it, the resulting throw
left Tab dead entirely. The walk checks the same conditions (`hidden`
attribute, `visibility: hidden`, `display: none` on the element or an
ancestor) and works everywhere.
- **`iframe` and `details > summary` now participate in the cycle.** Browsers
tab to both, but the trap — which steps focus itself — skipped them, making
them unreachable by keyboard while trapped. Only a details' first summary is
matched, since that is the disclosure widget browsers focus.
5 changes: 2 additions & 3 deletions .changeset/focus-trap-tab-stop-fidelity.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ Three fixes, all consumer-visible:
trap. Initial focus on open remains the caller's job.
- **Non-rendered elements no longer enter the cycle.** Elements hidden via the
`hidden` attribute, `display: none` (own or ancestor), or
`visibility: hidden` are filtered out with `Element.checkVisibility()`.
Focusing a non-rendered element is a no-op, so a hidden element in the cycle
used to stall the trap on it.
`visibility: hidden` are filtered out. Focusing a non-rendered element is a
no-op, so a hidden element in the cycle used to stall the trap on it.
- **A same-name radio group is one tab stop.** Per the APG radio group
pattern, the stop is the checked radio, else the group's first; groups are
scoped by name and form owner. The trap steps focus itself, so it now
Expand Down
27 changes: 27 additions & 0 deletions .changeset/overlay-ordered-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@dunky.dev/overlay': minor
'@dunky.dev/dom-overlay': patch
---

Assistive-tech containment no longer lapses while a non-modal layer is open
above a modal one.

Containment now follows the topmost **modal** layer rather than the topmost
layer. The ordinary layers — a select menu, a combobox list, a tooltip, a
context menu — are non-modal and live inside dialogs; opening one used to
release the dialog's containment, leaving the page behind reachable by
pointer, keyboard, and screen reader for exactly as long as someone was
interacting with the menu. The layers stacked above the modal one are held
out of the hiding — they portal to the body as siblings of the dialog, so
without the exception the containment would inert the very layer the user is
in. Topmost keeps its meaning: a non-modal layer above still owns Escape and
the focus trap; only containment stays put.

To support this, the agnostic stack gains a public `ordered()` method
returning every layer topmost first — the host needs to look past the top of
the stack, while modality stays a host concept:

```ts
const stack = createLayerStack<Layer>()
stack.ordered() // every layer, topmost first
```
16 changes: 16 additions & 0 deletions .changeset/scroll-lock-overflow-per-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@dunky.dev/dom-scroll-lock': patch
---

`lockScroll` now saves, hides, and restores `overflow` per axis
(`overflow-x` / `overflow-y`), never via the shorthand. Per CSSOM the
`overflow` shorthand serializes back to `''` unless both longhands are set,
so a container that declares its scrolling on one axis only —

```tsx
<div style={{ overflowY: 'auto' }}>
```

— saved as "unset"; release then removed the consumer's own declaration and
the container stopped scrolling permanently. Restore now returns the inline
style to exactly what the first holder saw, as the contract promises.
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
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"vitest/require-mock-type-parameters": "off"
"vitest/require-mock-type-parameters": "off",
"vitest/expect-expect": ["warn", { "assertFunctionNames": ["expect", "expectOverflow"] }]
}
},
{
Expand Down
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
Loading
Loading