Nychthemeron/packages/library/src/lib/use-focus-trap.ts
2026-07-15 19:23:25 -04:00

81 lines
2.1 KiB
TypeScript

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)
})
}