feat: add focus trap to modal dialogs
This commit is contained in:
84
src/App.tsx
84
src/App.tsx
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user