Skip to content

Commit 1b44d90

Browse files
committed
Add navigation controls for overflowing document tabs
1 parent 3120c30 commit 1b44d90

2 files changed

Lines changed: 135 additions & 20 deletions

File tree

components/DocumentTabs.tsx

Lines changed: 126 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,44 +41,93 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
4141
const tabRefs = useRef(new Map<string, HTMLDivElement>());
4242
const dragState = useRef<{ id: string | null; index: number }>({ id: null, index: -1 });
4343
const [menuState, setMenuState] = useState<MenuState>(INITIAL_MENU_STATE);
44-
const [isOverflowing, setIsOverflowing] = useState(false);
44+
const [scrollState, setScrollState] = useState({
45+
canScrollLeft: false,
46+
canScrollRight: false,
47+
hiddenTabIds: [] as string[],
48+
});
4549

46-
const updateOverflow = useCallback(() => {
50+
const updateScrollState = useCallback(() => {
4751
const container = scrollContainerRef.current;
4852
if (!container) {
49-
setIsOverflowing(false);
53+
setScrollState({ canScrollLeft: false, canScrollRight: false, hiddenTabIds: [] });
5054
return;
5155
}
52-
setIsOverflowing(container.scrollWidth > container.clientWidth + 1);
53-
}, []);
56+
57+
const { scrollLeft, scrollWidth, clientWidth } = container;
58+
const containerRect = container.getBoundingClientRect();
59+
const hiddenTabIds: string[] = [];
60+
61+
for (const id of openDocumentIds) {
62+
const element = tabRefs.current.get(id);
63+
if (!element) continue;
64+
const rect = element.getBoundingClientRect();
65+
const isHiddenLeft = rect.right <= containerRect.left + 2;
66+
const isHiddenRight = rect.left >= containerRect.right - 2;
67+
if (isHiddenLeft || isHiddenRight) {
68+
hiddenTabIds.push(id);
69+
}
70+
}
71+
72+
const canScrollLeft = scrollLeft > 1;
73+
const canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
74+
75+
setScrollState((previous) => {
76+
if (
77+
previous.canScrollLeft === canScrollLeft &&
78+
previous.canScrollRight === canScrollRight &&
79+
previous.hiddenTabIds.length === hiddenTabIds.length &&
80+
previous.hiddenTabIds.every((id, index) => id === hiddenTabIds[index])
81+
) {
82+
return previous;
83+
}
84+
return {
85+
canScrollLeft,
86+
canScrollRight,
87+
hiddenTabIds,
88+
};
89+
});
90+
}, [openDocumentIds]);
5491

5592
useEffect(() => {
56-
updateOverflow();
57-
}, [updateOverflow, openDocumentIds.length, documents]);
93+
updateScrollState();
94+
}, [updateScrollState, openDocumentIds.length, documents]);
5895

5996
useEffect(() => {
6097
const container = scrollContainerRef.current;
6198
if (!container || typeof ResizeObserver === 'undefined') return;
6299

63-
const observer = new ResizeObserver(() => updateOverflow());
100+
const observer = new ResizeObserver(() => updateScrollState());
64101
observer.observe(container);
65102
if (container.parentElement) {
66103
observer.observe(container.parentElement);
67104
}
68-
window.addEventListener('resize', updateOverflow);
105+
window.addEventListener('resize', updateScrollState);
69106
return () => {
70107
observer.disconnect();
71-
window.removeEventListener('resize', updateOverflow);
108+
window.removeEventListener('resize', updateScrollState);
72109
};
73-
}, [updateOverflow]);
110+
}, [updateScrollState]);
74111

75112
useEffect(() => {
76113
if (!activeDocumentId) return;
77114
const element = tabRefs.current.get(activeDocumentId);
78115
if (element) {
79116
element.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
117+
requestAnimationFrame(() => updateScrollState());
80118
}
81-
}, [activeDocumentId]);
119+
}, [activeDocumentId, updateScrollState]);
120+
121+
useEffect(() => {
122+
const container = scrollContainerRef.current;
123+
if (!container) return;
124+
125+
const handleScroll = () => updateScrollState();
126+
container.addEventListener('scroll', handleScroll, { passive: true });
127+
return () => {
128+
container.removeEventListener('scroll', handleScroll);
129+
};
130+
}, [updateScrollState]);
82131

