import { useEffect, useRef } from 'react'; /** * Focus-trap hook for modal-like surfaces (S24). * * Pass `active` (the modal's open state) and a ref to the container * element. While active: * * • Focus is moved into the container on mount (first tabbable child, * or the container itself if it has tabindex). * • Tab and Shift+Tab cycle through tabbable descendants only — they * can't escape to elements behind the modal. * • On deactivation (close), focus is returned to wherever it was * before the modal opened. * * The set of tabbable selectors here covers the common interactive * elements; we intentionally exclude `area` and `iframe` since they're * unusual in a modal context. `[tabindex="-1"]` is excluded — those are * focus targets but not Tab targets. * * Reading focused elements via `document.activeElement` is the standard * pattern; React refs aren't enough because focus may live on an element * inside a third-party library (e.g. Filerobot) that doesn't expose its * own ref. */ const TABBABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); export function useFocusTrap(active, containerRef) { const previousFocusRef = useRef(null); useEffect(() => { if (!active) return undefined; // Remember where focus was before so we can restore it on close. previousFocusRef.current = document.activeElement; const container = containerRef.current; if (!container) return undefined; // Move focus to the first tabbable element inside the container, // falling back to the container itself if it's focusable. const focusables = container.querySelectorAll(TABBABLE_SELECTOR); const first = focusables[0]; if (first instanceof HTMLElement) { first.focus(); } else if (container.tabIndex >= 0) { container.focus(); } const handleKeyDown = (e) => { if (e.key !== 'Tab') return; const live = container.querySelectorAll(TABBABLE_SELECTOR); if (live.length === 0) { // Nothing tabbable — keep focus on the container. e.preventDefault(); if (container.tabIndex >= 0) container.focus(); return; } const firstEl = live[0]; const lastEl = live[live.length - 1]; const activeEl = document.activeElement; if (e.shiftKey && activeEl === firstEl) { e.preventDefault(); lastEl.focus(); } else if (!e.shiftKey && activeEl === lastEl) { e.preventDefault(); firstEl.focus(); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); // Restore focus to where it was before, if that element still // exists in the DOM. Wrapped in a try/catch because focusing a // detached node throws in some browsers. const prev = previousFocusRef.current; if (prev && typeof prev.focus === 'function') { try { prev.focus(); } catch { /* ignore */ } } }; }, [active, containerRef]); }