Spaces:
Running
Running
File size: 2,642 Bytes
5f3e9f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | /**
* Focus-trap hook for modal dialogs.
*
* Requirements (WCAG 2.1.2): when a dialog is open, Tab and Shift+Tab must
* cycle focus within the dialog, focus must be moved into the dialog on
* open, and focus must be returned to the previously-focused element on
* close. We do that with a single keydown listener instead of the heavier
* `focus-trap` library to keep the bundle small.
*
* Usage:
* const ref = useFocusTrap<HTMLDivElement>(open)
* <div ref={ref} role="dialog" aria-modal>...</div>
*/
import { useEffect, useRef } from 'react'
const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',')
export function useFocusTrap<T extends HTMLElement = HTMLElement>(active: boolean) {
const ref = useRef<T | null>(null)
useEffect(() => {
if (!active) return
const container = ref.current
if (!container) return
const previouslyFocused = document.activeElement as HTMLElement | null
const focusables = (): HTMLElement[] =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
)
// Move initial focus to the first focusable inside the container so
// screen readers announce the dialog content immediately.
const initial = focusables()[0] ?? container
initial.focus({ preventScroll: true })
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const items = focusables()
if (items.length === 0) {
e.preventDefault()
container.focus()
return
}
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('keydown', onKey)
// Restore focus only if the previously-focused element is still in
// the DOM and connected (the dialog might have unmounted it).
if (previouslyFocused && document.contains(previouslyFocused)) {
try {
previouslyFocused.focus({ preventScroll: true })
} catch {
/* element no longer focusable */
}
}
}
}, [active])
return ref
}
|