feat: add full keyboard navigation for menu bar
This commit is contained in:
87
src/App.tsx
87
src/App.tsx
@@ -253,6 +253,8 @@ function App() {
|
|||||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||||
const [headings, setHeadings] = useState<Heading[]>([]);
|
const [headings, setHeadings] = useState<Heading[]>([]);
|
||||||
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
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 [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
const [showAboutModal, setShowAboutModal] = useState(false);
|
const [showAboutModal, setShowAboutModal] = useState(false);
|
||||||
const [zoom, setZoom] = useState(100);
|
const [zoom, setZoom] = useState(100);
|
||||||
@@ -744,6 +746,77 @@ function App() {
|
|||||||
localStorage.setItem('vesper-ui-zoom', String(Math.round(uiZoom)));
|
localStorage.setItem('vesper-ui-zoom', String(Math.round(uiZoom)));
|
||||||
}, [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 = {
|
const osScrollbarOptions = {
|
||||||
scrollbars: { autoHide: 'scroll' as const, autoHideDelay: 800, theme: 'os-theme-dark' as const }
|
scrollbars: { autoHide: 'scroll' as const, autoHideDelay: 800, theme: 'os-theme-dark' as const }
|
||||||
};
|
};
|
||||||
@@ -784,13 +857,13 @@ function App() {
|
|||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{!focusMode && (
|
{!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' }}>
|
<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'} 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 === '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'} 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 === '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'} ref={(el) => { menuItemRefs.current['help'] = el; }} onClick={() => setMenuOpen(prev => prev === 'help' ? null : 'help')}>Help</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>
|
<AnimatePresence>
|
||||||
{menuOpen === 'file' && menuItemRefs.current['file'] && (
|
{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={() => { 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>
|
<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>
|
<div className="menu-separator"></div>
|
||||||
@@ -798,7 +871,7 @@ function App() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{menuOpen === 'view' && menuItemRefs.current['view'] && (
|
{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={() => { 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={() => { 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>
|
<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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{menuOpen === 'help' && menuItemRefs.current['help'] && (
|
{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={() => { setShowShortcutsModal(true); setMenuOpen(null); }}>Keyboard Shortcuts</button>
|
||||||
<button className="menu-dropdown-item" role="menuitem" onClick={() => { setShowAboutModal(true); setMenuOpen(null); }}>About Vesper</button>
|
<button className="menu-dropdown-item" role="menuitem" onClick={() => { setShowAboutModal(true); setMenuOpen(null); }}>About Vesper</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user