feat: add keyboard navigation for sidebar and resize handle
This commit is contained in:
69
src/App.tsx
69
src/App.tsx
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user