a11y(2.4.3): inert-based focus containment + restore; drop trap from native dialogs#3598
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>
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.
There was a problem hiding this comment.
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.
| 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>.
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>
laurakwhit
left a comment
There was a problem hiding this comment.
Two small comments that might be worth looking into, but otherwise 🚀
| if ( | ||
| sibling !== current && | ||
| sibling instanceof HTMLElement && | ||
| !sibling.inert |
There was a problem hiding this comment.
Do we need to add a check for aria-live here as well? E.g. would a toast fired from within a drawer get inerted/unannounced?
There was a problem hiding this comment.
Yes, this is a real concern. I'm going to address this as a follow up ticket (DT-4252)
| portalElement?.destroy(); | ||
| portalElement = null; | ||
| if (wrapperEl && originalParent?.isConnected) { | ||
| originalParent.insertBefore(wrapperEl, originalNextSibling); |
There was a problem hiding this comment.
Might not hurt to add a check that originalNextSibling is still a child of originalParent here since Node.insertBefore might throw an exception if it isn't.
There was a problem hiding this comment.
great catch, fixed!
The reference sibling captured at maximize time can be removed from the DOM while maximized (e.g. a re-render); insertBefore throws NotFoundError when the ref node is no longer a child of originalParent. Fall back to appending in that case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…T-4252] The inert-based focus trap (#3598) inerts everything outside the trap up to <body>. The Toaster's aria-live region rendered inside the main app container, so it was inerted while a drawer/maximizable was open and toasts went unannounced to screen readers (still shown visually — an AT-only gap). Hoist the live region to <body> from within the Toaster and mark it data-inert-skip; inertBackground now skips live-region roots (matched on the element itself, so a container merely holding one is still inerted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…T-4252] (#3654) * a11y(2.4.3): keep toast live region announcing during a focus trap [DT-4252] The inert-based focus trap (#3598) inerts everything outside the trap up to <body>. The Toaster's aria-live region rendered inside the main app container, so it was inerted while a drawer/maximizable was open and toasts went unannounced to screen readers (still shown visually — an AT-only gap). Hoist the live region to <body> from within the Toaster and mark it data-inert-skip; inertBackground now skips live-region roots (matched on the element itself, so a container merely holding one is still inerted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(focus-trap): isolate data-inert-skip case from aria-live The data-inert-skip test also set aria-live, so it passed on the aria-live branch alone. Drop aria-live here so it exercises data-inert-skip only; aria-live has its own test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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