feat: add focus trap to modal dialogs

This commit is contained in:
Your Name
2026-02-19 21:33:39 +02:00
parent 59b047ff25
commit 204757bf6a

View File

@@ -241,6 +241,77 @@ function setupKineticScroll(
};
}
function useFocusTrap(isOpen: boolean, containerRef: React.RefObject<HTMLElement | null>) {
const triggerRef = useRef<HTMLElement | null>(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<HTMLElement>(
'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<HTMLElement>(
'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<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(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<HTMLDivElement>(null);
const aboutModalRef = useRef<HTMLDivElement>(null);
useFocusTrap(showShortcutsModal, shortcutsModalRef);
useFocusTrap(showAboutModal, aboutModalRef);
const [appWindow, setAppWindow] = useState<ReturnType<typeof getCurrentWindow> | null>(null);
const activeTab = tabs.find(t => t.id === activeTabId) || null;
@@ -1147,8 +1223,8 @@ function App() {
<AnimatePresence>
{showShortcutsModal && (
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowShortcutsModal(false)}>
<motion.div className="shortcuts-dialog" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition} onClick={e => e.stopPropagation()}>
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowShortcutsModal(false)} onKeyDown={(e) => { if (e.key === 'Escape') setShowShortcutsModal(false); }}>
<motion.div ref={shortcutsModalRef} className="shortcuts-dialog" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition} onClick={e => e.stopPropagation()}>
<div className="shortcuts-dialog-header">
<h2 className="shortcuts-dialog-title" id="shortcuts-title">Keyboard Shortcuts</h2>
<button className="shortcuts-dialog-close" aria-label="Close dialog" onClick={() => setShowShortcutsModal(false)}><X size={16} /></button>
@@ -1179,8 +1255,8 @@ function App() {
</motion.div>
)}
{showAboutModal && (
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowAboutModal(false)}>
<motion.div className="modal" role="dialog" aria-modal="true" aria-labelledby="about-title" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition} onClick={e => e.stopPropagation()}>
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowAboutModal(false)} onKeyDown={(e) => { if (e.key === 'Escape') setShowAboutModal(false); }}>
<motion.div ref={aboutModalRef} className="modal" role="dialog" aria-modal="true" aria-labelledby="about-title" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title" id="about-title">About Vesper</h2>
<button className="modal-close" aria-label="Close dialog" onClick={() => setShowAboutModal(false)}><X size={16} /></button>