Skip to content

feat(useFocusTrap): add composable - #939

Merged
johnleider merged 5 commits into
devfrom
feat/use-focus-trap
Aug 31, 2026
Merged

feat(useFocusTrap): add composable#939
johnleider merged 5 commits into
devfrom
feat/use-focus-trap

Conversation

@Haviles04

@Haviles04 Haviles04 commented Aug 27, 2026

Copy link
Copy Markdown
Member

fixes #909

@Haviles04
Haviles04 changed the base branch from master to dev August 27, 2026 15:14
@Haviles04 Haviles04 changed the title Feat/use focus trap feat(useFocusTrap) Aug 27, 2026
@Haviles04 Haviles04 changed the title feat(useFocusTrap) feat(useFocusTrap): add composable Aug 28, 2026
Haviles04 and others added 5 commits August 31, 2026 15:13
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
johnleider force-pushed the feat/use-focus-trap branch from fed5a0e to 5767ad4 Compare August 31, 2026 20:15
@johnleider
johnleider merged commit 3cf3360 into dev Aug 31, 2026
17 of 18 checks passed
@johnleider
johnleider deleted the feat/use-focus-trap branch August 31, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants