Skip to content

feat(dom-element): share isRendered; initial focus skips unrendered candidates - #50

Merged
ivanbanov merged 4 commits into
fix/overlay-portal-branch-containmentfrom
feat/dom-element-is-rendered
Aug 26, 2026
Merged

feat(dom-element): share isRendered; initial focus skips unrendered candidates#50
ivanbanov merged 4 commits into
fix/overlay-portal-branch-containmentfrom
feat/dom-element-is-rendered

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

Stacked on #49 — review that one first, or read this diff alone (they touch different files).

The bug

getInitialFocus filtered [disabled] and [type="hidden"] but never asked whether the element actually rendered:

export function getInitialFocus(content: HTMLElement): HTMLElement {
  return content.querySelector<HTMLElement>(FORM_FIELD_SELECTOR) ?? content
}

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. The console.warn in openDialogLayer cannot 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 initialFocus hadn't rendered, options.initialFocus ?? getInitialFocus(content) went straight to the window and skipped the form-field step entirely. dom-dialog's SPEC conditions initialFocus on 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-trap already answered this question, privately, in get-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.md and AGENTS.md to allow an edge to a smaller util — never a peer that would import it back:

A DOM util imports nothing from this repo except another DOM util — and only a smaller one, never a peer that would import it back. A shared predicate (isRendered) is one package so its callers can't drift; the direction of such an edge is a design decision, recorded in the importing package's SPEC.md.

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's preventDefault() — on a browser without it, the throw would leave Tab dead entirely.
  • getClientRects().length / offsetParent are geometry, which test environments report as zeros, so nothing here would be covered. offsetParent is also null for a position: fixed element that is plainly visible — a false negative on exactly the overlay content this guards.
  • The hidden attribute is checked with closest, separately from the display walk: hidden="until-found" hides through content-visibility, not display.

Two behavior changes from the extraction

Change Effect
Added an isConnected check A detached element can't take focus, and computed style on one reports the property defaults rather than none, so the walk alone would pass it.
Dropped the trap's container bound on the display walk An ancestor above the container being display: none now 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 getInitialFocus tests: they built content detached, which no longer resolves a field. They mount now.

Tests

268 pass across the repo (was 261): 5 new in dom-element, 2 new in dom-overlay. Both new overlay tests verified load-bearing — stub out the two isRendered guards and exactly those two fail, nothing else.

The dialog-level test falls back to the dialog window when the target refuses focus still passes unchanged, and still earns its place: it designates a disabled input, which is rendered, so it survives the filter and the runtime focus() fallback catches it. That keeps the two mechanisms — filtered candidate vs. refused focus — separately covered.

Not in scope

  • The containmentreachability rename. Still deferred; the tree says containment throughout.

🤖 Generated with Claude Code

ivanbanov and others added 4 commits August 26, 2026 15:16
…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>
@ivanbanov

Copy link
Copy Markdown
Member Author

Round 5 applied in bbb04da — the fieldset-disabled / inert keyboard dead ends, from the post-merge review of the upstream focus-trap PR.

  • @dunky.dev/dom-element gains isFocusable(element): !element.matches(':disabled') && element.closest('[inert]') === null. The pseudo-class resolves fieldset[disabled] ancestry — first-legend exception included — where the IDL property only sees the element's own attribute.
  • The trap's cycle and the initial-focus chain both filter with it, beside isRendered. In the trap a barred candidate was a hard dead end (Tab already preventDefault()-ed, same refused target recomputed forever); in the chain it silently dropped focus to the window past a viable later field.
  • The third upstream finding (radio stop should land on the checked radio) was already correct here — covered by the existing radio-group test.

7 new tests (4 dom-element, 2 trap, 1 overlay), all written first and failing before the fix. 275 pass.

🤖 Generated with Claude Code

@ivanbanov
ivanbanov merged commit e01c6d3 into fix/overlay-portal-branch-containment Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant