feat: add useFocusTrap composable
Replaces reka-ui's internal focus-trap behavior for Dialog and Select.
This commit is contained in:
parent
dc3aa2c816
commit
cbe9e45af5
2 changed files with 173 additions and 0 deletions
81
packages/library/src/lib/use-focus-trap.ts
Normal file
81
packages/library/src/lib/use-focus-trap.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
92
packages/library/tests/use-focus-trap.spec.ts
Normal file
92
packages/library/tests/use-focus-trap.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue