fix(dom-overlay): hide by descent from the body, not an ancestor walk - #49
Merged
Conversation
Containment matched its retained elements by ancestry, so a branch that contained one was skipped whole. `container` on the Portal parts puts a layer wherever the consumer wants, and when that branch also held page content the whole branch went unhidden. Descend from the body instead: a branch holding a retained root is recursed into rather than spared, so content beside a portalled layer is hidden individually while the layer stays reachable. A target at or above the body no-ops — the old walk got that from its loop condition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
feat(dom-element): share isRendered; initial focus skips unrendered candidates
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.
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:
A sibling that contains a retained element was skipped whole.
containeron the Portal parts is public API (Dialog.Portal,container?: HTMLElement | null), so a layer can land on an app branch rather than the body — and when that branch also holds page content, the entire branch goes unhidden. The page stays reachable by pointer, keyboard, and screen reader for as long as the layer is open.Reproduced in jsdom before the fix — a dialog open, a non-modal menu portalled into an app branch that also holds an
<article>:The fix
The bottom-up walk from
targetbecomes a descent fromdocument.body. The retained roots aretargetplusexclude. For each child of a parent:Page content beside a retained layer then gets hidden individually while the layer stays reachable.
Two details are load-bearing and shouldn't be simplified back out:
isRootis checked beforeretainsRoot.Node.containsreturns true for the node itself, so a root satisfiesretainsRoottoo; testingretainsRootfirst would descend into the target and hide its own children.target.contains(document.body)guard. It coversdocument.bodyanddocument.documentElement. The old walk no-opped there because its loop condition wasnode !== document.body; the descent would otherwise hide the entire page.stack.tsneeds no change — itsexcludearray and theisConnectedguard both still apply.Tests
Two new tests in
tests/containment.test.ts, both verified to earn their place:hides page content sitting beside a layer portalled into an app branchgit checkout HEAD -- src/hide-outside.ts-> only this test failshides nothing for a layer at the body — there is no outsidetarget.contains(document.body)guard -> only this test fails261 tests pass across the repo (was 259). Every prior test passes unchanged — the rewrite is behaviour-preserving except for the branch case it fixes.
Not in scope
getInitialFocusfilters[disabled]and[type=hidden]but not visibility, so a field in a collapsed section wins the draw, the focus call silently no-ops, and the fallback catches it without firing the warning. Same question as the focus-trap package — to be answered once for both.containment->reachabilityrename. The tree still usescontainmentthroughout. After this round no occurrence of the word means DOMNode.containsany more, so the rename is now uniform if picked up:syncContainment->syncReachability, the SPEC section heading, the test describes.🤖 Generated with Claude Code