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() {
|
function App() {
|
||||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||||
@@ -287,6 +358,11 @@ function App() {
|
|||||||
const [tabScrollState, setTabScrollState] = useState({ canLeft: false, canRight: false });
|
const [tabScrollState, setTabScrollState] = useState({ canLeft: false, canRight: false });
|
||||||
const tabsRef = useRef(tabs);
|
const tabsRef = useRef(tabs);
|
||||||
tabsRef.current = 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 [appWindow, setAppWindow] = useState<ReturnType<typeof getCurrentWindow> | null>(null);
|
||||||
const activeTab = tabs.find(t => t.id === activeTabId) || null;
|
const activeTab = tabs.find(t => t.id === activeTabId) || null;
|
||||||
@@ -1147,8 +1223,8 @@ function App() {
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showShortcutsModal && (
|
{showShortcutsModal && (
|
||||||
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowShortcutsModal(false)}>
|
<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 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 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">
|
<div className="shortcuts-dialog-header">
|
||||||
<h2 className="shortcuts-dialog-title" id="shortcuts-title">Keyboard Shortcuts</h2>
|
<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>
|
<button className="shortcuts-dialog-close" aria-label="Close dialog" onClick={() => setShowShortcutsModal(false)}><X size={16} /></button>
|
||||||
@@ -1179,8 +1255,8 @@ function App() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{showAboutModal && (
|
{showAboutModal && (
|
||||||
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowAboutModal(false)}>
|
<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 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 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">
|
<div className="modal-header">
|
||||||
<h2 className="modal-title" id="about-title">About Vesper</h2>
|
<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>
|
<button className="modal-close" aria-label="Close dialog" onClick={() => setShowAboutModal(false)}><X size={16} /></button>
|
||||||
|
|||||||
Reference in New Issue
Block a user