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 [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>
|
||||||
|
|||||||
Reference in New Issue
Block a user