import { useEffect, useRef } from "preact/hooks"; /** * Focus Trap Hook * * Traps keyboard focus within a container element (e.g., modal, dialog). * When focus reaches the boundary, it cycles back to the first/last focusable element. * * @param isActive - Whether the focus trap should be active * @param containerRef - Ref to the container element * * Usage: * ```tsx * const modalRef = useRef(null); * useFocusTrap(isModalOpen, modalRef); * ``` */ export function useFocusTrap( isActive: boolean, containerRef: { current: HTMLElement | null }, ) { const previousActiveElementRef = useRef(null); useEffect(() => { if (!isActive || !containerRef.current) { return; } // Store the previously focused element to restore focus when modal closes previousActiveElementRef.current = document.activeElement as HTMLElement; const container = containerRef.current; // Get all focusable elements within the container const getFocusableElements = (): HTMLElement[] => { const focusableSelectors = [ "a[href]", "button:not([disabled])", "textarea:not([disabled])", "input:not([disabled])", "select:not([disabled])", '[tabindex]:not([tabindex="-1"])', ].join(", "); const focusableElements = Array.from( container.querySelectorAll(focusableSelectors), ) as HTMLElement[]; // Filter out elements that are hidden or not visible return focusableElements.filter( (el) => getComputedStyle(el).display !== "none" && getComputedStyle(el).visibility !== "hidden" && el.getAttribute("aria-hidden") !== "true", ); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Tab") { return; } const focusableElements = getFocusableElements(); if (focusableElements.length === 0) { return; } const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; // If shift+tab on first element, move to last if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); return; } // If tab on last element, move to first if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); return; } }; // Focus the first focusable element when trap activates const focusableElements = getFocusableElements(); if (focusableElements.length > 0) { // Small delay to ensure the modal is fully rendered setTimeout(() => { focusableElements[0].focus(); }, 50); } // Add event listener for tab key document.addEventListener("keydown", handleKeyDown, true); // Cleanup: restore focus to previous element when modal closes return () => { document.removeEventListener("keydown", handleKeyDown, true); // Restore focus to the element that was focused before the modal opened if ( previousActiveElementRef.current && document.contains(previousActiveElementRef.current) ) { previousActiveElementRef.current.focus(); } }; }, [isActive, containerRef]); } /** * Focus Management Hook for Modals * * Manages focus restoration when modal opens/closes */ export function useFocusManagement(isOpen: boolean) { const previousActiveElementRef = useRef(null); useEffect(() => { if (isOpen) { // Store the currently focused element previousActiveElementRef.current = document.activeElement as HTMLElement; // Prevent body scroll when modal is open document.body.style.overflow = "hidden"; } else { // Restore focus when modal closes if ( previousActiveElementRef.current && document.contains(previousActiveElementRef.current) ) { previousActiveElementRef.current.focus(); } // Restore body scroll document.body.style.overflow = ""; } return () => { // Cleanup on unmount document.body.style.overflow = ""; }; }, [isOpen]); }