feat: add useFocusTrap composable

Replaces reka-ui's internal focus-trap behavior for Dialog and Select.
This commit is contained in:
Matthew McPeak 2026-07-13 18:09:18 -04:00
parent dc3aa2c816
commit cbe9e45af5
2 changed files with 173 additions and 0 deletions

View file

@ -0,0 +1,81 @@
import type { Ref } from 'vue'
import { nextTick, onUnmounted, watch } from 'vue'
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
}
export interface FocusTrapOptions {
initialFocus?: () => HTMLElement | null
}
export function useFocusTrap(
containerRef: Ref<HTMLElement | null>,
active: Ref<boolean>,
options: FocusTrapOptions = {},
) {
let previouslyFocused: HTMLElement | null = null
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Tab') return
const container = containerRef.value
if (!container) return
const focusable = getFocusable(container)
if (focusable.length === 0) {
event.preventDefault()
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
const current = document.activeElement
if (event.shiftKey) {
if (current === first || !container.contains(current)) {
event.preventDefault()
last.focus()
}
} else {
if (current === last || !container.contains(current)) {
event.preventDefault()
first.focus()
}
}
}
watch(
active,
(isActive) => {
if (isActive) {
previouslyFocused = document.activeElement as HTMLElement | null
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
const container = containerRef.value
if (!container) return
if (container.contains(document.activeElement)) return
const target = options.initialFocus?.() ?? getFocusable(container)[0] ?? container
target?.focus()
})
} else {
document.removeEventListener('keydown', handleKeydown)
previouslyFocused?.focus?.()
previouslyFocused = null
}
},
{ immediate: true },
)
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
}

View file

@ -0,0 +1,92 @@
import { describe, it, expect, afterEach } from 'vitest'
import { nextTick, ref } from 'vue'
import { useFocusTrap } from '../src/lib/use-focus-trap'
function appendContainer() {
const container = document.createElement('div')
const first = document.createElement('button')
first.textContent = 'first'
const last = document.createElement('button')
last.textContent = 'last'
container.append(first, last)
document.body.append(container)
return { container, first, last }
}
describe('useFocusTrap', () => {
afterEach(() => {
document.body.replaceChildren()
})
it('moves focus into the container on activation', async () => {
const { container, first } = appendContainer()
const containerRef = ref<HTMLElement | null>(container)
const active = ref(false)
useFocusTrap(containerRef, active)
active.value = true
await nextTick()
await nextTick()
expect(document.activeElement).toBe(first)
})
it('uses the initialFocus override when provided', async () => {
const { container, last } = appendContainer()
const containerRef = ref<HTMLElement | null>(container)
const active = ref(false)
useFocusTrap(containerRef, active, { initialFocus: () => last })
active.value = true
await nextTick()
await nextTick()
expect(document.activeElement).toBe(last)
})
it('wraps Tab from the last focusable back to the first', async () => {
const { container, first, last } = appendContainer()
const containerRef = ref<HTMLElement | null>(container)
const active = ref(true)
useFocusTrap(containerRef, active)
last.focus()
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
document.dispatchEvent(event)
expect(document.activeElement).toBe(first)
})
it('wraps Shift+Tab from the first focusable back to the last', async () => {
const { container, first, last } = appendContainer()
const containerRef = ref<HTMLElement | null>(container)
const active = ref(true)
useFocusTrap(containerRef, active)
first.focus()
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true })
document.dispatchEvent(event)
expect(document.activeElement).toBe(last)
})
it('restores focus to the previously-focused element on deactivation', async () => {
const opener = document.createElement('button')
opener.textContent = 'opener'
document.body.append(opener)
opener.focus()
const { container } = appendContainer()
const containerRef = ref<HTMLElement | null>(container)
const active = ref(false)
useFocusTrap(containerRef, active)
active.value = true
await nextTick()
await nextTick()
active.value = false
await nextTick()
expect(document.activeElement).toBe(opener)
})
})