feat: add full keyboard navigation for menu bar

This commit is contained in:
Your Name
2026-02-19 21:24:47 +02:00
parent 86d25a5823
commit 7031accf17

View File

@@ -253,6 +253,8 @@ function App() {
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const [headings, setHeadings] = useState<Heading[]>([]);
const [menuOpen, setMenuOpen] = useState<string | null>(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<HTMLElement>('[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<HTMLElement>('[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() {
<AnimatePresence initial={false}>
{!focusMode && (
<motion.div key="menu-bar" className="menu-bar" role="menubar" initial={{ height: 0, opacity: 0 }} animate={{ height: 28, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={smoothTransition} style={{ overflow: 'hidden' }}>
<button className="menu-item" role="menuitem" aria-haspopup="true" aria-expanded={menuOpen === 'file'} ref={(el) => { menuItemRefs.current['file'] = el; }} onClick={() => setMenuOpen(prev => prev === 'file' ? null : 'file')}>File</button>
<button className="menu-item" role="menuitem" aria-haspopup="true" aria-expanded={menuOpen === 'view'} ref={(el) => { menuItemRefs.current['view'] = el; }} onClick={() => setMenuOpen(prev => prev === 'view' ? null : 'view')}>View</button>
<button className="menu-item" role="menuitem" aria-haspopup="true" aria-expanded={menuOpen === 'help'} ref={(el) => { menuItemRefs.current['help'] = el; }} onClick={() => setMenuOpen(prev => prev === 'help' ? null : 'help')}>Help</button>
<motion.div key="menu-bar" className="menu-bar" role="menubar" onKeyDown={handleMenuBarKeyDown} initial={{ height: 0, opacity: 0 }} animate={{ height: 28, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={smoothTransition} style={{ overflow: 'hidden' }}>
<button className="menu-item" role="menuitem" aria-haspopup="true" aria-expanded={menuOpen === 'file'} tabIndex={menuFocusIndex === 0 ? 0 : -1} ref={(el) => { menuItemRefs.current['file'] = el; }} onClick={() => { setMenuFocusIndex(0); setMenuOpen(prev => prev === 'file' ? null : 'file'); }}>File</button>
<button className="menu-item" role="menuitem" aria-haspopup="true" aria-expanded={menuOpen === 'view'} tabIndex={menuFocusIndex === 1 ? 0 : -1} ref={(el) => { menuItemRefs.current['view'] = el; }} onClick={() => { setMenuFocusIndex(1); setMenuOpen(prev => prev === 'view' ? null : 'view'); }}>View</button>
<button className="menu-item" role="menuitem" aria-haspopup="true" aria-expanded={menuOpen === 'help'} tabIndex={menuFocusIndex === 2 ? 0 : -1} ref={(el) => { menuItemRefs.current['help'] = el; }} onClick={() => { setMenuFocusIndex(2); setMenuOpen(prev => prev === 'help' ? null : 'help'); }}>Help</button>
<AnimatePresence>
{menuOpen === 'file' && menuItemRefs.current['file'] && (
<motion.div className="menu-dropdown" role="menu" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['file']!.getBoundingClientRect().left, top: menuItemRefs.current['file']!.getBoundingClientRect().bottom - 6 }}>
<motion.div className="menu-dropdown" role="menu" onKeyDown={handleMenuDropdownKeyDown} initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['file']!.getBoundingClientRect().left, top: menuItemRefs.current['file']!.getBoundingClientRect().bottom - 6 }}>
<button className="menu-dropdown-item" role="menuitem" onClick={() => { handleOpenDialog(); setMenuOpen(null); }}>Open File <span className="menu-shortcut">Ctrl+O</span></button>
<button className="menu-dropdown-item" role="menuitem" onClick={() => { if (activeTabId) closeTab(activeTabId); setMenuOpen(null); }}>Close Tab <span className="menu-shortcut">Ctrl+W</span></button>
<div className="menu-separator"></div>
@@ -798,7 +871,7 @@ function App() {
</motion.div>
)}
{menuOpen === 'view' && menuItemRefs.current['view'] && (
<motion.div className="menu-dropdown" role="menu" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['view']!.getBoundingClientRect().left, top: menuItemRefs.current['view']!.getBoundingClientRect().bottom - 6 }}>
<motion.div className="menu-dropdown" role="menu" onKeyDown={handleMenuDropdownKeyDown} initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['view']!.getBoundingClientRect().left, top: menuItemRefs.current['view']!.getBoundingClientRect().bottom - 6 }}>
<button className="menu-dropdown-item" role="menuitem" onClick={() => { setShowSearch(s => !s); setMenuOpen(null); }}>Toggle Search <span className="menu-shortcut">Ctrl+F</span></button>
<button className="menu-dropdown-item" role="menuitem" onClick={() => { setShowSidebar(s => !s); setMenuOpen(null); }}>Toggle Sidebar <span className="menu-shortcut">Ctrl+Shift+S</span></button>
<button className="menu-dropdown-item" role="menuitem" onClick={() => { setFocusMode(f => !f); setMenuOpen(null); }}>Focus Mode <span className="menu-shortcut">F11</span></button>
@@ -814,7 +887,7 @@ function App() {
</motion.div>
)}
{menuOpen === 'help' && menuItemRefs.current['help'] && (
<motion.div className="menu-dropdown" role="menu" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['help']!.getBoundingClientRect().left, top: menuItemRefs.current['help']!.getBoundingClientRect().bottom - 6 }}>
<motion.div className="menu-dropdown" role="menu" onKeyDown={handleMenuDropdownKeyDown} initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['help']!.getBoundingClientRect().left, top: menuItemRefs.current['help']!.getBoundingClientRect().bottom - 6 }}>
<button className="menu-dropdown-item" role="menuitem" onClick={() => { setShowShortcutsModal(true); setMenuOpen(null); }}>Keyboard Shortcuts</button>
<button className="menu-dropdown-item" role="menuitem" onClick={() => { setShowAboutModal(true); setMenuOpen(null); }}>About Vesper</button>
</motion.div>