Skip to content

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

Open
ardiewen wants to merge 2 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 2 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 Jun 24, 2026 7:44pm

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>

const originalParent = wrapperEl.parentNode;
const originalNextSibling = wrapperEl.nextSibling;
portalElement = portal(wrapperEl, document.body);

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.

firstFocusable?.addEventListener('keydown', onKeydown);
lastFocusable?.addEventListener('keydown', onKeydown);
const [firstFocusable] = getFocusableElements(node);
(firstFocusable ?? node).focus();

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.

NIT: Might be nice to add tabindex="-1" to components like maximizable.svelte and drawer.svelte since by default they have none. Although, it shouldn't ever really be an issue because they will likely always contain a focusable child element.

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>.

};
};

export const focusTrap = (node: HTMLElement, enabled: boolean) => {

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.

TIL Svelte runs action setup top-to-bottom, because if we change the order of use:portal and use:focusTrap in the Drawer component it silently breaks. Not sure we need to make any changes, just might be worth noting and/or adding some kind of "use portal before focus trap" lint warning.

});
});

describe('focusTrap focus management (inert-based)', () => {

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.

Wouldn't hurt to add a test for overlapping traps (e.g. if there is a Maximizable component inside a Drawer component).

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