a11y(2.4.3): inert-based focus containment + restore; drop trap from native dialogs#3598
a11y(2.4.3): inert-based focus containment + restore; drop trap from native dialogs#3598ardiewen wants to merge 2 commits into
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…-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); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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)', () => { |
There was a problem hiding this comment.
Wouldn't hurt to add a test for overlapping traps (e.g. if there is a Maximizable component inside a Drawer component).
Alternative to #3536. Fixes WCAG 2.4.3 (Focus Order) using platform primitives instead of the hand-rolled keydown trap.
focus-trap.tsdid 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 → removeduse:focusTrapentirely.drawer— no overlay,maximizable) needs help → the action now setsinerton background content (capturing/restoring focus around it).inertalso blocks pointer + assistive-tech access, which the old keydown trap (first/lastTabonly) did not.maximizableadditionally 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
inert).pnpm checkclean, suite green.focusTrapremoved).inertblocks background focus; close → inert cleared + focus restored to trigger.inertcontainment; focus restored to the maximize button via both Esc and the minimize button.inertis Baseline (Chrome 102+/FF 112+/Safari 15.5+), same era asshowModal().A11y-Audit-Ref: 2.4.3-focus-restoration-on-close
🤖 Generated with Claude Code