diff --git a/lib/use-focus-trap.js b/lib/use-focus-trap.js index 9b5080b..9cfb005 100644 --- a/lib/use-focus-trap.js +++ b/lib/use-focus-trap.js @@ -1,6 +1,74 @@ import { useEffect, useRef } from 'react'; -const FOCUSABLE = [ +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +function getFocusableElements(container) { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true' + ); +} + +/** + * Trap focus inside a container while `active` and restore focus on close. + * Returns a ref to attach to the dialog panel (not the backdrop). + * + * Used by pre-Liquid-Glass call sites (e.g. ScannerPageView) where the + * caller wants the hook to own the container ref. New code that needs + * to share / forward the panel ref should use the default export + * `useFocusTrapContainer` instead — see Modal.js for the pattern. + */ +export function useFocusTrap(active) { + const containerRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + useEffect(() => { + if (!active) return undefined; + + previouslyFocusedRef.current = document.activeElement; + const container = containerRef.current; + if (!container) return undefined; + + const focusFirst = () => { + const nodes = getFocusableElements(container); + nodes[0]?.focus(); + }; + + focusFirst(); + + const handleKeyDown = (event) => { + if (event.key !== 'Tab') return; + + const nodes = getFocusableElements(container); + if (nodes.length === 0) return; + + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + const previous = previouslyFocusedRef.current; + if (previous && typeof previous.focus === 'function') { + previous.focus(); + } + }; + }, [active]); + + return containerRef; +} + +const NEW_FOCUSABLE = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', @@ -10,7 +78,18 @@ const FOCUSABLE = [ '[contenteditable="true"]', ].join(','); -export default function useFocusTrap({ active, containerRef, initialFocusRef, restoreFocus = true }) { +/** + * Focus-trap variant for the `` primitive in components/ui/. + * Takes a caller-owned containerRef + optional initialFocusRef, so the + * panel ref can be forwarded through GlassSurface / forwardRef chains + * without colliding with the hook's internal ref ownership. + */ +export default function useFocusTrapContainer({ + active, + containerRef, + initialFocusRef, + restoreFocus = true, +}) { const previousActiveElement = useRef(null); useEffect(() => { @@ -22,7 +101,7 @@ export default function useFocusTrap({ active, containerRef, initialFocusRef, re const focusTarget = initialFocusRef?.current ?? - container.querySelector(FOCUSABLE) ?? + container.querySelector(NEW_FOCUSABLE) ?? container; if (focusTarget && typeof focusTarget.focus === 'function') { if (focusTarget === container && !container.hasAttribute('tabindex')) { @@ -33,7 +112,7 @@ export default function useFocusTrap({ active, containerRef, initialFocusRef, re const handleKeyDown = (e) => { if (e.key !== 'Tab') return; - const focusable = Array.from(container.querySelectorAll(FOCUSABLE)); + const focusable = Array.from(container.querySelectorAll(NEW_FOCUSABLE)); if (focusable.length === 0) { e.preventDefault(); return;