feat: add keyboard navigation for tab bar
This commit is contained in:
56
src/App.tsx
56
src/App.tsx
@@ -271,6 +271,7 @@ function App() {
|
||||
const [resizeStartState, setResizeStartState] = useState<{ startX: number; startWidth: number } | 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 [tabFocusIndex, setTabFocusIndex] = useState(0);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
@@ -279,6 +280,7 @@ function App() {
|
||||
const kineticDragActiveRef = useRef(false);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const tabScrollRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [tabScrollState, setTabScrollState] = useState({ canLeft: false, canRight: false });
|
||||
const tabsRef = useRef(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
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
@@ -817,6 +853,20 @@ function App() {
|
||||
}
|
||||
}, [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 = {
|
||||
scrollbars: { autoHide: 'scroll' as const, autoHideDelay: 800, theme: 'os-theme-dark' as const }
|
||||
};
|
||||
@@ -901,10 +951,10 @@ function App() {
|
||||
{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 }}>
|
||||
<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}>
|
||||
{tabs.map(tab => (
|
||||
<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); }}>
|
||||
{tabs.map((tab, index) => (
|
||||
<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>
|
||||
<button className="tab-close" aria-label="Close tab" onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}><X size={12} /></button>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user