feat(dom-element): share isRendered; initial focus skips unrendered candidates - #50
Merged
ivanbanov merged 4 commits intoAug 26, 2026
Conversation
…andidates `getInitialFocus` filtered nothing: a field inside a collapsed section satisfied the selector, `focus()` on it silently no-opped, and 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. A designated `initialFocus` that hadn't rendered was worse: it skipped the form-field step entirely, contradicting "when one is set and can take focus". The focus trap already asked this question, privately. Extract it to `@dunky.dev/dom-element` so the two callers can't drift, and filter every step of the initial-focus chain rather than just the last. The extracted predicate also gains an `isConnected` check and drops the trap's container bound on the display walk — a detached element and one under a hidden ancestor both can't take focus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One entry covered a new package, a refactor with two behavior changes, and a bug fix — three different things for a consumer reading the changelog. Same bump set, one story each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A modal dialog plus a non-modal panel portalled into an app branch that also holds page content: the branch is descended into rather than spared whole, so the article beside the panel is hidden individually while the panel stays reachable. The story's [inert] rule dims what containment hid, making the aria-hidden + inert state visible on the canvas. Mirrored in react and solid; recorded as skipped on native, where containment is the host Modal's own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d inert focus candidates The focusability selectors gate on an element's own attributes, but disabling and inertness also arrive from ancestors: a control inside a fieldset[disabled] subtree, or anything inside [inert], satisfies the selector while a browser refuses to focus it — silently. In the trap that is a hard dead end: the Tab is already preventDefault()-ed when focus is stepped by hand, so every press recomputes the same refused target and focus never moves again. In the initial-focus chain it is the quieter miss: the barred field wins the draw and focus falls to the window past a viable later field. Add isFocusable to @dunky.dev/dom-element — :disabled resolves fieldset ancestry with the native first-legend exception, where the IDL property sees only the element's own attribute — and filter both callers with it, beside the isRendered facet they already share. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
|
Round 5 applied in
7 new tests (4 🤖 Generated with Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #49 — review that one first, or read this diff alone (they touch different files).
The bug
getInitialFocusfiltered[disabled]and[type="hidden"]but never asked whether the element actually rendered:A field inside a collapsed section satisfies the selector and wins the draw.
focus()on it does nothing — and says nothing — so the fallback lands focus on the dialog window. Theconsole.warninopenDialogLayercannot fire, because from its point of view the fallback succeeded. The dialog opens on its window instead of the field the user expects: degraded, not broken, and silent.A second bug in the same class. When the consumer-supplied
initialFocushadn't rendered,options.initialFocus ?? getInitialFocus(content)went straight to the window and skipped the form-field step entirely.dom-dialog's SPEC conditionsinitialFocuson being able to take focus, so the old chain contradicted it. Both are fixed by filtering every step of the chain, not just the last one.Where the predicate lives
dom-focus-trapalready answered this question, privately, inget-focusables.ts— it filters the Tab cycle for exactly the same reason. Two packages answering it separately will drift, so it moves to a new package,@dunky.dev/dom-element, and both import it.That needed a boundary change: ARCHITECTURE.md said "a DOM util imports nothing from this repo", and there was no util→util precedent. Amended in
ARCHITECTURE.mdandAGENTS.mdto allow an edge to a smaller util — never a peer that would import it back:Why a computed-style walk
Load-bearing, so it shouldn't be "modernised" back out — the rationale now lives in
packages/dom/utils/element/SPEC.md:Element.checkVisibility()is the nicest API, but it's recent (Chrome/Edge 105+, Firefox 106+, Safari 17.4+) and the trap resolves focusables after the Tab keydown'spreventDefault()— on a browser without it, the throw would leave Tab dead entirely.getClientRects().length/offsetParentare geometry, which test environments report as zeros, so nothing here would be covered.offsetParentis alsonullfor aposition: fixedelement that is plainly visible — a false negative on exactly the overlay content this guards.hiddenattribute is checked withclosest, separately from the display walk:hidden="until-found"hides throughcontent-visibility, notdisplay.Two behavior changes from the extraction
isConnectedchecknone, so the walk alone would pass it.containerbound on the display walkdisplay: nonenow excludes the focusables too — nothing inside a hidden container can take focus either way, so the trap's documented "no focusables → Tab is a no-op" already covered it.The first one required updating two existing
getInitialFocustests: they builtcontentdetached, which no longer resolves a field. They mount now.Tests
268 pass across the repo (was 261): 5 new in
dom-element, 2 new indom-overlay. Both new overlay tests verified load-bearing — stub out the twoisRenderedguards and exactly those two fail, nothing else.The dialog-level test
falls back to the dialog window when the target refuses focusstill passes unchanged, and still earns its place: it designates a disabled input, which is rendered, so it survives the filter and the runtimefocus()fallback catches it. That keeps the two mechanisms — filtered candidate vs. refused focus — separately covered.Not in scope
containment→reachabilityrename. Still deferred; the tree sayscontainmentthroughout.🤖 Generated with Claude Code