From 204757bf6a47467c97187fdc03ec7f3aed8f220b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 21:33:39 +0200 Subject: [PATCH] feat: add focus trap to modal dialogs --- src/App.tsx | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6a741a4..1487820 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -241,6 +241,77 @@ function setupKineticScroll( }; } +function useFocusTrap(isOpen: boolean, containerRef: React.RefObject) { + const triggerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + // Save the element that had focus before the modal opened + triggerRef.current = document.activeElement as HTMLElement; + + // Wait for AnimatePresence to render the modal + const raf = requestAnimationFrame(() => { + const container = containerRef.current; + if (!container) return; + + const focusable = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length > 0) focusable[0].focus(); + }); + + return () => { + cancelAnimationFrame(raf); + // Restore focus to trigger element when modal closes + triggerRef.current?.focus(); + triggerRef.current = null; + }; + }, [isOpen, containerRef]); + + useEffect(() => { + if (!isOpen) return; + + const container = containerRef.current; + if (!container) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + // The modal's own close handler will be called separately + return; + } + + if (e.key !== 'Tab') return; + + const focusable = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + // Use capture to intercept Tab before anything else + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [isOpen, containerRef]); +} + function App() { const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); @@ -287,6 +358,11 @@ function App() { const [tabScrollState, setTabScrollState] = useState({ canLeft: false, canRight: false }); const tabsRef = useRef(tabs); tabsRef.current = tabs; + const shortcutsModalRef = useRef(null); + const aboutModalRef = useRef(null); + + useFocusTrap(showShortcutsModal, shortcutsModalRef); + useFocusTrap(showAboutModal, aboutModalRef); const [appWindow, setAppWindow] = useState | null>(null); const activeTab = tabs.find(t => t.id === activeTabId) || null; @@ -1147,8 +1223,8 @@ function App() { {showShortcutsModal && ( - setShowShortcutsModal(false)}> - e.stopPropagation()}> + setShowShortcutsModal(false)} onKeyDown={(e) => { if (e.key === 'Escape') setShowShortcutsModal(false); }}> + e.stopPropagation()}>

Keyboard Shortcuts

@@ -1179,8 +1255,8 @@ function App() { )} {showAboutModal && ( - setShowAboutModal(false)}> - e.stopPropagation()}> + setShowAboutModal(false)} onKeyDown={(e) => { if (e.key === 'Escape') setShowAboutModal(false); }}> + e.stopPropagation()}>

About Vesper