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