@redocly/theme
Version:
Shared UI components lib
329 lines (277 loc) • 10 kB
text/typescript
import { useCallback, useRef, useState, useEffect } from 'react';
type UseTabsProps = {
initialTab: string;
totalTabs: number;
containerRef?: React.RefObject<HTMLElement | null>;
};
export function useTabs({ initialTab, totalTabs, containerRef }: UseTabsProps) {
const [activeTab, setActiveTab] = useState(initialTab);
const [visibleTabs, setVisibleTabs] = useState<number[]>(
Array.from({ length: totalTabs }, (_, i) => i),
);
const [overflowTabs, setOverflowTabs] = useState<number[]>([]);
const [allTabsHidden, setAllTabsHidden] = useState<boolean>(false);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const tabLabelsRef = useRef<string[]>([]);
const resizeTimeoutRef = useRef<number | undefined>(undefined);
const [ready, setReady] = useState<boolean>(false);
const hasCalculatedOnce = useRef(false);
const lastWidthRef = useRef<number>(0);
const originalOrderRef = useRef<number[]>([]);
useEffect(() => {
originalOrderRef.current = Array.from({ length: totalTabs }, (_, i) => i);
}, [totalTabs]);
const setTabRef = useCallback((element: HTMLButtonElement | null, index: number) => {
tabRefs.current[index] = element;
if (element) {
const label = element.getAttribute('data-label');
if (label) {
tabLabelsRef.current[index] = label;
}
}
}, []);
const getTabId = useCallback((label: string, index: number) => {
const cleanLabel = label.replace(/\s+/g, '-').toLowerCase();
return `${cleanLabel}-${index}`;
}, []);
const focusTab = (index: number) => {
const currentElement = tabRefs.current[index];
if (currentElement) {
currentElement.focus();
}
};
const onTabSelect = useCallback((index: number) => {
focusTab(index);
const label = tabRefs.current[index]?.getAttribute('data-label');
if (label) setActiveTab(label);
}, []);
const onTabClick = useCallback(
(labelOrIndex: string | number) => {
let clickedIndex: number;
if (typeof labelOrIndex === 'string') {
clickedIndex = tabRefs.current.findIndex(
(ref) => ref?.getAttribute('data-label') === labelOrIndex,
);
if (clickedIndex === -1) return;
} else {
clickedIndex = labelOrIndex;
}
if (allTabsHidden) {
const label = tabLabelsRef.current[clickedIndex];
if (label) {
setActiveTab(label);
focusTab(clickedIndex);
}
return;
}
if (overflowTabs.includes(clickedIndex)) {
const newVisibleTabs = [...visibleTabs];
const newOverflowTabs = [...overflowTabs];
const clickedIdxInOverflow = newOverflowTabs.indexOf(clickedIndex);
if (clickedIdxInOverflow !== -1) {
newOverflowTabs.splice(clickedIdxInOverflow, 1);
}
const lastVisible = newVisibleTabs.pop();
if (lastVisible !== undefined) {
newOverflowTabs.unshift(lastVisible);
}
newVisibleTabs.push(clickedIndex);
setVisibleTabs(newVisibleTabs);
setOverflowTabs(newOverflowTabs);
requestAnimationFrame(() => {
const label = tabRefs.current[clickedIndex]?.getAttribute('data-label');
if (label) {
setActiveTab(label);
focusTab(clickedIndex);
}
});
} else {
const label = tabRefs.current[clickedIndex]?.getAttribute('data-label');
if (label) {
setActiveTab(label);
focusTab(clickedIndex);
}
}
},
[visibleTabs, overflowTabs, allTabsHidden],
);
const handleKeyboard = useCallback(
(event: React.KeyboardEvent, index: number) => {
let newIndex = index;
if (event.key === 'ArrowRight') {
newIndex = (index + 1) % totalTabs;
} else if (event.key === 'ArrowLeft') {
newIndex = (index - 1 + totalTabs) % totalTabs;
} else if (event.key === 'Home') {
event.preventDefault();
newIndex = 0;
} else if (event.key === 'End') {
event.preventDefault();
newIndex = totalTabs - 1;
} else {
return;
}
onTabSelect(newIndex);
},
[totalTabs, onTabSelect],
);
const calculateVisibleTabs = useCallback(() => {
const container = containerRef?.current;
if (!container) return;
const contentWrapper = container.closest('div');
if (!contentWrapper) {
setVisibleTabs(Array.from({ length: totalTabs }, (_, i) => i));
setOverflowTabs([]);
setAllTabsHidden(false);
return;
}
const containerWidth = container.offsetWidth - 60;
const tabElements = container.querySelectorAll('[role="tab"]');
const moreButtonWidth = 80;
const safetyMargin = 20;
const tabWidths = Array.from(tabElements).map((el) => (el as HTMLElement).offsetWidth);
const tabLabels = Array.from(tabElements).map((el) => el.getAttribute('data-label') || '');
const tabTypes = Array.from(tabElements).map((el) => el.getAttribute('data-type') || '');
const hasLongLabels = tabLabels.some((label) => label.length > 30);
const minVisibleTabs = hasLongLabels ? 1 : 2;
const activeTabIndex = tabRefs.current.findIndex(
(ref) => ref?.getAttribute('data-label') === activeTab,
);
let currentWidth = 0;
const visible: number[] = [];
const overflow: number[] = [];
let minTabsWidth = 0;
Array.from({ length: minVisibleTabs }).forEach((_, i) => {
if (i < tabWidths.length) {
minTabsWidth += tabWidths[i] + (i > 0 ? moreButtonWidth + safetyMargin : 0);
}
});
if (minTabsWidth > containerWidth) {
setVisibleTabs([]);
setOverflowTabs(Array.from({ length: totalTabs }, (_, i) => i));
setAllTabsHidden(true);
return;
}
const tabsByType = new Map<string, number[]>();
Array.from({ length: totalTabs }).forEach((_, i) => {
const type = tabTypes[i] || 'default';
if (!tabsByType.has(type)) {
tabsByType.set(type, []);
}
tabsByType.get(type)?.push(i);
});
tabsByType.forEach((tabIndices) => {
let typeCurrentWidth = currentWidth;
const typeVisible: number[] = [];
const typeOverflow: number[] = [];
tabIndices.slice(0, minVisibleTabs).forEach((tabIndex) => {
const tabWidth = tabWidths[tabIndex];
const projectedWidth =
typeCurrentWidth +
tabWidth +
(typeVisible.length > 0 ? moreButtonWidth + safetyMargin : 0);
if (projectedWidth <= containerWidth) {
typeVisible.push(tabIndex);
typeCurrentWidth += tabWidth;
} else {
typeOverflow.push(tabIndex);
}
});
tabIndices.slice(minVisibleTabs).forEach((tabIndex) => {
const tabWidth = tabWidths[tabIndex];
const projectedWidth = typeCurrentWidth + tabWidth + moreButtonWidth + safetyMargin;
if (projectedWidth <= containerWidth) {
typeVisible.push(tabIndex);
typeCurrentWidth += tabWidth;
} else {
typeOverflow.push(tabIndex);
}
});
visible.push(...typeVisible);
overflow.push(...typeOverflow);
currentWidth = typeCurrentWidth;
});
if (activeTabIndex !== -1 && !visible.includes(activeTabIndex)) {
if (visible.length > 0) {
const removed = visible.pop();
if (removed !== undefined) {
overflow.unshift(removed);
}
}
visible.push(activeTabIndex);
const activeOverflowIndex = overflow.indexOf(activeTabIndex);
if (activeOverflowIndex !== -1) overflow.splice(activeOverflowIndex, 1);
}
setVisibleTabs(visible);
setOverflowTabs(overflow);
setAllTabsHidden(visible.length === 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerRef, totalTabs]);
useEffect(() => {
if (!containerRef?.current) return;
const ensureTabsReady = () => {
const allTabsReady =
tabRefs.current.length === totalTabs && tabRefs.current.every((tab) => tab?.offsetWidth);
if (!allTabsReady) {
resizeTimeoutRef.current = requestAnimationFrame(ensureTabsReady);
return;
}
calculateVisibleTabs();
hasCalculatedOnce.current = true;
};
resizeTimeoutRef.current = requestAnimationFrame(ensureTabsReady);
let resizeTimeout: number;
const handleResize = () => {
if (!hasCalculatedOnce.current) return;
if (resizeTimeout) {
cancelAnimationFrame(resizeTimeout);
}
resizeTimeout = requestAnimationFrame(() => {
if (resizeTimeoutRef.current) {
cancelAnimationFrame(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = requestAnimationFrame(() => {
const container = containerRef?.current;
if (!container) return;
const currentWidth = container.offsetWidth;
if (Math.abs(lastWidthRef.current - currentWidth) > 5) {
lastWidthRef.current = currentWidth;
calculateVisibleTabs();
}
});
});
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(containerRef.current);
window.addEventListener('resize', handleResize);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
if (resizeTimeoutRef.current) {
cancelAnimationFrame(resizeTimeoutRef.current);
}
if (resizeTimeout) {
cancelAnimationFrame(resizeTimeout);
}
};
}, [containerRef, totalTabs, calculateVisibleTabs]);
useEffect(() => {
const raf = requestAnimationFrame(() => {
setReady(true);
calculateVisibleTabs();
});
return () => cancelAnimationFrame(raf);
}, [calculateVisibleTabs]);
return {
activeTab,
setActiveTab,
setTabRef,
onTabClick,
handleKeyboard,
getTabId,
visibleTabs,
overflowTabs,
ready,
allTabsHidden,
};
}