83132
const closeMenu = useCallback(() => {
84133
setMenuState(INITIAL_MENU_STATE);
@@ -110,8 +159,11 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
110159

111160
const openOverflowMenu = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
112161
event.preventDefault();
162+
if (!scrollState.hiddenTabIds.length) {
163+
return;
164+
}
113165
const rect = event.currentTarget.getBoundingClientRect();
114-
const items: MenuItem[] = openDocumentIds.map((id) => {
166+
const items: MenuItem[] = scrollState.hiddenTabIds.map((id) => {
115167
const doc = docsById.get(id);
116168
const displayTitle = doc?.title?.trim() || 'Untitled Document';
117169
return {
@@ -126,7 +178,41 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
126178
position: { x: rect.left, y: rect.bottom + 4 },
127179
items,
128180
});
129-
}, [openDocumentIds, docsById, onSelectTab, activeDocumentId]);
181+
}, [scrollState.hiddenTabIds, docsById, onSelectTab, activeDocumentId]);
182+
183+
const scrollToDirection = useCallback((direction: 'left' | 'right') => {
184+
const container = scrollContainerRef.current;
185+
if (!container) return;
186+
187+
const { clientWidth, scrollLeft } = container;
188+
if (!openDocumentIds.length) return;
189+
190+
if (direction === 'left') {
191+
for (let index = openDocumentIds.length - 1; index >= 0; index -= 1) {
192+
const id = openDocumentIds[index];
193+
const element = tabRefs.current.get(id);
194+
if (!element) continue;
195+
const tabStart = element.offsetLeft;
196+
if (tabStart < scrollLeft - 1) {
197+
container.scrollTo({ left: tabStart, behavior: 'smooth' });
198+
return;
199+
}
200+
}
201+
container.scrollTo({ left: 0, behavior: 'smooth' });
202+
} else {
203+
for (let index = 0; index < openDocumentIds.length; index += 1) {
204+
const id = openDocumentIds[index];
205+
const element = tabRefs.current.get(id);
206+
if (!element) continue;
207+
const tabEnd = element.offsetLeft + element.offsetWidth;
208+
if (tabEnd > scrollLeft + clientWidth + 1) {
209+
container.scrollTo({ left: tabEnd - clientWidth, behavior: 'smooth' });
210+
return;
211+
}
212+
}
213+
container.scrollTo({ left: container.scrollWidth - clientWidth, behavior: 'smooth' });
214+
}
215+
}, [openDocumentIds]);
130216

131217
const handleDragStart = useCallback((event: React.DragEvent<HTMLDivElement>, tabId: string, index: number) => {
132218
dragState.current = { id: tabId, index };
@@ -242,24 +328,44 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
242328
<div className="flex items-center gap-1 px-2 w-full h-full">
243329
<div
244330
ref={scrollContainerRef}
245-
className="flex-1 overflow-hidden h-full"
331+
className="flex-1 h-full overflow-x-auto overflow-y-hidden scrollbar-hidden"
246332
onDragOver={handleDragOver}
247333
onDrop={handleContainerDrop}
334+
role="tablist"
248335
>
249-
<div className="flex items-stretch gap-1 overflow-x-auto h-full pr-2" role="tablist">
336+
<div className="flex items-stretch gap-1 h-full min-w-max pr-2">
250337
{tabElements}
251338
</div>
252339
</div>
253-
{isOverflowing && openDocumentIds.length > 0 && (
340+
<div className="flex items-center gap-1">
341+
<button
342+
type="button"
343+
className="flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70 disabled:opacity-40 disabled:cursor-default"
344+
onClick={() => scrollToDirection('left')}
345+
aria-label="Scroll tabs left"
346+
disabled={!scrollState.canScrollLeft}
347+
>
348+
<ChevronDownIcon className="w-4 h-4 -rotate-90" />
349+
</button>
350+
<button
351+
type="button"
352+
className="flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70 disabled:opacity-40 disabled:cursor-default"
353+
onClick={() => scrollToDirection('right')}
354+
aria-label="Scroll tabs right"
355+
disabled={!scrollState.canScrollRight}
356+
>
357+
<ChevronDownIcon className="w-4 h-4 rotate-90" />
358+
</button>
254359
<button
255360
type="button"
256-
className="flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70"
361+
className="flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70 disabled:opacity-40 disabled:cursor-default"
257362
onClick={openOverflowMenu}
258-
aria-label="Show all tabs"
363+
aria-label="Show hidden tabs"
364+
disabled={!scrollState.hiddenTabIds.length}
259365
>
260366
<ChevronDownIcon className="w-4 h-4" />
261367
</button>
262-
)}
368+
</div>
263369
</div>
264370
<ContextMenu
265371
isOpen={menuState.isOpen}

styles/tailwind.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
11
@tailwind base;
22
@tailwind components;
33
@tailwind utilities;
4+
5+
.scrollbar-hidden {
6+
scrollbar-width: none;
7+
-ms-overflow-style: none;
8+
}
9+
10+
.scrollbar-hidden::-webkit-scrollbar {
11+
display: none;
12+
}

0 commit comments

Comments
 (0)