diff --git a/src/App.tsx b/src/App.tsx index 79acdad..bc713b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -253,6 +253,8 @@ function App() { const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [headings, setHeadings] = useState([]); const [menuOpen, setMenuOpen] = useState(null); + const [menuFocusIndex, setMenuFocusIndex] = useState(0); + const menuItems = ['file', 'view', 'help'] as const; const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showAboutModal, setShowAboutModal] = useState(false); const [zoom, setZoom] = useState(100); @@ -744,6 +746,77 @@ function App() { localStorage.setItem('vesper-ui-zoom', String(Math.round(uiZoom))); }, [uiZoom]); + // Menu bar keyboard navigation (WAI-ARIA menu pattern) + const handleMenuBarKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowRight') { + e.preventDefault(); + const next = (menuFocusIndex + 1) % menuItems.length; + setMenuFocusIndex(next); + menuItemRefs.current[menuItems[next]]?.focus(); + if (menuOpen) setMenuOpen(menuItems[next]); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + const prev = (menuFocusIndex - 1 + menuItems.length) % menuItems.length; + setMenuFocusIndex(prev); + menuItemRefs.current[menuItems[prev]]?.focus(); + if (menuOpen) setMenuOpen(menuItems[prev]); + } else if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setMenuOpen(menuItems[menuFocusIndex]); + } else if (e.key === 'Escape') { + setMenuOpen(null); + } + }; + + const handleMenuDropdownKeyDown = (e: React.KeyboardEvent) => { + const menu = e.currentTarget as HTMLElement; + const items = menu.querySelectorAll('[role="menuitem"]'); + if (!items.length) return; + + const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0; + items[next]?.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1; + items[prev]?.focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + items[0]?.focus(); + } else if (e.key === 'End') { + e.preventDefault(); + items[items.length - 1]?.focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setMenuOpen(null); + menuItemRefs.current[menuItems[menuFocusIndex]]?.focus(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + const next = (menuFocusIndex + 1) % menuItems.length; + setMenuFocusIndex(next); + setMenuOpen(menuItems[next]); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + const prev = (menuFocusIndex - 1 + menuItems.length) % menuItems.length; + setMenuFocusIndex(prev); + setMenuOpen(menuItems[prev]); + } + }; + + // Auto-focus first menuitem when a dropdown opens + useEffect(() => { + if (menuOpen) { + requestAnimationFrame(() => { + const dropdown = document.querySelector('.menu-dropdown[role="menu"]'); + const firstItem = dropdown?.querySelector('[role="menuitem"]'); + firstItem?.focus(); + }); + } + }, [menuOpen]); + const osScrollbarOptions = { scrollbars: { autoHide: 'scroll' as const, autoHideDelay: 800, theme: 'os-theme-dark' as const } }; @@ -784,13 +857,13 @@ function App() { {!focusMode && ( - - - - + + + + {menuOpen === 'file' && menuItemRefs.current['file'] && ( - +
@@ -798,7 +871,7 @@ function App() {
)} {menuOpen === 'view' && menuItemRefs.current['view'] && ( - + @@ -814,7 +887,7 @@ function App() { )} {menuOpen === 'help' && menuItemRefs.current['help'] && ( - +