feat(useFocusTrap): add composable - #939
Merged
Merged
Conversation
A native `<dialog>` opened with `showModal()` is trapped by the browser. Everything else had to hand-roll containment: `Dialog.Content` calls `element.showModal?.()`, so `as="div"` silently gets no top layer, no focus trap, no page inerting, and no native cancel. `@paper/bulma`'s BuModal copies the whole loop; `Treeview/TreeviewList.vue` carries a second copy of the focusable selector. `useFocusTrap(target, options)` is that loop, once. Tab/Shift+Tab wrap at the first and last tabbable descendant, focus enters the root on activate, and the previously focused element gets it back on deactivate. Driven by a reactive `active` source or imperatively; `initial` picks the landing spot (or `false` to skip autofocus), `restore: false` leaves focus alone, and `onEscape` opts into Escape — the trap never closes anything itself. Two decisions worth calling out: - The keydown listener binds to `document` in the capture phase, not to the root. A root-bound listener stops firing the moment focus leaves the subtree, so a backdrop click that blurs to `<body>` kills the trap for good — which is why BuModal needs `tabindex="-1"` plus `@pointerdown.prevent` on its backdrop. Document binding recovers on the next Tab and needs neither hack. Only the boundaries are intercepted, so nested widgets keep their own Tab handling. - `aria-disabled="true"` controls stay tabbable, per APG. Filtering them would let the browser step past the computed last stop and out of the trap. This diverges from TreeviewList on purpose, where roving focus must skip disabled items. Containment pierces open shadow roots via `getActiveElement()`; discovery cannot, which is documented alongside the `<iframe>` limitation. The root's `tabindex="-1"` stays the consumer's responsibility — v0 does not write attributes onto elements it did not render. Closes #909 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nested traps now resolve APG-inward: only the last activated trap handles Tab and Escape. Radio groups skip disabled/hidden/inert members instead of vanishing. listen: false makes onKeydown a real seam. initial/restore renamed to initialFocus/returnFocus. tabbable is re-exported so Treeview does not deep-import a private sibling.
Match usePresence's options-bag source name. Request focusVisible on programmatic focus so click-to-open paints a ring under *:focus-visible. Honest example caption: wrapping is described only while the trap is on.
Last connected trap owns Tab; last connected trap with onEscape owns Escape. Stack is not pushed under SSR. Treeview imports tabbable without evaluating the trap module. Options are single-word: active, initial, restore. Inspect follow-ups: real ssr tests, docs, PHILOSOPHY §2.5.
johnleider
force-pushed
the
feat/use-focus-trap
branch
from
August 31, 2026 20:15
fed5a0e to
5767ad4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fixes #909