Skip to content

fix: harden the layering utils — back guards, containment, focus trap, scroll lock - #48

Merged
ivanbanov merged 4 commits into
mainfrom
fix/layering-review-fixes
Aug 24, 2026
Merged

fix: harden the layering utils — back guards, containment, focus trap, scroll lock#48
ivanbanov merged 4 commits into
mainfrom
fix/layering-review-fixes

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

Review-driven fixes across the four layering DOM utils and their consumers. All behavior-first: every fix landed as a failing test before the source change.

browser-navigation

  • Two guards releasing in the same synchronous turn stranded the swallow counter: the first release's idle check detached the shared popstate listener while the second release's self-caused history.back() was still in flight, leaving the next guard deaf to its first Back press. A pendingReleases counter now keeps the listener alive until every deferred consumption has run.
  • onPopState removed the answered guard positionally with pop(). When a guard's onBack released that same guard synchronously (legal API use), the pop evicted the guard beneath it instead. Removal is now by identity.

dom-overlay

  • hideExitingLayer with a boundary that isn't an ancestor of the content walked to the document root and inerted <html>, taking the whole page out for the exit window. The walk now detects exhaustion and scopes the miss to the content itself.
  • Both hiders skipped any element bearing aria-hidden, but aria-hidden="false" asserts visible — the opposite of author-hidden — so such elements stayed exposed behind a modal. The skip now tests the value, and the undo restores the authored value instead of removing the attribute.

dom-dialog

  • When neither the initial focus target nor the dialog window can take focus, focus silently stayed outside the layer — against the APG modal-dialog pattern. openDialogLayer now warns that the window needs tabindex="-1".

dom-focus-trap

  • The keydown listener was bound to the container, so a Tab pressed while focus sat on the trigger or body escaped to native tab order. It now binds to the document in the capture phase (bubble delivery can be cut off by stopPropagation in the subtree) and reads ownerDocument.activeElement for iframe safety.
  • getFocusables was selector-only, so hidden / display: none / visibility: hidden elements entered the cycle and stalled the trap in a real browser. Candidates are now filtered with element.checkVisibility(); jsdom's missing implementation is shimmed in a shared vitest.setup.ts, keeping the package source browser-spec-only.
  • Every same-name radio was its own cycle stop; browsers give a radio group one (the checked radio, else the first), per the APG radio-group pattern. The trap reproduces that.

dom-scroll-lock + use-scroll-lock (react, solid)

  • Scrollbar compensation assigned the footprint over padding-inline-end / padding-block-end, erasing padding the target already had. It now adds the footprint to the computed padding.
  • Release restores saved inline styles via three unconditional setProperty calls — per CSSOM a saved '' removes the declaration, so no branching.
  • Both hooks collapsed null into "the body", so useScrollLock(true, ref.current) locked the page on the first run and never corrected itself. null now means "no target yet" and locks nothing; undefined still means the body. The Dialog bindings, whose context uses null for "page dialog → body", map it to undefined at the call site.

Six changesets (all patch). Full suite: 219/219, typecheck, lint, and format clean.

🤖 Generated with Claude Code

ivanbanov and others added 4 commits August 24, 2026 18:17
… releases and remove guards by identity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…="false", warn on a silent focus miss

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s, one stop per radio group

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, setProperty restore, null target locks nothing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov
ivanbanov merged commit 58df2f5 into main Aug 24, 2026
6 checks passed
@ivanbanov
ivanbanov deleted the fix/layering-review-fixes branch August 24, 2026 16:19
ivanbanov added a commit that referenced this pull request Aug 24, 2026
main's #46 renamed @dunky.dev/dom-navigation to @dunky.dev/browser-navigation
and #48 hardened the same interceptor this branch extends. Two conflicts, both
unions rather than choices:

- detachWhenIdle: the listener now outlives armed guards, parked watchers,
  in-flight self-caused pops, AND undecided releases. Dropping the parked
  check would end a Forward watch the moment its layer closed.
- The unwind loop: main removes the guard by identity (onBack may have
  released it, and a positional pop would evict the guard beneath). Parking
  now happens only when that removal actually found the guard — a layer that
  released itself inside onBack tore itself down rather than closing, so
  there is nothing to offer a reopen to. New test and SPEC clause for it.

The changeset follows the rename. Full suite (240), typecheck, lint, format,
and build pass; SPECs reconciled against the merged code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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