Skip to content

fix(dom-overlay): hide by descent from the body, not an ancestor walk - #49

Merged
ivanbanov merged 6 commits into
mainfrom
fix/overlay-portal-branch-containment
Aug 26, 2026
Merged

fix(dom-overlay): hide by descent from the body, not an ancestor walk#49
ivanbanov merged 6 commits into
mainfrom
fix/overlay-portal-branch-containment

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

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:

for (const element of exclude) {
  if (sibling.contains(element)) return true
}

A sibling that contains a retained element was skipped whole. container on 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>:

menu portalled to body        -> app inert: true  | menu inert: false
menu portalled inside the app -> app inert: false | page content reachable: true | menu inert: false

The fix

The bottom-up walk from target becomes a descent from document.body. The retained roots are target plus exclude. For each child of a parent:

  1. the child is a root -> skip it and its subtree entirely
  2. the child retains a root (contains one) -> descend into it rather than skipping the branch
  3. otherwise -> the existing skip checks, then hide

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:

  • isRoot is checked before retainsRoot. Node.contains returns true for the node itself, so a root satisfies retainsRoot too; testing retainsRoot first would descend into the target and hide its own children.
  • The target.contains(document.body) guard. It covers document.body and document.documentElement. The old walk no-opped there because its loop condition was node !== document.body; the descent would otherwise hide the entire page.

stack.ts needs no change — its exclude array and the isConnected guard both still apply.

Tests

Two new tests in tests/containment.test.ts, both verified to earn their place:

Test Verification
hides page content sitting beside a layer portalled into an app branch git checkout HEAD -- src/hide-outside.ts -> only this test fails
hides nothing for a layer at the body — there is no outside delete the target.contains(document.body) guard -> only this test fails

261 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

  • Initial-focus visibility. getInitialFocus filters [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.
  • The containment -> reachability rename. The tree still uses containment throughout. After this round no occurrence of the word means DOM Node.contains any more, so the rename is now uniform if picked up: syncContainment -> syncReachability, the SPEC section heading, the test describes.

🤖 Generated with Claude Code

ivanbanov and others added 2 commits August 26, 2026 14:58
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>
ivanbanov and others added 4 commits August 26, 2026 15:19
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
@ivanbanov
ivanbanov merged commit 997e07e into main 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