Skip to content

a11y(2.4.3): inert-based focus containment + restore; drop trap from native dialogs#3598

Open
ardiewen wants to merge 8 commits into
mainfrom
wcag/2.4.3-focus-trap-inert
Open

a11y(2.4.3): inert-based focus containment + restore; drop trap from native dialogs#3598
ardiewen wants to merge 8 commits into
mainfrom
wcag/2.4.3-focus-trap-inert

Conversation

@ardiewen

@ardiewen ardiewen commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Alternative to #3536. Fixes WCAG 2.4.3 (Focus Order) using platform primitives instead of the hand-rolled keydown trap.

focus-trap.ts did two jobs — Tab containment + focus restoration — both of which have native equivalents:

  • <dialog> + showModal() (holocene/modal, command-palette/modal) already traps and restores focus natively → removed use:focusTrap entirely.
  • Non-dialog (drawer — no overlay, maximizable) needs help → the action now sets inert on background content (capturing/restoring focus around it). inert also blocks pointer + assistive-tech access, which the old keydown trap (first/last Tab only) did not.
  • maximizable additionally captures focus in the component before toggling, because maximizing re-renders CodeMirror and blurs to <body> before the action runs (the action alone — and a11y(2.4.3): focus-trap — capture and restore focus to the trigger element on close #3536 — would restore to <body>).

Net: −44 lines of trap machinery (keydown handlers, MutationObserver, first/last tracking).

Testing

  • Unit: +4 tests (inert on activate / clear on destroy, capture+restore, toggle lifecycle, preserve pre-existing inert). pnpm check clean, suite green.
  • Browser-verified end-to-end:
    • Modal — outside-focus blocked natively, Esc restores focus to trigger (with focusTrap removed).
    • Drawer — real inert blocks background focus; close → inert cleared + focus restored to trigger.
    • Maximizable — real inert containment; focus restored to the maximize button via both Esc and the minimize button.
  • NVDA/VoiceOver pass (background unreachable while a drawer/maximized surface is open).

inert is Baseline (Chrome 102+/FF 112+/Safari 15.5+), same era as showModal().

A11y-Audit-Ref: 2.4.3-focus-restoration-on-close

🤖 Generated with Claude Code

…native dialogs

Replaces the keydown-wrapping focus trap with an `inert`-based approach and
removes the trap entirely from the two `<dialog>`+`showModal()` consumers,
which already get focus containment and restoration natively.

- `focus-trap.ts`: `inertBackground()` walks node→<body> marking sibling
  content `inert` (clearing only what it set), and the action now captures the
  pre-activation focus and restores it on deactivate. This delegates Tab
  containment to the platform — and `inert` also blocks pointer + assistive-tech
  access to the background, which the old keydown trap (first/last Tab only) did
  not, notably for `drawer.svelte`, which has no overlay.
- `holocene/modal.svelte`, `command-palette/modal.svelte`: removed `use:focusTrap`.
  Native `<dialog>.showModal()`/`close()` already traps and restores focus.
- Drawer and Maximizable (non-dialog) keep the action, now inert-based.

Verified in-browser: Modal containment + Esc-restore via native dialog; Drawer
and Maximizable inert containment (real focus-blocking) + restore via the action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
holocene Ready Ready Preview, Comment Jul 6, 2026 8:01pm

Request Review

@github-actions github-actions Bot added a11y Accessibility audit PR a11y:bucket-3 Bucket 3: engineer required a11y:sc-2.4.3 labels Jun 24, 2026
…-render)

Maximizing re-renders the CodeMirror content, which blurs focus to <body>
before the focus-trap action activates — so the action captured <body> and
restored focus there on minimize instead of returning it to the trigger.

Capture the focused element in maximizable.svelte before toggling `maximized`,
and restore it after `tick()` on the minimize/Escape paths. Browser-verified:
focus returns to the maximize button via both Escape and the minimize button;
inert containment unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use:focusTrap potentially run before the portalElement is set? Maybe we could make portalElement $state() and add a null check to use:focusTrap just in case.

@ardiewen ardiewen Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified: yes.

Went another direction here: removed the $effect entirely: maximize() now portals to <body> synchronously before setting maximized = true, so the node is already in place when use:focusTrap activates in the same flush. No more $effect phase ordering, so there's nothing to null-check.

Comment thread src/lib/utilities/focus-trap.ts
releaseBackground();
releaseBackground = () => {};

if (previouslyFocused && document.body.contains(previouslyFocused)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we provide a way to pass a fallback or default here? That way if whatever opened the focus trap re-renders (e.g. a CodeBlock) while the user is in it they won't be kicked back to <body>.

Comment thread src/lib/utilities/focus-trap.ts
Comment thread src/lib/utilities/focus-trap.test.ts
The drawer is now a true modal (inert background), so clicking the main
submit button while it is open no longer works. Values bind live to the
form, so closing commits nothing. Also avoids a strict-mode double-match
on the timeout error text (rendered in both the drawer and the form).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y:bucket-3 Bucket 3: engineer required a11y:sc-2.4.3 a11y Accessibility audit PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants