diff --git a/.changeset/back-guard-batched-release.md b/.changeset/back-guard-batched-release.md deleted file mode 100644 index f98470f..0000000 --- a/.changeset/back-guard-batched-release.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@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. diff --git a/.changeset/back-guard-chained-consumption.md b/.changeset/back-guard-chained-consumption.md deleted file mode 100644 index fbbf80f..0000000 --- a/.changeset/back-guard-chained-consumption.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@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. diff --git a/.changeset/dialog-escape-scope.md b/.changeset/dialog-escape-scope.md deleted file mode 100644 index c567db0..0000000 --- a/.changeset/dialog-escape-scope.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@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. - -``` - -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. diff --git a/.changeset/dialog-focus-miss-warning.md b/.changeset/dialog-focus-miss-warning.md deleted file mode 100644 index 4c79d24..0000000 --- a/.changeset/dialog-focus-miss-warning.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@dunky.dev/dom-dialog': patch ---- - -`openDialogLayer` now warns when focus cannot move into the dialog at all — -when the initial focus target refuses focus and the dialog window can't take -the fallback either (typically because it lacks `tabindex="-1"`). Focus -stranded outside an open modal breaks the modal dialog pattern; the miss used -to be silent, now the warning names the fix. diff --git a/.changeset/dialog-forward-reopens.md b/.changeset/dialog-forward-reopens.md deleted file mode 100644 index c90ea8b..0000000 --- a/.changeset/dialog-forward-reopens.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@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 - { - // 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. diff --git a/.changeset/fix-back-guard-unwinding.md b/.changeset/fix-back-guard-unwinding.md deleted file mode 100644 index 73760e8..0000000 --- a/.changeset/fix-back-guard-unwinding.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@dunky.dev/browser-navigation': patch ---- - -Fix two `interceptBackNavigation` bugs around releases: - -- Two guards released in the same synchronous turn no longer strand the - shared `popstate` listener: the first release's idle check could detach it - while the second release's self-caused pop was still in flight, leaving - that pop uncounted — the next guard's first Back press was then misread as - self-caused and its `onBack` never fired. -- A guard that releases itself inside its own `onBack` (a legal use of the - public API) no longer evicts the guard beneath it: the handler now removes - the answering guard by identity instead of positionally, so lower layers - stay armed and keep their history entries. diff --git a/.changeset/focus-trap-rendered-walk.md b/.changeset/focus-trap-rendered-walk.md deleted file mode 100644 index cf250d4..0000000 --- a/.changeset/focus-trap-rendered-walk.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@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. diff --git a/.changeset/focus-trap-tab-stop-fidelity.md b/.changeset/focus-trap-tab-stop-fidelity.md deleted file mode 100644 index 0bf1432..0000000 --- a/.changeset/focus-trap-tab-stop-fidelity.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@dunky.dev/dom-focus-trap': patch ---- - -Make the trap's Tab cycle match what a browser would actually focus. - -Three fixes, all consumer-visible: - -- **The trap now intercepts Tab from anywhere in the document.** The keydown - listener moved from the container to the document (capture phase), so a Tab - pressed while focus is still outside — on the trigger, or on `body` — wraps - into the cycle at the edge instead of following native tab order out of the - 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. 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 - reproduces the browser's grouping instead of visiting every radio. diff --git a/.changeset/overlay-hiding-fixes.md b/.changeset/overlay-hiding-fixes.md deleted file mode 100644 index 9668a2a..0000000 --- a/.changeset/overlay-hiding-fixes.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@dunky.dev/dom-overlay': patch ---- - -Two fixes to how containment and the exit window treat pre-existing markup: - -- Elements marked `aria-hidden="false"` are now hidden behind a modal layer - like any other, and the authored value is restored on undo. `"false"` - asserts visible — the opposite of author-hidden — so the previous skip left - such elements exposed to assistive tech behind an open modal. Only a truthy - `aria-hidden` (or `inert`) still counts as the author's own hiding. -- `hideExitingLayer` no longer inerts `` when the supplied boundary is - not an ancestor of the content. A stale or mismatched boundary used to - exhaust the ancestor walk at the document root and take the whole page out - for the exit window; the hide now falls back to the content itself. diff --git a/.changeset/overlay-ordered-layers.md b/.changeset/overlay-ordered-layers.md deleted file mode 100644 index 6b294af..0000000 --- a/.changeset/overlay-ordered-layers.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@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() -stack.ordered() // every layer, topmost first -``` diff --git a/.changeset/rename-browser-navigation.md b/.changeset/rename-browser-navigation.md deleted file mode 100644 index 11095be..0000000 --- a/.changeset/rename-browser-navigation.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@dunky.dev/browser-navigation': minor -'@dunky.dev/dom-dialog': patch ---- - -Rename `@dunky.dev/dom-navigation` to `@dunky.dev/browser-navigation`. - -The util guards the browser's session history — Back, Forward, reload — and -never touches the DOM, so the old name pointed at the wrong layer. The API is -unchanged; only the package name moves: - -```diff --import { interceptBackNavigation } from '@dunky.dev/dom-navigation' -+import { interceptBackNavigation } from '@dunky.dev/browser-navigation' -``` - -`@dunky.dev/dom-navigation` will receive no further releases. diff --git a/.changeset/scroll-lock-additive-compensation.md b/.changeset/scroll-lock-additive-compensation.md deleted file mode 100644 index 3144446..0000000 --- a/.changeset/scroll-lock-additive-compensation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@dunky.dev/dom-scroll-lock': patch ---- - -Two `lockScroll` fixes: - -- Scrollbar compensation is now additive: the footprint is added on top of - the target's computed padding instead of assigned over it. Previously the - inline longhand won the cascade and erased any `padding-inline-end` / - `padding-block-end` the target already had (inline or from a stylesheet), - shifting layout the other way — the lock must not shift layout in either - direction. -- Release restores the saved inline styles via `style.setProperty` instead of - branching per value: a saved `''` (originally unset) removes the - declaration per CSSOM, so the target returns to exactly what the first - holder saw. diff --git a/.changeset/scroll-lock-overflow-per-axis.md b/.changeset/scroll-lock-overflow-per-axis.md deleted file mode 100644 index 32ff5e5..0000000 --- a/.changeset/scroll-lock-overflow-per-axis.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@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 -
-``` - -— 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. diff --git a/.changeset/use-scroll-lock-null-target.md b/.changeset/use-scroll-lock-null-target.md deleted file mode 100644 index 2219cfb..0000000 --- a/.changeset/use-scroll-lock-null-target.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@dunky.dev/react-use-scroll-lock': patch -'@dunky.dev/solid-use-scroll-lock': patch ---- - -`useScrollLock` now treats a `null` target as "no target yet" and locks -nothing. Previously `null` collapsed into "the page body", so passing a -not-yet-resolved element (e.g. `ref.current` on the first run) locked the -page instead of the intended container — and never corrected itself. An -omitted target still means the page body. - -Pass the element through something reactive so the lock engages once the -node resolves — in React hold it in state (a ref populating doesn't -re-render), in Solid pass a signal-backed element (a plain `ref` read is -not reactive): - -```tsx -// React -const [panel, setPanel] = useState(null) -useScrollLock(open, panel) // locks nothing until the node resolves - -// Solid -const [panel, setPanel] = createSignal(null) -useScrollLock(open, panel) -``` diff --git a/packages/core/dialog/CHANGELOG.md b/packages/core/dialog/CHANGELOG.md index 47b93bb..3bd93f6 100644 --- a/packages/core/dialog/CHANGELOG.md +++ b/packages/core/dialog/CHANGELOG.md @@ -1,5 +1,87 @@ # @dunky.dev/dialog +## 0.4.0 + +### Minor Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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. + + ``` + + 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. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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 + { + // 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. + ## 0.3.1 ### Patch Changes diff --git a/packages/core/dialog/package.json b/packages/core/dialog/package.json index 3af8481..445879a 100644 --- a/packages/core/dialog/package.json +++ b/packages/core/dialog/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/dialog", - "version": "0.3.1", + "version": "0.4.0", "description": "Framework-agnostic dialog interaction, modeled as a state machine.", "license": "MIT", "repository": { diff --git a/packages/core/utils/overlay/CHANGELOG.md b/packages/core/utils/overlay/CHANGELOG.md index cf9eb74..9cdeb49 100644 --- a/packages/core/utils/overlay/CHANGELOG.md +++ b/packages/core/utils/overlay/CHANGELOG.md @@ -1,5 +1,57 @@ # @dunky.dev/overlay +## 0.2.0 + +### Minor Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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. + + ``` + + 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. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`4698e0c`](https://github.com/dunky-dev/ui/commit/4698e0c5f24182e050473cd68faf2e60d2b66630) Thanks [@ivanbanov](https://github.com/ivanbanov)! - 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() + stack.ordered() // every layer, topmost first + ``` + ## 0.1.1 ### Patch Changes diff --git a/packages/core/utils/overlay/package.json b/packages/core/utils/overlay/package.json index 4699eb2..9bf3133 100644 --- a/packages/core/utils/overlay/package.json +++ b/packages/core/utils/overlay/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/overlay", - "version": "0.1.1", + "version": "0.2.0", "description": "Agnostic overlay-layer stack: registration and topmost resolution, shared by every overlay primitive across substrates.", "license": "MIT", "repository": { diff --git a/packages/dom/components/dialog/CHANGELOG.md b/packages/dom/components/dialog/CHANGELOG.md index 665a7a6..73f6025 100644 --- a/packages/dom/components/dialog/CHANGELOG.md +++ b/packages/dom/components/dialog/CHANGELOG.md @@ -1,5 +1,114 @@ # @dunky.dev/dom-dialog +## 0.2.0 + +### Minor Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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. + + ``` + + 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. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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 + { + // 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. + +### Patch Changes + +- [#48](https://github.com/dunky-dev/ui/pull/48) [`c35d1ab`](https://github.com/dunky-dev/ui/commit/c35d1abe05f07f6df741f297c0d8b35bd0a1e03c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `openDialogLayer` now warns when focus cannot move into the dialog at all — + when the initial focus target refuses focus and the dialog window can't take + the fallback either (typically because it lacks `tabindex="-1"`). Focus + stranded outside an open modal breaks the modal dialog pattern; the miss used + to be silent, now the warning names the fix. + +- [#46](https://github.com/dunky-dev/ui/pull/46) [`5a58c2d`](https://github.com/dunky-dev/ui/commit/5a58c2dd2afcea9c02d54262beaec6e5a95e9e95) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Rename `@dunky.dev/dom-navigation` to `@dunky.dev/browser-navigation`. + + The util guards the browser's session history — Back, Forward, reload — and + never touches the DOM, so the old name pointed at the wrong layer. The API is + unchanged; only the package name moves: + + ```diff + -import { interceptBackNavigation } from '@dunky.dev/dom-navigation' + +import { interceptBackNavigation } from '@dunky.dev/browser-navigation' + ``` + + `@dunky.dev/dom-navigation` will receive no further releases. + +- Updated dependencies [[`a33e149`](https://github.com/dunky-dev/ui/commit/a33e1496a5e2440c968002efc493f850ea9db26a), [`8ae32b4`](https://github.com/dunky-dev/ui/commit/8ae32b422e66b9bf9810a85d299a730205f2f1ca), [`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559), [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c), [`979c3c7`](https://github.com/dunky-dev/ui/commit/979c3c7a03e793d98ea33fd7392a6d87fe84517a), [`560d539`](https://github.com/dunky-dev/ui/commit/560d539dac5d2ed4b318b9ddad08f9717ddb8f00), [`6ed64a2`](https://github.com/dunky-dev/ui/commit/6ed64a213a42f2f03d07759afd84b456fd753218), [`c35d1ab`](https://github.com/dunky-dev/ui/commit/c35d1abe05f07f6df741f297c0d8b35bd0a1e03c), [`4698e0c`](https://github.com/dunky-dev/ui/commit/4698e0c5f24182e050473cd68faf2e60d2b66630), [`5a58c2d`](https://github.com/dunky-dev/ui/commit/5a58c2dd2afcea9c02d54262beaec6e5a95e9e95)]: + - @dunky.dev/browser-navigation@0.2.0 + - @dunky.dev/dom-overlay@0.2.0 + - @dunky.dev/dialog@0.4.0 + - @dunky.dev/dom-focus-trap@0.1.2 + ## 0.1.0 ### Minor Changes diff --git a/packages/dom/components/dialog/package.json b/packages/dom/components/dialog/package.json index 8b0023c..91ebebf 100644 --- a/packages/dom/components/dialog/package.json +++ b/packages/dom/components/dialog/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/dom-dialog", - "version": "0.1.0", + "version": "0.2.0", "description": "Framework-free DOM behavior for @dunky.dev/dialog: the document-level effects, the open and exit sequences, and the outside-press gating every DOM substrate shares.", "license": "MIT", "repository": { diff --git a/packages/dom/utils/focus-trap/CHANGELOG.md b/packages/dom/utils/focus-trap/CHANGELOG.md index 3d8e74d..b674905 100644 --- a/packages/dom/utils/focus-trap/CHANGELOG.md +++ b/packages/dom/utils/focus-trap/CHANGELOG.md @@ -1,5 +1,39 @@ # @dunky.dev/dom-focus-trap +## 0.1.2 + +### Patch Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`560d539`](https://github.com/dunky-dev/ui/commit/560d539dac5d2ed4b318b9ddad08f9717ddb8f00) Thanks [@ivanbanov](https://github.com/ivanbanov)! - 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. + +- [#48](https://github.com/dunky-dev/ui/pull/48) [`6ed64a2`](https://github.com/dunky-dev/ui/commit/6ed64a213a42f2f03d07759afd84b456fd753218) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Make the trap's Tab cycle match what a browser would actually focus. + + Three fixes, all consumer-visible: + - **The trap now intercepts Tab from anywhere in the document.** The keydown + listener moved from the container to the document (capture phase), so a Tab + pressed while focus is still outside — on the trigger, or on `body` — wraps + into the cycle at the edge instead of following native tab order out of the + 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. 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 + reproduces the browser's grouping instead of visiting every radio. + ## 0.1.1 ### Patch Changes diff --git a/packages/dom/utils/focus-trap/package.json b/packages/dom/utils/focus-trap/package.json index 6205c3d..8c2a7bc 100644 --- a/packages/dom/utils/focus-trap/package.json +++ b/packages/dom/utils/focus-trap/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/dom-focus-trap", - "version": "0.1.1", + "version": "0.1.2", "description": "Framework-free Tab/Shift+Tab containment for a DOM subtree.", "license": "MIT", "repository": { diff --git a/packages/dom/utils/navigation/CHANGELOG.md b/packages/dom/utils/navigation/CHANGELOG.md index e3d1766..1158995 100644 --- a/packages/dom/utils/navigation/CHANGELOG.md +++ b/packages/dom/utils/navigation/CHANGELOG.md @@ -1,5 +1,120 @@ # @dunky.dev/browser-navigation +## 0.2.0 + +### Minor Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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 + { + // 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. + +- [#46](https://github.com/dunky-dev/ui/pull/46) [`5a58c2d`](https://github.com/dunky-dev/ui/commit/5a58c2dd2afcea9c02d54262beaec6e5a95e9e95) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Rename `@dunky.dev/dom-navigation` to `@dunky.dev/browser-navigation`. + + The util guards the browser's session history — Back, Forward, reload — and + never touches the DOM, so the old name pointed at the wrong layer. The API is + unchanged; only the package name moves: + + ```diff + -import { interceptBackNavigation } from '@dunky.dev/dom-navigation' + +import { interceptBackNavigation } from '@dunky.dev/browser-navigation' + ``` + + `@dunky.dev/dom-navigation` will receive no further releases. + +### Patch Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`a33e149`](https://github.com/dunky-dev/ui/commit/a33e1496a5e2440c968002efc493f850ea9db26a) Thanks [@ivanbanov](https://github.com/ivanbanov)! - 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. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`8ae32b4`](https://github.com/dunky-dev/ui/commit/8ae32b422e66b9bf9810a85d299a730205f2f1ca) Thanks [@ivanbanov](https://github.com/ivanbanov)! - 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. + +- [#48](https://github.com/dunky-dev/ui/pull/48) [`979c3c7`](https://github.com/dunky-dev/ui/commit/979c3c7a03e793d98ea33fd7392a6d87fe84517a) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Fix two `interceptBackNavigation` bugs around releases: + - Two guards released in the same synchronous turn no longer strand the + shared `popstate` listener: the first release's idle check could detach it + while the second release's self-caused pop was still in flight, leaving + that pop uncounted — the next guard's first Back press was then misread as + self-caused and its `onBack` never fired. + - A guard that releases itself inside its own `onBack` (a legal use of the + public API) no longer evicts the guard beneath it: the handler now removes + the answering guard by identity instead of positionally, so lower layers + stay armed and keep their history entries. + ## 0.1.1 ### Patch Changes diff --git a/packages/dom/utils/navigation/package.json b/packages/dom/utils/navigation/package.json index 689fd64..9af86d6 100644 --- a/packages/dom/utils/navigation/package.json +++ b/packages/dom/utils/navigation/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/browser-navigation", - "version": "0.1.1", + "version": "0.2.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": { diff --git a/packages/dom/utils/overlay/CHANGELOG.md b/packages/dom/utils/overlay/CHANGELOG.md index cdd7574..c8878ec 100644 --- a/packages/dom/utils/overlay/CHANGELOG.md +++ b/packages/dom/utils/overlay/CHANGELOG.md @@ -1,5 +1,73 @@ # @dunky.dev/dom-overlay +## 0.2.0 + +### Minor Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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. + + ``` + + 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. + +### Patch Changes + +- [#48](https://github.com/dunky-dev/ui/pull/48) [`c35d1ab`](https://github.com/dunky-dev/ui/commit/c35d1abe05f07f6df741f297c0d8b35bd0a1e03c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Two fixes to how containment and the exit window treat pre-existing markup: + - Elements marked `aria-hidden="false"` are now hidden behind a modal layer + like any other, and the authored value is restored on undo. `"false"` + asserts visible — the opposite of author-hidden — so the previous skip left + such elements exposed to assistive tech behind an open modal. Only a truthy + `aria-hidden` (or `inert`) still counts as the author's own hiding. + - `hideExitingLayer` no longer inerts `` when the supplied boundary is + not an ancestor of the content. A stale or mismatched boundary used to + exhaust the ancestor walk at the document root and take the whole page out + for the exit window; the hide now falls back to the content itself. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`4698e0c`](https://github.com/dunky-dev/ui/commit/4698e0c5f24182e050473cd68faf2e60d2b66630) Thanks [@ivanbanov](https://github.com/ivanbanov)! - 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() + stack.ordered() // every layer, topmost first + ``` + +- Updated dependencies [[`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559), [`4698e0c`](https://github.com/dunky-dev/ui/commit/4698e0c5f24182e050473cd68faf2e60d2b66630)]: + - @dunky.dev/overlay@0.2.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/dom/utils/overlay/package.json b/packages/dom/utils/overlay/package.json index 63c9254..79c4f1b 100644 --- a/packages/dom/utils/overlay/package.json +++ b/packages/dom/utils/overlay/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/dom-overlay", - "version": "0.1.1", + "version": "0.2.0", "description": "Framework-free DOM behavior for overlay substrates: the shared layer stack with assistive-tech containment, the exit window, and initial focus.", "license": "MIT", "repository": { diff --git a/packages/dom/utils/scroll-lock/CHANGELOG.md b/packages/dom/utils/scroll-lock/CHANGELOG.md index 5fda035..40f8d1a 100644 --- a/packages/dom/utils/scroll-lock/CHANGELOG.md +++ b/packages/dom/utils/scroll-lock/CHANGELOG.md @@ -1,5 +1,34 @@ # @dunky.dev/dom-scroll-lock +## 0.1.2 + +### Patch Changes + +- [#48](https://github.com/dunky-dev/ui/pull/48) [`4208569`](https://github.com/dunky-dev/ui/commit/4208569ecad4b141ecdf814ae195bcc0e14a7afc) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Two `lockScroll` fixes: + - Scrollbar compensation is now additive: the footprint is added on top of + the target's computed padding instead of assigned over it. Previously the + inline longhand won the cascade and erased any `padding-inline-end` / + `padding-block-end` the target already had (inline or from a stylesheet), + shifting layout the other way — the lock must not shift layout in either + direction. + - Release restores the saved inline styles via `style.setProperty` instead of + branching per value: a saved `''` (originally unset) removes the + declaration per CSSOM, so the target returns to exactly what the first + holder saw. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`9d93cfc`](https://github.com/dunky-dev/ui/commit/9d93cfc638c75e8ee8c48a1ad51b2537645680cf) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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 +
+ ``` + + — 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. + ## 0.1.1 ### Patch Changes diff --git a/packages/dom/utils/scroll-lock/package.json b/packages/dom/utils/scroll-lock/package.json index 5442756..501406c 100644 --- a/packages/dom/utils/scroll-lock/package.json +++ b/packages/dom/utils/scroll-lock/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/dom-scroll-lock", - "version": "0.1.1", + "version": "0.1.2", "description": "Framework-free, reference-counted scroll lock for any scroll container.", "license": "MIT", "repository": { diff --git a/packages/native/dialog/CHANGELOG.md b/packages/native/dialog/CHANGELOG.md index 18118fa..02f012c 100644 --- a/packages/native/dialog/CHANGELOG.md +++ b/packages/native/dialog/CHANGELOG.md @@ -1,5 +1,12 @@ # @dunky.dev/native-dialog +## 0.1.2 + +### Patch Changes + +- Updated dependencies [[`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559), [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c)]: + - @dunky.dev/dialog@0.4.0 + ## 0.1.1 ### Patch Changes diff --git a/packages/native/dialog/package.json b/packages/native/dialog/package.json index aab7f9b..9bf9a96 100644 --- a/packages/native/dialog/package.json +++ b/packages/native/dialog/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/native-dialog", - "version": "0.1.1", + "version": "0.1.2", "description": "React Native binding for @dunky.dev/dialog.", "license": "MIT", "repository": { diff --git a/packages/react/dialog/CHANGELOG.md b/packages/react/dialog/CHANGELOG.md index 6513547..5a8051d 100644 --- a/packages/react/dialog/CHANGELOG.md +++ b/packages/react/dialog/CHANGELOG.md @@ -1,5 +1,95 @@ # @dunky.dev/react-dialog +## 0.4.0 + +### Minor Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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. + + ``` + + 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. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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 + { + // 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. + +### Patch Changes + +- Updated dependencies [[`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559), [`c35d1ab`](https://github.com/dunky-dev/ui/commit/c35d1abe05f07f6df741f297c0d8b35bd0a1e03c), [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c), [`5a58c2d`](https://github.com/dunky-dev/ui/commit/5a58c2dd2afcea9c02d54262beaec6e5a95e9e95), [`4208569`](https://github.com/dunky-dev/ui/commit/4208569ecad4b141ecdf814ae195bcc0e14a7afc)]: + - @dunky.dev/dom-dialog@0.2.0 + - @dunky.dev/dialog@0.4.0 + - @dunky.dev/react-use-scroll-lock@0.1.2 + - @dunky.dev/react-use-focus-trap@0.1.2 + ## 0.3.0 ### Minor Changes diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json index 47be229..2d9d979 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/react-dialog", - "version": "0.3.0", + "version": "0.4.0", "description": "React binding for @dunky.dev/dialog.", "license": "MIT", "repository": { diff --git a/packages/react/hooks/use-focus-trap/CHANGELOG.md b/packages/react/hooks/use-focus-trap/CHANGELOG.md index 884ad7b..82b4e2e 100644 --- a/packages/react/hooks/use-focus-trap/CHANGELOG.md +++ b/packages/react/hooks/use-focus-trap/CHANGELOG.md @@ -1,5 +1,12 @@ # @dunky.dev/react-use-focus-trap +## 0.1.2 + +### Patch Changes + +- Updated dependencies [[`560d539`](https://github.com/dunky-dev/ui/commit/560d539dac5d2ed4b318b9ddad08f9717ddb8f00), [`6ed64a2`](https://github.com/dunky-dev/ui/commit/6ed64a213a42f2f03d07759afd84b456fd753218)]: + - @dunky.dev/dom-focus-trap@0.1.2 + ## 0.1.1 ### Patch Changes diff --git a/packages/react/hooks/use-focus-trap/package.json b/packages/react/hooks/use-focus-trap/package.json index 6bdee15..9bd09e1 100644 --- a/packages/react/hooks/use-focus-trap/package.json +++ b/packages/react/hooks/use-focus-trap/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/react-use-focus-trap", - "version": "0.1.1", + "version": "0.1.2", "description": "React binding for @dunky.dev/dom-focus-trap.", "license": "MIT", "repository": { diff --git a/packages/react/hooks/use-scroll-lock/CHANGELOG.md b/packages/react/hooks/use-scroll-lock/CHANGELOG.md index ad7dd2c..edf68f0 100644 --- a/packages/react/hooks/use-scroll-lock/CHANGELOG.md +++ b/packages/react/hooks/use-scroll-lock/CHANGELOG.md @@ -1,5 +1,33 @@ # @dunky.dev/react-use-scroll-lock +## 0.1.2 + +### Patch Changes + +- [#48](https://github.com/dunky-dev/ui/pull/48) [`4208569`](https://github.com/dunky-dev/ui/commit/4208569ecad4b141ecdf814ae195bcc0e14a7afc) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `useScrollLock` now treats a `null` target as "no target yet" and locks + nothing. Previously `null` collapsed into "the page body", so passing a + not-yet-resolved element (e.g. `ref.current` on the first run) locked the + page instead of the intended container — and never corrected itself. An + omitted target still means the page body. + + Pass the element through something reactive so the lock engages once the + node resolves — in React hold it in state (a ref populating doesn't + re-render), in Solid pass a signal-backed element (a plain `ref` read is + not reactive): + + ```tsx + // React + const [panel, setPanel] = useState(null) + useScrollLock(open, panel) // locks nothing until the node resolves + + // Solid + const [panel, setPanel] = createSignal(null) + useScrollLock(open, panel) + ``` + +- Updated dependencies [[`4208569`](https://github.com/dunky-dev/ui/commit/4208569ecad4b141ecdf814ae195bcc0e14a7afc), [`9d93cfc`](https://github.com/dunky-dev/ui/commit/9d93cfc638c75e8ee8c48a1ad51b2537645680cf)]: + - @dunky.dev/dom-scroll-lock@0.1.2 + ## 0.1.1 ### Patch Changes diff --git a/packages/react/hooks/use-scroll-lock/package.json b/packages/react/hooks/use-scroll-lock/package.json index e3983b1..50c98fd 100644 --- a/packages/react/hooks/use-scroll-lock/package.json +++ b/packages/react/hooks/use-scroll-lock/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/react-use-scroll-lock", - "version": "0.1.1", + "version": "0.1.2", "description": "React binding for @dunky.dev/dom-scroll-lock.", "license": "MIT", "repository": { diff --git a/packages/solid/dialog/CHANGELOG.md b/packages/solid/dialog/CHANGELOG.md index c3234bd..6182145 100644 --- a/packages/solid/dialog/CHANGELOG.md +++ b/packages/solid/dialog/CHANGELOG.md @@ -1,5 +1,95 @@ # @dunky.dev/solid-dialog +## 0.2.0 + +### Minor Changes + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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. + + ``` + + 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. + +- [#39](https://github.com/dunky-dev/ui/pull/39) [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `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 + { + // 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. + +### Patch Changes + +- Updated dependencies [[`ffa4fad`](https://github.com/dunky-dev/ui/commit/ffa4fada7719daa8661adab52c20952f3d8d7559), [`c35d1ab`](https://github.com/dunky-dev/ui/commit/c35d1abe05f07f6df741f297c0d8b35bd0a1e03c), [`6b51d8d`](https://github.com/dunky-dev/ui/commit/6b51d8de4d1069863a56c7ac5f74cb3c8dfaa20c), [`5a58c2d`](https://github.com/dunky-dev/ui/commit/5a58c2dd2afcea9c02d54262beaec6e5a95e9e95), [`4208569`](https://github.com/dunky-dev/ui/commit/4208569ecad4b141ecdf814ae195bcc0e14a7afc)]: + - @dunky.dev/dom-dialog@0.2.0 + - @dunky.dev/dialog@0.4.0 + - @dunky.dev/solid-use-scroll-lock@0.1.1 + - @dunky.dev/solid-use-focus-trap@0.1.1 + ## 0.1.0 ### Minor Changes diff --git a/packages/solid/dialog/package.json b/packages/solid/dialog/package.json index 72d5a6c..c885d16 100644 --- a/packages/solid/dialog/package.json +++ b/packages/solid/dialog/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/solid-dialog", - "version": "0.1.0", + "version": "0.2.0", "description": "Solid binding for @dunky.dev/dialog.", "license": "MIT", "repository": { diff --git a/packages/solid/hooks/use-focus-trap/CHANGELOG.md b/packages/solid/hooks/use-focus-trap/CHANGELOG.md index b205b71..0115914 100644 --- a/packages/solid/hooks/use-focus-trap/CHANGELOG.md +++ b/packages/solid/hooks/use-focus-trap/CHANGELOG.md @@ -1,5 +1,12 @@ # @dunky.dev/solid-use-focus-trap +## 0.1.1 + +### Patch Changes + +- Updated dependencies [[`560d539`](https://github.com/dunky-dev/ui/commit/560d539dac5d2ed4b318b9ddad08f9717ddb8f00), [`6ed64a2`](https://github.com/dunky-dev/ui/commit/6ed64a213a42f2f03d07759afd84b456fd753218)]: + - @dunky.dev/dom-focus-trap@0.1.2 + ## 0.1.0 ### Minor Changes diff --git a/packages/solid/hooks/use-focus-trap/package.json b/packages/solid/hooks/use-focus-trap/package.json index 42570a7..7ffa5c4 100644 --- a/packages/solid/hooks/use-focus-trap/package.json +++ b/packages/solid/hooks/use-focus-trap/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/solid-use-focus-trap", - "version": "0.1.0", + "version": "0.1.1", "description": "Solid binding for @dunky.dev/dom-focus-trap.", "license": "MIT", "repository": { diff --git a/packages/solid/hooks/use-scroll-lock/CHANGELOG.md b/packages/solid/hooks/use-scroll-lock/CHANGELOG.md index 54ea886..16ae53d 100644 --- a/packages/solid/hooks/use-scroll-lock/CHANGELOG.md +++ b/packages/solid/hooks/use-scroll-lock/CHANGELOG.md @@ -1,5 +1,33 @@ # @dunky.dev/solid-use-scroll-lock +## 0.1.1 + +### Patch Changes + +- [#48](https://github.com/dunky-dev/ui/pull/48) [`4208569`](https://github.com/dunky-dev/ui/commit/4208569ecad4b141ecdf814ae195bcc0e14a7afc) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `useScrollLock` now treats a `null` target as "no target yet" and locks + nothing. Previously `null` collapsed into "the page body", so passing a + not-yet-resolved element (e.g. `ref.current` on the first run) locked the + page instead of the intended container — and never corrected itself. An + omitted target still means the page body. + + Pass the element through something reactive so the lock engages once the + node resolves — in React hold it in state (a ref populating doesn't + re-render), in Solid pass a signal-backed element (a plain `ref` read is + not reactive): + + ```tsx + // React + const [panel, setPanel] = useState(null) + useScrollLock(open, panel) // locks nothing until the node resolves + + // Solid + const [panel, setPanel] = createSignal(null) + useScrollLock(open, panel) + ``` + +- Updated dependencies [[`4208569`](https://github.com/dunky-dev/ui/commit/4208569ecad4b141ecdf814ae195bcc0e14a7afc), [`9d93cfc`](https://github.com/dunky-dev/ui/commit/9d93cfc638c75e8ee8c48a1ad51b2537645680cf)]: + - @dunky.dev/dom-scroll-lock@0.1.2 + ## 0.1.0 ### Minor Changes diff --git a/packages/solid/hooks/use-scroll-lock/package.json b/packages/solid/hooks/use-scroll-lock/package.json index fef89ef..11cab90 100644 --- a/packages/solid/hooks/use-scroll-lock/package.json +++ b/packages/solid/hooks/use-scroll-lock/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/solid-use-scroll-lock", - "version": "0.1.0", + "version": "0.1.1", "description": "Solid binding for @dunky.dev/dom-scroll-lock.", "license": "MIT", "repository": {