/** * Shared focus trap for modal/drawer surfaces (improvement-plan P2-3.2). * * Promoted from the proven implementation in components/mcp/modal.tsx: * - moves focus inside the container on activate ([data-autofocus] first), * - keeps the Tab cycle within the container, * - calls onEscape for the Escape key (capture phase), * - restores focus to the previously focused element on deactivate. */ import { useEffect, useRef } from 'react'; import type { RefObject } from 'react'; const FOCUSABLE_SELECTOR = 'input, select, textarea, button, a[href], [tabindex]:not([tabindex="-1"])'; export interface UseFocusTrapOptions { /** Whether the trap is active (modal/drawer open). */ active: boolean; /** Called when Escape is pressed while the trap is active. */ onEscape?: () => void; } export function useFocusTrap( containerRef: RefObject, { active, onEscape }: UseFocusTrapOptions, ): void { const previousFocusRef = useRef(null); // Keep the latest callback without re-binding listeners on every render. const onEscapeRef = useRef(onEscape); onEscapeRef.current = onEscape; useEffect(() => { if (!active) { return; } previousFocusRef.current = document.activeElement as HTMLElement | null; // Move focus to the first focusable element (or the container itself). const focusFirst = () => { const container = containerRef.current; if (!container) { return; } if (container.contains(document.activeElement)) { return; } const target = container.querySelector( `[data-autofocus], ${FOCUSABLE_SELECTOR}`, ); (target || container).focus(); }; const raf = requestAnimationFrame(focusFirst); const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onEscapeRef.current?.(); return; } if (e.key !== 'Tab') { return; } const container = containerRef.current; if (!container) { return; } const focusables = Array.from( container.querySelectorAll(FOCUSABLE_SELECTOR), ).filter((el) => !el.hasAttribute('disabled')); if (focusables.length === 0) { return; } const first = focusables[0]; const last = focusables[focusables.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', onKeyDown, true); return () => { cancelAnimationFrame(raf); document.removeEventListener('keydown', onKeyDown, true); previousFocusRef.current?.focus?.(); }; }, [active, containerRef]); }