feat: add keyboard navigation for sidebar and resize handle

This commit is contained in:
Your Name
2026-02-19 21:29:11 +02:00
parent 2e0741760f
commit e7544c283e

View File

@@ -272,6 +272,7 @@ function App() {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; selectedText: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; selectedText: string } | null>(null);
const [selectionRects, setSelectionRects] = useState<{ left: number; top: number; width: number; height: number }[]>([]); const [selectionRects, setSelectionRects] = useState<{ left: number; top: number; width: number; height: number }[]>([]);
const [tabFocusIndex, setTabFocusIndex] = useState(0); const [tabFocusIndex, setTabFocusIndex] = useState(0);
const [sidebarFocusIndex, setSidebarFocusIndex] = useState(0);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
@@ -281,6 +282,7 @@ function App() {
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
const tabScrollRef = useRef<HTMLDivElement>(null); const tabScrollRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLDivElement | null)[]>([]); const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
const sidebarItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
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;
@@ -842,6 +844,36 @@ function App() {
} }
}; };
// Sidebar keyboard navigation (roving tabindex)
const handleSidebarKeyDown = (e: React.KeyboardEvent) => {
if (headings.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.min(sidebarFocusIndex + 1, headings.length - 1);
setSidebarFocusIndex(next);
sidebarItemRefs.current[next]?.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = Math.max(sidebarFocusIndex - 1, 0);
setSidebarFocusIndex(prev);
sidebarItemRefs.current[prev]?.focus();
} else if (e.key === 'Home') {
e.preventDefault();
setSidebarFocusIndex(0);
sidebarItemRefs.current[0]?.focus();
} else if (e.key === 'End') {
e.preventDefault();
const last = headings.length - 1;
setSidebarFocusIndex(last);
sidebarItemRefs.current[last]?.focus();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const heading = headings[sidebarFocusIndex];
if (heading) scrollToHeading(heading.text);
}
};
// Auto-focus first menuitem when a dropdown opens // Auto-focus first menuitem when a dropdown opens
useEffect(() => { useEffect(() => {
if (menuOpen) { if (menuOpen) {
@@ -860,6 +892,11 @@ function App() {
} }
}, [tabs.length, tabFocusIndex]); }, [tabs.length, tabFocusIndex]);
// Reset sidebar focus index when headings change
useEffect(() => {
setSidebarFocusIndex(0);
}, [headings]);
// Scroll the focused tab into view when tabFocusIndex changes // Scroll the focused tab into view when tabFocusIndex changes
useEffect(() => { useEffect(() => {
if (tabs.length > 1) { if (tabs.length > 1) {
@@ -971,15 +1008,41 @@ function App() {
{showSidebar && ( {showSidebar && (
<motion.nav className="sidebar-scroll-wrapper" aria-label="Table of Contents" initial={{ width: 0, opacity: 0 }} animate={{ width: sidebarWidth, opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={resizeStartState ? { duration: 0 } : smoothTransition} style={{ height: '100%' }}> <motion.nav className="sidebar-scroll-wrapper" aria-label="Table of Contents" initial={{ width: 0, opacity: 0 }} animate={{ width: sidebarWidth, opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={resizeStartState ? { duration: 0 } : smoothTransition} style={{ height: '100%' }}>
<OverlayScrollbarsComponent options={osScrollbarOptions} style={{ height: '100%' }}> <OverlayScrollbarsComponent options={osScrollbarOptions} style={{ height: '100%' }}>
<div className="sidebar" ref={sidebarRef}> <div className="sidebar" ref={sidebarRef} onKeyDown={handleSidebarKeyDown}>
<h2 className="sidebar-heading">Contents</h2> <h2 className="sidebar-heading">Contents</h2>
{headings.length > 0 {headings.length > 0
? headings.map(h => <button key={h.id} className={`sidebar-item h${h.level}`} onClick={() => scrollToHeading(h.text)}>{h.text}</button>) ? headings.map((h, index) => (
<button
key={h.id}
ref={(el) => { sidebarItemRefs.current[index] = el; }}
className={`sidebar-item h${h.level}`}
tabIndex={index === sidebarFocusIndex ? 0 : -1}
onClick={() => { setSidebarFocusIndex(index); scrollToHeading(h.text); }}
>
{h.text}
</button>
))
: <div className="sidebar-empty">No headings</div> : <div className="sidebar-empty">No headings</div>
} }
</div> </div>
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
<div className="sidebar-resize-handle" onMouseDown={startSidebarResize}></div> <div
className="sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
tabIndex={0}
onMouseDown={startSidebarResize}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
setSidebarWidth(w => Math.min(400, w + (e.shiftKey ? 50 : 10)));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setSidebarWidth(w => Math.max(150, w - (e.shiftKey ? 50 : 10)));
}
}}
></div>
</motion.nav> </motion.nav>
)} )}
</AnimatePresence> </AnimatePresence>