import { RefObject, useEffect, useRef } from 'react'; interface UseDialogFocusTrapParams { active: boolean; containerRef: RefObject; } const focusableSelector = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); const isElementHiddenFromAT = (node: HTMLElement): boolean => { if (node.getAttribute('aria-hidden') === 'true') return true; if (node.closest('[aria-hidden="true"]')) return true; return false; }; const isInert = (node: HTMLElement): boolean => { // `inert` is not in TS lib dom everywhere. // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((node as any).inert) return true; if (node.closest('[inert]')) return true; return false; }; const getFocusableElements = (root: HTMLElement): HTMLElement[] => { const nodes = Array.from(root.querySelectorAll(focusableSelector)); return nodes.filter((node) => !isElementHiddenFromAT(node) && !isInert(node)); }; export const useDialogFocusTrap = ({ active, containerRef }: UseDialogFocusTrapParams): void => { const previouslyFocusedRef = useRef(null); useEffect(() => { if (!active) return undefined; const root = containerRef.current; if (!root) return undefined; previouslyFocusedRef.current = document.activeElement as HTMLElement | null; const focusInitial = () => { const focusables = getFocusableElements(root); (focusables[0] ?? root).focus(); }; const raf = window.requestAnimationFrame(focusInitial); const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; const focusables = getFocusableElements(root); if (focusables.length === 0) { e.preventDefault(); root.focus(); return; } const activeEl = document.activeElement as HTMLElement | null; const currentIndex = activeEl ? focusables.indexOf(activeEl) : -1; const lastIndex = focusables.length - 1; if (e.shiftKey) { if (currentIndex <= 0) { e.preventDefault(); focusables[lastIndex].focus(); } } else if (currentIndex === -1 || currentIndex >= lastIndex) { e.preventDefault(); focusables[0].focus(); } }; const onFocusIn = (e: FocusEvent) => { const target = e.target as HTMLElement | null; // If focus went to an element outside the dialog, redirect it back if (target && !root.contains(target)) { e.preventDefault(); e.stopPropagation(); // Focus back to the first focusable element in the dialog const focusables = getFocusableElements(root); if (focusables.length > 0) { focusables[0].focus(); } else { root.focus(); } } }; document.addEventListener('keydown', onKeyDown); document.addEventListener('focusin', onFocusIn, true); return () => { window.cancelAnimationFrame(raf); document.removeEventListener('keydown', onKeyDown); document.removeEventListener('focusin', onFocusIn, true); const prev = previouslyFocusedRef.current; if (prev && document.contains(prev)) { prev.focus(); } }; }, [active, containerRef]); }; export default useDialogFocusTrap;