From 31a2af28d3561183df3036a787f390f731875f70 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:20:58 +0000 Subject: [PATCH] chore(release): version packages --- .changeset/dialog-restore-focus.md | 27 ------ .changeset/dom-element-is-rendered.md | 40 --------- .../focus-candidates-barred-elements.md | 27 ------ .../focus-trap-shared-rendered-check.md | 20 ----- .changeset/navigation-spec-scenarios.md | 24 ------ .changeset/overlay-initial-focus-rendered.md | 34 -------- .../overlay-portal-branch-containment.md | 25 ------ README.md | 2 +- packages/dom/components/dialog/CHANGELOG.md | 85 +++++++++++++++++++ packages/dom/components/dialog/package.json | 2 +- packages/dom/utils/element/CHANGELOG.md | 42 +++++++++ packages/dom/utils/element/package.json | 2 +- packages/dom/utils/focus-trap/CHANGELOG.md | 45 ++++++++++ packages/dom/utils/focus-trap/package.json | 2 +- packages/dom/utils/navigation/CHANGELOG.md | 25 ++++++ packages/dom/utils/navigation/package.json | 2 +- packages/dom/utils/overlay/CHANGELOG.md | 81 ++++++++++++++++++ packages/dom/utils/overlay/package.json | 2 +- packages/react/dialog/CHANGELOG.md | 32 +++++++ packages/react/dialog/SPEC.md | 8 +- packages/react/dialog/package.json | 2 +- .../react/hooks/use-focus-trap/CHANGELOG.md | 7 ++ .../react/hooks/use-focus-trap/package.json | 2 +- packages/solid/dialog/CHANGELOG.md | 32 +++++++ packages/solid/dialog/SPEC.md | 8 +- packages/solid/dialog/package.json | 2 +- .../solid/hooks/use-focus-trap/CHANGELOG.md | 7 ++ .../solid/hooks/use-focus-trap/package.json | 2 +- 28 files changed, 374 insertions(+), 215 deletions(-) delete mode 100644 .changeset/dialog-restore-focus.md delete mode 100644 .changeset/dom-element-is-rendered.md delete mode 100644 .changeset/focus-candidates-barred-elements.md delete mode 100644 .changeset/focus-trap-shared-rendered-check.md delete mode 100644 .changeset/navigation-spec-scenarios.md delete mode 100644 .changeset/overlay-initial-focus-rendered.md delete mode 100644 .changeset/overlay-portal-branch-containment.md create mode 100644 packages/dom/utils/element/CHANGELOG.md diff --git a/.changeset/dialog-restore-focus.md b/.changeset/dialog-restore-focus.md deleted file mode 100644 index cc42aaa..0000000 --- a/.changeset/dialog-restore-focus.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@dunky.dev/dom-dialog': minor -'@dunky.dev/react-dialog': minor -'@dunky.dev/solid-dialog': minor ---- - -`Dialog.Content` gains `restoreFocus` — the close-side counterpart to -`initialFocus`. Closing still returns focus to whatever held it before the -dialog opened; `restoreFocus` names where it goes when that holder can't -meaningfully take focus back: focus sat on the body (a pointer press can -leave it there), or on an element removed from the document since. -Typically the dialog's trigger. - -```tsx -// React — a ref, read at close time - - -// Solid — an element or accessor, resolved at close time - trigger}>… -``` - -Before, those two cases silently dropped focus: restoring to the body goes -nowhere, and focusing a disconnected element is a no-op, leaving focus -stranded on the closing layer. The element focused before opening still -always wins when it is meaningful — the fallback never overrides it. At the -DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`, -resolved at close. diff --git a/.changeset/dom-element-is-rendered.md b/.changeset/dom-element-is-rendered.md deleted file mode 100644 index 3407059..0000000 --- a/.changeset/dom-element-is-rendered.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@dunky.dev/dom-element': minor ---- - -New package: `@dunky.dev/dom-element`, framework-free predicates about a single -element. - -`isRendered(element)` answers whether an element actually rendered, and so can -take focus, be pressed, or be read out. Presence in the DOM is not enough: an -element inside a collapsed section still answers `querySelector`, but `focus()` -on it does nothing and reports nothing. - -```ts -import { isRendered } from '@dunky.dev/dom-element' - -for (const field of content.querySelectorAll('input, select, textarea')) { - if (isRendered(field)) { - field.focus() - break - } -} -``` - -Checked: the `hidden` attribute (`hidden="until-found"` included), -`display: none` on the element or any ancestor — `display` doesn't inherit, so -ancestors are walked — `visibility: hidden | collapse`, and being detached. -Not checked: `opacity: 0` and `content-visibility`, which do render, and -rendering is what decides focusability. - -`isFocusable(element)` is the sibling facet: whether anything bars the element -from taking focus. Disabling and inertness also arrive from ancestors — a -control inside a `fieldset[disabled]` subtree (with the native exception for -its first `legend`) or anything inside `[inert]` refuses `focus()` — which a -selector's own-attribute checks (`:not([disabled])`) can't see. The facets are -deliberately narrow and compose; the tab order stays the caller's question. - -It's a package of its own because two utils have to agree on the answers: -`@dunky.dev/dom-focus-trap` filters its Tab cycle with them and -`@dunky.dev/dom-overlay` filters its initial-focus candidates, both guarding -against the same silent `focus()` no-op. diff --git a/.changeset/focus-candidates-barred-elements.md b/.changeset/focus-candidates-barred-elements.md deleted file mode 100644 index 3df9c6a..0000000 --- a/.changeset/focus-candidates-barred-elements.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@dunky.dev/dom-focus-trap': patch -'@dunky.dev/dom-overlay': patch -'@dunky.dev/dom-dialog': patch ---- - -Focus candidates barred by an ancestor are excluded: controls disabled through -`fieldset[disabled]` and anything inside `[inert]`. - -`FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own -attributes (`input:not([disabled])`), but both bars also arrive from -ancestors, so a barred control satisfied the selector while a browser refuses -to focus it — silently. - -In the focus trap that was a hard dead end: the Tab keydown is already -`preventDefault()`-ed when focus is stepped by hand, so every press recomputed -the same refused target and focus never moved again. The cycle now only holds -what a browser would actually focus, keeping the native exception that -controls in a disabled fieldset's first `legend` stay enabled. - -In the initial-focus chain it was the quieter failure mode: the barred field -won the draw, `focus()` no-opped, and focus fell to the overlay window even -when a viable field came later. Every candidate — designated element and form -fields alike — is now also filtered for these bars. - -Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the -`isRendered` filter they already shared. diff --git a/.changeset/focus-trap-shared-rendered-check.md b/.changeset/focus-trap-shared-rendered-check.md deleted file mode 100644 index 6739e21..0000000 --- a/.changeset/focus-trap-shared-rendered-check.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@dunky.dev/dom-focus-trap': patch ---- - -The Tab cycle's rendered check now comes from `@dunky.dev/dom-element` instead -of a private copy. - -`@dunky.dev/dom-overlay` needs the same predicate to filter its initial-focus -candidates, and two packages answering the question separately would drift. -The check itself is unchanged in intent — a non-rendered element is a no-op to -focus, so keeping one in the cycle would stall the trap on it — but sharing it -tightens two cases: - -- A **detached** element is now excluded. It can't take focus, and computed - style on one reports the property defaults rather than `none`, so the display - walk alone let it through. -- A `display: none` ancestor **above the container** now excludes the - focusables under it. The private copy stopped its walk at the container. - Nothing inside a hidden container can take focus either way, so this lands on - the trap's documented behavior for an empty cycle: Tab is a no-op. diff --git a/.changeset/navigation-spec-scenarios.md b/.changeset/navigation-spec-scenarios.md deleted file mode 100644 index 20797c2..0000000 --- a/.changeset/navigation-spec-scenarios.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@dunky.dev/browser-navigation': patch ---- - -`SPEC.md` (shipped with the package) gains a Scenarios section: 26 compact -traces of `interceptBackNavigation` and `watchSpentEntry` behavior, grouped -by one layer, release and consumption, forward/claims/reload, stacked -layers, and timing edges. - -The behavior contract was already fully stated, but as one mechanism per -prose bullet — nothing let a reader replay a concrete flow end to end. -Each trace is a replayable episode in the module's own vocabulary (`arm`, -`release`, `Back`, `Forward`), e.g.: - -``` -arm A -> arm B -> arm C -> Back -> release B -> Forward -> Forward - => the Back closes C; the first Forward soaks into B's abandoned entry; - the second asks C, which reopens only if it still can without B. -``` - -Two outcomes the prose previously left implicit are now stated outright: a -deliberately released entry absorbs one Forward press (it can't be deleted, -only left to soak the traversal), and any re-plant — a new layer or a -veto's re-arm — truncates every parked Forward watch above it. diff --git a/.changeset/overlay-initial-focus-rendered.md b/.changeset/overlay-initial-focus-rendered.md deleted file mode 100644 index e39f292..0000000 --- a/.changeset/overlay-initial-focus-rendered.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@dunky.dev/dom-overlay': patch -'@dunky.dev/dom-dialog': patch ---- - -Initial focus now skips a candidate that didn't render. - -`getInitialFocus` filtered `[disabled]` and `[type="hidden"]` but never asked -whether the element actually rendered. A field inside a collapsed section -satisfied the selector and won the draw; `focus()` on it did nothing — and said -nothing — so focus fell back to the dialog window, with the fallback's warning -unable to fire, because from its point of view the fallback had succeeded. The -overlay opened on its window instead of the field: degraded, not broken, and -silent. - -A designated `initialFocus` that hadn't rendered was worse. It went straight to -the window and skipped the form-field step entirely, contradicting the -documented "when one is set **and can take focus**". So `getInitialFocus` now -takes the designated element as a second argument and resolves the whole chain -in one call, filtering every step rather than just the last: - -```ts -// designated -> first form field -> the overlay window itself -getInitialFocus(content, designatedElement).focus({ preventScroll: true }) -``` - -Callers that were writing `initialFocus ?? getInitialFocus(content)` should -pass the designated element in instead — the `??` is what spent it on a -candidate that couldn't take focus. `@dunky.dev/dom-dialog` does this for every -DOM substrate already, so a dialog's `initialFocus` inherits the fix without a -change on the consumer's side. - -The predicate is `isRendered` from `@dunky.dev/dom-element`, shared with the -focus trap so the two can't disagree on what counts as rendered. diff --git a/.changeset/overlay-portal-branch-containment.md b/.changeset/overlay-portal-branch-containment.md deleted file mode 100644 index 26c33d5..0000000 --- a/.changeset/overlay-portal-branch-containment.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@dunky.dev/dom-overlay': patch ---- - -Page content beside a layer portalled into an app branch is now hidden by a -modal layer's containment. - -Containment holds a few elements out of the hiding — the topmost modal layer, -its backdrop, and the layers stacked above it — and it matched them by -ancestry, so a branch that _contained_ one was skipped whole. Where a layer -sits is the consumer's choice: `container` on the Portal part lets it land -anywhere, and when that branch also held page content, the entire branch went -unhidden — the page reachable by pointer, keyboard, and screen reader for as -long as the layer was open. - -```tsx -// The menu lands inside the app branch, beside the page content. - -``` - -Hiding now descends from the body instead of walking up from the layer. A -branch that holds one of those retained elements is descended into rather than -spared, so the content beside it is hidden individually while the layer itself -stays reachable. A layer at or above the body is a no-op — nothing sits -outside it. diff --git a/README.md b/README.md index 13f1a4c..51c3a18 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ up there with the same behavior and the same a11y. The engine that runs the core machines lives in its own repo: [dunky-dev/state-machine](https://github.com/dunky-dev/state-machine). Here it -is just a dependency — this repo defines *what* each primitive does and how it +is just a dependency — this repo defines _what_ each primitive does and how it renders, not how machines execute. ## Layout diff --git a/packages/dom/components/dialog/CHANGELOG.md b/packages/dom/components/dialog/CHANGELOG.md index 73f6025..47c76a9 100644 --- a/packages/dom/components/dialog/CHANGELOG.md +++ b/packages/dom/components/dialog/CHANGELOG.md @@ -1,5 +1,90 @@ # @dunky.dev/dom-dialog +## 0.3.0 + +### Minor Changes + +- [#53](https://github.com/dunky-dev/ui/pull/53) [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `Dialog.Content` gains `restoreFocus` — the close-side counterpart to + `initialFocus`. Closing still returns focus to whatever held it before the + dialog opened; `restoreFocus` names where it goes when that holder can't + meaningfully take focus back: focus sat on the body (a pointer press can + leave it there), or on an element removed from the document since. + Typically the dialog's trigger. + + ```tsx + // React — a ref, read at close time + + + // Solid — an element or accessor, resolved at close time + trigger}>… + ``` + + Before, those two cases silently dropped focus: restoring to the body goes + nowhere, and focusing a disconnected element is a no-op, leaving focus + stranded on the closing layer. The element focused before opening still + always wins when it is meaningful — the fallback never overrides it. At the + DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`, + resolved at close. + +### Patch Changes + +- [#50](https://github.com/dunky-dev/ui/pull/50) [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Focus candidates barred by an ancestor are excluded: controls disabled through + `fieldset[disabled]` and anything inside `[inert]`. + + `FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own + attributes (`input:not([disabled])`), but both bars also arrive from + ancestors, so a barred control satisfied the selector while a browser refuses + to focus it — silently. + + In the focus trap that was a hard dead end: the Tab keydown is already + `preventDefault()`-ed when focus is stepped by hand, so every press recomputed + the same refused target and focus never moved again. The cycle now only holds + what a browser would actually focus, keeping the native exception that + controls in a disabled fieldset's first `legend` stay enabled. + + In the initial-focus chain it was the quieter failure mode: the barred field + won the draw, `focus()` no-opped, and focus fell to the overlay window even + when a viable field came later. Every candidate — designated element and form + fields alike — is now also filtered for these bars. + + Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the + `isRendered` filter they already shared. + +- [#50](https://github.com/dunky-dev/ui/pull/50) [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Initial focus now skips a candidate that didn't render. + + `getInitialFocus` filtered `[disabled]` and `[type="hidden"]` but never asked + whether the element actually rendered. A field inside a collapsed section + satisfied the selector and won the draw; `focus()` on it did nothing — and said + nothing — so focus fell back to the dialog window, with the fallback's warning + unable to fire, because from its point of view the fallback had succeeded. The + overlay opened on its window instead of the field: degraded, not broken, and + silent. + + A designated `initialFocus` that hadn't rendered was worse. It went straight to + the window and skipped the form-field step entirely, contradicting the + documented "when one is set **and can take focus**". So `getInitialFocus` now + takes the designated element as a second argument and resolves the whole chain + in one call, filtering every step rather than just the last: + + ```ts + // designated -> first form field -> the overlay window itself + getInitialFocus(content, designatedElement).focus({ preventScroll: true }) + ``` + + Callers that were writing `initialFocus ?? getInitialFocus(content)` should + pass the designated element in instead — the `??` is what spent it on a + candidate that couldn't take focus. `@dunky.dev/dom-dialog` does this for every + DOM substrate already, so a dialog's `initialFocus` inherits the fix without a + change on the consumer's side. + + The predicate is `isRendered` from `@dunky.dev/dom-element`, shared with the + focus trap so the two can't disagree on what counts as rendered. + +- Updated dependencies [[`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814), [`f5becd4`](https://github.com/dunky-dev/ui/commit/f5becd4f5e08e0fbf0930e65961c92e281f9e463), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814), [`772a7df`](https://github.com/dunky-dev/ui/commit/772a7dfe18a58d070c1872b48ef8e6acec180723)]: + - @dunky.dev/dom-focus-trap@0.1.3 + - @dunky.dev/dom-overlay@0.2.1 + - @dunky.dev/browser-navigation@0.2.1 + ## 0.2.0 ### Minor Changes diff --git a/packages/dom/components/dialog/package.json b/packages/dom/components/dialog/package.json index 91ebebf..9738d8d 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.2.0", + "version": "0.3.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/element/CHANGELOG.md b/packages/dom/utils/element/CHANGELOG.md new file mode 100644 index 0000000..a0fb555 --- /dev/null +++ b/packages/dom/utils/element/CHANGELOG.md @@ -0,0 +1,42 @@ +# @dunky.dev/dom-element + +## 0.1.0 + +### Minor Changes + +- [#50](https://github.com/dunky-dev/ui/pull/50) [`6c249f9`](https://github.com/dunky-dev/ui/commit/6c249f96dd6e3f821d4b71bae250f1d94e40298c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - New package: `@dunky.dev/dom-element`, framework-free predicates about a single + element. + + `isRendered(element)` answers whether an element actually rendered, and so can + take focus, be pressed, or be read out. Presence in the DOM is not enough: an + element inside a collapsed section still answers `querySelector`, but `focus()` + on it does nothing and reports nothing. + + ```ts + import { isRendered } from '@dunky.dev/dom-element' + + for (const field of content.querySelectorAll('input, select, textarea')) { + if (isRendered(field)) { + field.focus() + break + } + } + ``` + + Checked: the `hidden` attribute (`hidden="until-found"` included), + `display: none` on the element or any ancestor — `display` doesn't inherit, so + ancestors are walked — `visibility: hidden | collapse`, and being detached. + Not checked: `opacity: 0` and `content-visibility`, which do render, and + rendering is what decides focusability. + + `isFocusable(element)` is the sibling facet: whether anything bars the element + from taking focus. Disabling and inertness also arrive from ancestors — a + control inside a `fieldset[disabled]` subtree (with the native exception for + its first `legend`) or anything inside `[inert]` refuses `focus()` — which a + selector's own-attribute checks (`:not([disabled])`) can't see. The facets are + deliberately narrow and compose; the tab order stays the caller's question. + + It's a package of its own because two utils have to agree on the answers: + `@dunky.dev/dom-focus-trap` filters its Tab cycle with them and + `@dunky.dev/dom-overlay` filters its initial-focus candidates, both guarding + against the same silent `focus()` no-op. diff --git a/packages/dom/utils/element/package.json b/packages/dom/utils/element/package.json index 0b5ffd8..998323e 100644 --- a/packages/dom/utils/element/package.json +++ b/packages/dom/utils/element/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/dom-element", - "version": "0.0.0", + "version": "0.1.0", "description": "Framework-free element predicates — the DOM questions every primitive asks.", "license": "MIT", "repository": { diff --git a/packages/dom/utils/focus-trap/CHANGELOG.md b/packages/dom/utils/focus-trap/CHANGELOG.md index b674905..e6eab5a 100644 --- a/packages/dom/utils/focus-trap/CHANGELOG.md +++ b/packages/dom/utils/focus-trap/CHANGELOG.md @@ -1,5 +1,50 @@ # @dunky.dev/dom-focus-trap +## 0.1.3 + +### Patch Changes + +- [#50](https://github.com/dunky-dev/ui/pull/50) [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Focus candidates barred by an ancestor are excluded: controls disabled through + `fieldset[disabled]` and anything inside `[inert]`. + + `FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own + attributes (`input:not([disabled])`), but both bars also arrive from + ancestors, so a barred control satisfied the selector while a browser refuses + to focus it — silently. + + In the focus trap that was a hard dead end: the Tab keydown is already + `preventDefault()`-ed when focus is stepped by hand, so every press recomputed + the same refused target and focus never moved again. The cycle now only holds + what a browser would actually focus, keeping the native exception that + controls in a disabled fieldset's first `legend` stay enabled. + + In the initial-focus chain it was the quieter failure mode: the barred field + won the draw, `focus()` no-opped, and focus fell to the overlay window even + when a viable field came later. Every candidate — designated element and form + fields alike — is now also filtered for these bars. + + Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the + `isRendered` filter they already shared. + +- [#50](https://github.com/dunky-dev/ui/pull/50) [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814) Thanks [@ivanbanov](https://github.com/ivanbanov)! - The Tab cycle's rendered check now comes from `@dunky.dev/dom-element` instead + of a private copy. + + `@dunky.dev/dom-overlay` needs the same predicate to filter its initial-focus + candidates, and two packages answering the question separately would drift. + The check itself is unchanged in intent — a non-rendered element is a no-op to + focus, so keeping one in the cycle would stall the trap on it — but sharing it + tightens two cases: + - A **detached** element is now excluded. It can't take focus, and computed + style on one reports the property defaults rather than `none`, so the display + walk alone let it through. + - A `display: none` ancestor **above the container** now excludes the + focusables under it. The private copy stopped its walk at the container. + Nothing inside a hidden container can take focus either way, so this lands on + the trap's documented behavior for an empty cycle: Tab is a no-op. + +- Updated dependencies [[`6c249f9`](https://github.com/dunky-dev/ui/commit/6c249f96dd6e3f821d4b71bae250f1d94e40298c)]: + - @dunky.dev/dom-element@0.1.0 + ## 0.1.2 ### Patch Changes diff --git a/packages/dom/utils/focus-trap/package.json b/packages/dom/utils/focus-trap/package.json index a8a40e6..eecad59 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.2", + "version": "0.1.3", "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 1158995..fcf6649 100644 --- a/packages/dom/utils/navigation/CHANGELOG.md +++ b/packages/dom/utils/navigation/CHANGELOG.md @@ -1,5 +1,30 @@ # @dunky.dev/browser-navigation +## 0.2.1 + +### Patch Changes + +- [#52](https://github.com/dunky-dev/ui/pull/52) [`f5becd4`](https://github.com/dunky-dev/ui/commit/f5becd4f5e08e0fbf0930e65961c92e281f9e463) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `SPEC.md` (shipped with the package) gains a Scenarios section: 26 compact + traces of `interceptBackNavigation` and `watchSpentEntry` behavior, grouped + by one layer, release and consumption, forward/claims/reload, stacked + layers, and timing edges. + + The behavior contract was already fully stated, but as one mechanism per + prose bullet — nothing let a reader replay a concrete flow end to end. + Each trace is a replayable episode in the module's own vocabulary (`arm`, + `release`, `Back`, `Forward`), e.g.: + + ``` + arm A -> arm B -> arm C -> Back -> release B -> Forward -> Forward + => the Back closes C; the first Forward soaks into B's abandoned entry; + the second asks C, which reopens only if it still can without B. + ``` + + Two outcomes the prose previously left implicit are now stated outright: a + deliberately released entry absorbs one Forward press (it can't be deleted, + only left to soak the traversal), and any re-plant — a new layer or a + veto's re-arm — truncates every parked Forward watch above it. + ## 0.2.0 ### Minor Changes diff --git a/packages/dom/utils/navigation/package.json b/packages/dom/utils/navigation/package.json index 9af86d6..fd942f0 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.2.0", + "version": "0.2.1", "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 c8878ec..8b220f7 100644 --- a/packages/dom/utils/overlay/CHANGELOG.md +++ b/packages/dom/utils/overlay/CHANGELOG.md @@ -1,5 +1,86 @@ # @dunky.dev/dom-overlay +## 0.2.1 + +### Patch Changes + +- [#50](https://github.com/dunky-dev/ui/pull/50) [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Focus candidates barred by an ancestor are excluded: controls disabled through + `fieldset[disabled]` and anything inside `[inert]`. + + `FOCUSABLE_SELECTOR` and the form-field selector gate on an element's own + attributes (`input:not([disabled])`), but both bars also arrive from + ancestors, so a barred control satisfied the selector while a browser refuses + to focus it — silently. + + In the focus trap that was a hard dead end: the Tab keydown is already + `preventDefault()`-ed when focus is stepped by hand, so every press recomputed + the same refused target and focus never moved again. The cycle now only holds + what a browser would actually focus, keeping the native exception that + controls in a disabled fieldset's first `legend` stay enabled. + + In the initial-focus chain it was the quieter failure mode: the barred field + won the draw, `focus()` no-opped, and focus fell to the overlay window even + when a viable field came later. Every candidate — designated element and form + fields alike — is now also filtered for these bars. + + Both use the new `isFocusable` from `@dunky.dev/dom-element`, beside the + `isRendered` filter they already shared. + +- [#50](https://github.com/dunky-dev/ui/pull/50) [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Initial focus now skips a candidate that didn't render. + + `getInitialFocus` filtered `[disabled]` and `[type="hidden"]` but never asked + whether the element actually rendered. A field inside a collapsed section + satisfied the selector and won the draw; `focus()` on it did nothing — and said + nothing — so focus fell back to the dialog window, with the fallback's warning + unable to fire, because from its point of view the fallback had succeeded. The + overlay opened on its window instead of the field: degraded, not broken, and + silent. + + A designated `initialFocus` that hadn't rendered was worse. It went straight to + the window and skipped the form-field step entirely, contradicting the + documented "when one is set **and can take focus**". So `getInitialFocus` now + takes the designated element as a second argument and resolves the whole chain + in one call, filtering every step rather than just the last: + + ```ts + // designated -> first form field -> the overlay window itself + getInitialFocus(content, designatedElement).focus({ preventScroll: true }) + ``` + + Callers that were writing `initialFocus ?? getInitialFocus(content)` should + pass the designated element in instead — the `??` is what spent it on a + candidate that couldn't take focus. `@dunky.dev/dom-dialog` does this for every + DOM substrate already, so a dialog's `initialFocus` inherits the fix without a + change on the consumer's side. + + The predicate is `isRendered` from `@dunky.dev/dom-element`, shared with the + focus trap so the two can't disagree on what counts as rendered. + +- [#49](https://github.com/dunky-dev/ui/pull/49) [`772a7df`](https://github.com/dunky-dev/ui/commit/772a7dfe18a58d070c1872b48ef8e6acec180723) Thanks [@ivanbanov](https://github.com/ivanbanov)! - Page content beside a layer portalled into an app branch is now hidden by a + modal layer's containment. + + Containment holds a few elements out of the hiding — the topmost modal layer, + its backdrop, and the layers stacked above it — and it matched them by + ancestry, so a branch that _contained_ one was skipped whole. Where a layer + sits is the consumer's choice: `container` on the Portal part lets it land + anywhere, and when that branch also held page content, the entire branch went + unhidden — the page reachable by pointer, keyboard, and screen reader for as + long as the layer was open. + + ```tsx + // The menu lands inside the app branch, beside the page content. + + ``` + + Hiding now descends from the body instead of walking up from the layer. A + branch that holds one of those retained elements is descended into rather than + spared, so the content beside it is hidden individually while the layer itself + stays reachable. A layer at or above the body is a no-op — nothing sits + outside it. + +- Updated dependencies [[`6c249f9`](https://github.com/dunky-dev/ui/commit/6c249f96dd6e3f821d4b71bae250f1d94e40298c)]: + - @dunky.dev/dom-element@0.1.0 + ## 0.2.0 ### Minor Changes diff --git a/packages/dom/utils/overlay/package.json b/packages/dom/utils/overlay/package.json index 2672b41..4a2d68f 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.2.0", + "version": "0.2.1", "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/react/dialog/CHANGELOG.md b/packages/react/dialog/CHANGELOG.md index 5a8051d..462353d 100644 --- a/packages/react/dialog/CHANGELOG.md +++ b/packages/react/dialog/CHANGELOG.md @@ -1,5 +1,37 @@ # @dunky.dev/react-dialog +## 0.5.0 + +### Minor Changes + +- [#53](https://github.com/dunky-dev/ui/pull/53) [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `Dialog.Content` gains `restoreFocus` — the close-side counterpart to + `initialFocus`. Closing still returns focus to whatever held it before the + dialog opened; `restoreFocus` names where it goes when that holder can't + meaningfully take focus back: focus sat on the body (a pointer press can + leave it there), or on an element removed from the document since. + Typically the dialog's trigger. + + ```tsx + // React — a ref, read at close time + + + // Solid — an element or accessor, resolved at close time + trigger}>… + ``` + + Before, those two cases silently dropped focus: restoring to the body goes + nowhere, and focusing a disconnected element is a no-op, leaving focus + stranded on the closing layer. The element focused before opening still + always wins when it is meaningful — the fallback never overrides it. At the + DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`, + resolved at close. + +### Patch Changes + +- Updated dependencies [[`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0), [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]: + - @dunky.dev/dom-dialog@0.3.0 + - @dunky.dev/react-use-focus-trap@0.1.3 + ## 0.4.0 ### Minor Changes diff --git a/packages/react/dialog/SPEC.md b/packages/react/dialog/SPEC.md index f874feb..4e7b816 100644 --- a/packages/react/dialog/SPEC.md +++ b/packages/react/dialog/SPEC.md @@ -149,11 +149,11 @@ The positioning + scroll layer around the dialog window. The dialog window; renders a `
` with the `dialog` role. -| Prop | Type | Default | Description | -| -------------- | -------------------------------- | ----------------- | ------------------------------------------- | -| `initialFocus` | `RefObject` | the dialog window | The element to focus when the dialog opens. | +| Prop | Type | Default | Description | +| -------------- | -------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `initialFocus` | `RefObject` | the dialog window | The element to focus when the dialog opens. | | `restoreFocus` | `RefObject` | — | Focused on close when nothing meaningful held focus before opening (the body, or an element since removed). Typically the trigger. | -| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. | ### `Dialog.Title` diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json index 2d9d979..b22e7cc 100644 --- a/packages/react/dialog/package.json +++ b/packages/react/dialog/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/react-dialog", - "version": "0.4.0", + "version": "0.5.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 82b4e2e..308f7a5 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.3 + +### Patch Changes + +- Updated dependencies [[`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]: + - @dunky.dev/dom-focus-trap@0.1.3 + ## 0.1.2 ### Patch Changes diff --git a/packages/react/hooks/use-focus-trap/package.json b/packages/react/hooks/use-focus-trap/package.json index 9bd09e1..b01d277 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.2", + "version": "0.1.3", "description": "React binding for @dunky.dev/dom-focus-trap.", "license": "MIT", "repository": { diff --git a/packages/solid/dialog/CHANGELOG.md b/packages/solid/dialog/CHANGELOG.md index 6182145..4485e1b 100644 --- a/packages/solid/dialog/CHANGELOG.md +++ b/packages/solid/dialog/CHANGELOG.md @@ -1,5 +1,37 @@ # @dunky.dev/solid-dialog +## 0.3.0 + +### Minor Changes + +- [#53](https://github.com/dunky-dev/ui/pull/53) [`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0) Thanks [@ivanbanov](https://github.com/ivanbanov)! - `Dialog.Content` gains `restoreFocus` — the close-side counterpart to + `initialFocus`. Closing still returns focus to whatever held it before the + dialog opened; `restoreFocus` names where it goes when that holder can't + meaningfully take focus back: focus sat on the body (a pointer press can + leave it there), or on an element removed from the document since. + Typically the dialog's trigger. + + ```tsx + // React — a ref, read at close time + + + // Solid — an element or accessor, resolved at close time + trigger}>… + ``` + + Before, those two cases silently dropped focus: restoring to the body goes + nowhere, and focusing a disconnected element is a no-op, leaving focus + stranded on the closing layer. The element focused before opening still + always wins when it is meaningful — the fallback never overrides it. At the + DOM layer, `openDialogLayer` takes `restoreFocus?: () => HTMLElement | null`, + resolved at close. + +### Patch Changes + +- Updated dependencies [[`2cc4a1b`](https://github.com/dunky-dev/ui/commit/2cc4a1b00966526497da15b4123060a2b2c104f0), [`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]: + - @dunky.dev/dom-dialog@0.3.0 + - @dunky.dev/solid-use-focus-trap@0.1.2 + ## 0.2.0 ### Minor Changes diff --git a/packages/solid/dialog/SPEC.md b/packages/solid/dialog/SPEC.md index f0f98ea..4166469 100644 --- a/packages/solid/dialog/SPEC.md +++ b/packages/solid/dialog/SPEC.md @@ -155,11 +155,11 @@ The positioning + scroll layer around the dialog window. The dialog window; renders a `
` with the `dialog` role. -| Prop | Type | Default | Description | -| -------------- | --------------------------------------------------------- | ----------------- | ------------------------------------------------------------------- | -| `initialFocus` | `HTMLElement \| (() => HTMLElement \| null \| undefined)` | the dialog window | The element to focus when the dialog opens — resolved at open time. | +| Prop | Type | Default | Description | +| -------------- | --------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `initialFocus` | `HTMLElement \| (() => HTMLElement \| null \| undefined)` | the dialog window | The element to focus when the dialog opens — resolved at open time. | | `restoreFocus` | `HTMLElement \| (() => HTMLElement \| null \| undefined)` | — | Focused on close when nothing meaningful held focus before opening (the body, or an element since removed) — resolved at close time. Typically the trigger. | -| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. | +| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. | ### `Dialog.Title` diff --git a/packages/solid/dialog/package.json b/packages/solid/dialog/package.json index c885d16..8d0e5a6 100644 --- a/packages/solid/dialog/package.json +++ b/packages/solid/dialog/package.json @@ -1,6 +1,6 @@ { "name": "@dunky.dev/solid-dialog", - "version": "0.2.0", + "version": "0.3.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 0115914..f4cebc8 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.2 + +### Patch Changes + +- Updated dependencies [[`bbb04da`](https://github.com/dunky-dev/ui/commit/bbb04da6397f5e9a1641cbea9e2eb0c082c2965c), [`bfbe863`](https://github.com/dunky-dev/ui/commit/bfbe86307b07bfc8d55207c70cfdc328693e5814)]: + - @dunky.dev/dom-focus-trap@0.1.3 + ## 0.1.1 ### Patch Changes diff --git a/packages/solid/hooks/use-focus-trap/package.json b/packages/solid/hooks/use-focus-trap/package.json index 7ffa5c4..402b0f0 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.1", + "version": "0.1.2", "description": "Solid binding for @dunky.dev/dom-focus-trap.", "license": "MIT", "repository": {