import { useEffect, useRef } from 'react'; 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 call sites 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"])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]', ].join(','); /** * 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(() => { if (!active) return undefined; previousActiveElement.current = document.activeElement; const container = containerRef.current; if (!container) return undefined; const focusTarget = initialFocusRef?.current ?? container.querySelector(NEW_FOCUSABLE) ?? container; if (focusTarget && typeof focusTarget.focus === 'function') { if (focusTarget === container && !container.hasAttribute('tabindex')) { container.setAttribute('tabindex', '-1'); } focusTarget.focus({ preventScroll: true }); } const handleKeyDown = (e) => { if (e.key !== 'Tab') return; const focusable = Array.from(container.querySelectorAll(NEW_FOCUSABLE)); if (focusable.length === 0) { e.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.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', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); if ( restoreFocus && previousActiveElement.current && typeof previousActiveElement.current.focus === 'function' ) { previousActiveElement.current.focus({ preventScroll: true }); } }; }, [active, containerRef, initialFocusRef, restoreFocus]); }