feat: add keyboard navigation for tab bar

This commit is contained in:
Your Name
2026-02-19 21:27:24 +02:00
parent 7031accf17
commit 2e0741760f

View File

@@ -271,6 +271,7 @@ function App() {
const [resizeStartState, setResizeStartState] = useState<{ startX: number; startWidth: number } | null>(null); const [resizeStartState, setResizeStartState] = useState<{ startX: number; startWidth: number } | null>(null);
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 contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
@@ -279,6 +280,7 @@ function App() {
const kineticDragActiveRef = useRef(false); const kineticDragActiveRef = useRef(false);
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 [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;
@@ -806,6 +808,40 @@ function App() {
} }
}; };
// Tab bar keyboard navigation (WAI-ARIA tabs pattern with roving tabindex)
const handleTabListKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = (tabFocusIndex + 1) % tabs.length;
setTabFocusIndex(next);
tabRefs.current[next]?.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = (tabFocusIndex - 1 + tabs.length) % tabs.length;
setTabFocusIndex(prev);
tabRefs.current[prev]?.focus();
} else if (e.key === 'Home') {
e.preventDefault();
setTabFocusIndex(0);
tabRefs.current[0]?.focus();
} else if (e.key === 'End') {
e.preventDefault();
const last = tabs.length - 1;
setTabFocusIndex(last);
tabRefs.current[last]?.focus();
} else if (e.key === 'Delete') {
e.preventDefault();
if (tabs[tabFocusIndex]) closeTab(tabs[tabFocusIndex].id);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const tab = tabs[tabFocusIndex];
if (tab) {
setActiveTabId(tab.id);
parseHeadings(tab.content);
}
}
};
// Auto-focus first menuitem when a dropdown opens // Auto-focus first menuitem when a dropdown opens
useEffect(() => { useEffect(() => {
if (menuOpen) { if (menuOpen) {
@@ -817,6 +853,20 @@ function App() {
} }
}, [menuOpen]); }, [menuOpen]);
// Clamp tabFocusIndex when tabs are closed
useEffect(() => {
if (tabFocusIndex >= tabs.length && tabs.length > 0) {
setTabFocusIndex(tabs.length - 1);
}
}, [tabs.length, tabFocusIndex]);
// Scroll the focused tab into view when tabFocusIndex changes
useEffect(() => {
if (tabs.length > 1) {
scrollToTabIndex(tabFocusIndex);
}
}, [tabFocusIndex, tabs.length, scrollToTabIndex]);
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 }
}; };
@@ -901,10 +951,10 @@ function App() {
{tabs.length > 1 && ( {tabs.length > 1 && (
<motion.div className="tab-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 38, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: materialEase }}> <motion.div className="tab-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 38, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: materialEase }}>
<button className={`tab-scroll-arrow ${tabScrollState.canLeft ? 'visible' : ''}`} aria-label="Scroll tabs left" onMouseDown={() => startScrollingTabs('left')} onMouseUp={stopScrollingTabs} onMouseLeave={stopScrollingTabs}><ChevronLeft size={14} /></button> <button className={`tab-scroll-arrow ${tabScrollState.canLeft ? 'visible' : ''}`} aria-label="Scroll tabs left" onMouseDown={() => startScrollingTabs('left')} onMouseUp={stopScrollingTabs} onMouseLeave={stopScrollingTabs}><ChevronLeft size={14} /></button>
<div className="tab-scroll-container" ref={tabScrollRef} role="tablist"> <div className="tab-scroll-container" ref={tabScrollRef} role="tablist" onKeyDown={handleTabListKeyDown}>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{tabs.map(tab => ( {tabs.map((tab, index) => (
<motion.div layout key={tab.id} id={`tab-${tab.id}`} role="tab" aria-selected={tab.id === activeTabId} className={`tab ${tab.id === activeTabId ? 'active' : ''}`} initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={smoothTransition} onClick={() => { setActiveTabId(tab.id); parseHeadings(tab.content); }}> <motion.div layout key={tab.id} id={`tab-${tab.id}`} role="tab" aria-selected={tab.id === activeTabId} tabIndex={index === tabFocusIndex ? 0 : -1} ref={(el) => { tabRefs.current[index] = el; }} className={`tab ${tab.id === activeTabId ? 'active' : ''}`} initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={smoothTransition} onClick={() => { setTabFocusIndex(index); setActiveTabId(tab.id); parseHeadings(tab.content); }}>
<span className="tab-title">{tab.title}</span> <span className="tab-title">{tab.title}</span>
<button className="tab-close" aria-label="Close tab" onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}><X size={12} /></button> <button className="tab-close" aria-label="Close tab" onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}><X size={12} /></button>
</motion.div> </motion.div>