@redocly/theme
Version:
Shared UI components lib
352 lines (294 loc) • 10.6 kB
text/typescript
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
type UseTabsProps = {
activeTab: string;
onTabChange: (tab: string) => void;
totalTabs: number;
containerRef?: React.RefObject<HTMLElement | null>;
};
type Tabs = {
visible: number[];
overflow: number[];
};
type UseTabsReturn = {
setTabRef: (element: HTMLButtonElement | null, index: number) => void;
onTabClick: (labelOrIndex: string | number) => void;
handleKeyboard: (event: React.KeyboardEvent, index: number) => void;
visibleTabs: number[];
overflowTabs: number[];
isReady: boolean;
};
type UseActiveTabProps = {
initialTab: string;
tabsId?: string;
};
const MORE_BUTTON_WIDTH = 80;
const TABS_GAP = 8;
export function useTabs({
activeTab,
onTabChange,
totalTabs,
containerRef,
}: UseTabsProps): UseTabsReturn {
const [tabs, setTabs] = useState<Tabs>({
visible: Array.from({ length: totalTabs }, (_, i) => i),
overflow: [],
});
const [isReady, setIsReady] = useState(false);
const isFirstCalculation = useRef(true);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const tabWidthsRef = useRef<number[]>([]);
const tabLabelsRef = useRef<string[]>([]);
const activeTabRef = useRef(activeTab);
const calculateVisibleTabsRef = useRef<(() => void) | null>(null);
// Synchronously update ref before any callbacks or effects run
activeTabRef.current = activeTab;
const setTabRef = useCallback(
(element: HTMLButtonElement | null, index: number) => {
tabRefs.current[index] = element;
const width = element?.offsetWidth;
if (width) {
tabWidthsRef.current[index] = width;
}
const label = element?.getAttribute('data-label');
if (label) {
tabLabelsRef.current[index] = label;
}
// Trigger calculation once all tabs are registered
if (
isFirstCalculation.current &&
tabWidthsRef.current.length >= totalTabs &&
tabLabelsRef.current.length >= totalTabs &&
calculateVisibleTabsRef.current
) {
requestAnimationFrame(calculateVisibleTabsRef.current);
}
},
[totalTabs],
);
const focusTab = useCallback((index: number) => {
const currentElement = tabRefs.current[index];
currentElement?.focus();
}, []);
const onTabSelect = useCallback(
(index: number) => {
focusTab(index);
const label = tabRefs.current[index]?.getAttribute('data-label');
if (label) onTabChange(label);
},
[onTabChange, focusTab],
);
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 replaceLastVisibleTabWithClickedOverflowTab = useCallback((clickedIndex: number) => {
setTabs((prevTabs) => {
const { visible: visibleTabs, overflow: overflowTabs } = prevTabs;
const sortedVisible = [...visibleTabs].sort((a, b) => a - b);
const lastVisible = sortedVisible[sortedVisible.length - 1];
return {
visible: visibleTabs.map((idx) => (idx === lastVisible ? clickedIndex : idx)),
overflow: overflowTabs.map((idx) => (idx === clickedIndex ? lastVisible : idx)),
};
});
}, []);
const onTabClick = useCallback(
(labelOrIndex: string | number) => {
const clickedIndex =
typeof labelOrIndex === 'string'
? tabRefs.current.findIndex((ref) => ref?.getAttribute('data-label') === labelOrIndex)
: labelOrIndex;
if (clickedIndex === -1) return;
const label = tabLabelsRef.current[clickedIndex];
if (!label) return;
// If this is an overflow tab, replace it with a visible one
if (tabs.overflow.includes(clickedIndex)) {
replaceLastVisibleTabWithClickedOverflowTab(clickedIndex);
}
onTabChange(label);
focusTab(clickedIndex);
},
[tabs.overflow, onTabChange, replaceLastVisibleTabWithClickedOverflowTab, focusTab],
);
const calculateVisibleTabs = useCallback(() => {
const container = containerRef?.current;
if (!container) return;
const containerWidth = container.offsetWidth;
const tabWidths = tabWidthsRef.current;
const tabLabels = tabLabelsRef.current;
// Wait until all tabs are registered before calculating
if (tabWidths.length < totalTabs || tabLabels.length < totalTabs) {
return;
}
// Check if container has proper width (not zero)
if (containerWidth === 0) {
return;
}
// Find active tab index by label in tabLabelsRef, not by DOM element
// because tab might not be rendered if it's in overflow
const activeTabIndex = tabLabels.findIndex((label) => label === activeTabRef.current);
let tabsWidth = activeTabIndex !== -1 ? tabWidths[activeTabIndex] : 0;
const visibleTabs = activeTabIndex !== -1 ? [activeTabIndex] : [];
const overflowTabs = [];
for (let i = 0; i < tabWidths.length; i++) {
if (i === activeTabIndex) continue;
const tabWidthWithGap = tabWidths[i] + TABS_GAP;
const projectedWidth = tabsWidth + tabWidthWithGap;
if (projectedWidth <= containerWidth) {
visibleTabs.push(i);
tabsWidth += tabWidthWithGap;
} else {
overflowTabs.push(i);
}
}
if (overflowTabs.length > 0) {
tabsWidth += MORE_BUTTON_WIDTH;
while (tabsWidth > containerWidth && visibleTabs.length > 1) {
const removed = visibleTabs.pop();
// Never remove the active tab - it should always stay visible or be the last one
if (removed !== undefined && removed !== activeTabIndex) {
overflowTabs.unshift(removed);
tabsWidth -= tabWidths[removed];
} else if (removed === activeTabIndex) {
// Put it back if we accidentally removed the active tab
visibleTabs.push(removed);
break;
}
}
// If even with only the active tab visible, it doesn't fit with More button,
// move all tabs to overflow (show only dropdown)
if (tabsWidth > containerWidth && visibleTabs.length === 1) {
overflowTabs.unshift(...visibleTabs);
visibleTabs.length = 0;
}
}
setTabs({
visible: visibleTabs,
overflow: overflowTabs,
});
// Set ready state on first calculation
if (isFirstCalculation.current) {
isFirstCalculation.current = false;
setIsReady(true);
}
}, [containerRef, totalTabs]);
// Store calculateVisibleTabs in ref for use in setTabRef
calculateVisibleTabsRef.current = calculateVisibleTabs;
// Reset isFirstCalculation when totalTabs changes (new page/tabs)
useEffect(() => {
isFirstCalculation.current = true;
setIsReady(false);
// Clear refs so we wait for new tabs to register
tabWidthsRef.current = [];
tabLabelsRef.current = [];
}, [totalTabs]);
// Call calculateVisibleTabs on first render and resize
useEffect(() => {
const container = containerRef?.current;
if (!container) return;
let resizeTimeout: number | null = null;
// Use ResizeObserver to wait until container has proper size
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout) cancelAnimationFrame(resizeTimeout);
resizeTimeout = requestAnimationFrame(calculateVisibleTabs);
});
resizeObserver.observe(container);
const handleResize = () => {
if (resizeTimeout) cancelAnimationFrame(resizeTimeout);
resizeTimeout = requestAnimationFrame(calculateVisibleTabs);
};
window.addEventListener('resize', handleResize);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
if (resizeTimeout) cancelAnimationFrame(resizeTimeout);
};
}, [containerRef, totalTabs, calculateVisibleTabs]);
// Recalculate when activeTab changes to ensure it's visible
useEffect(() => {
if (!containerRef?.current || isFirstCalculation.current) return;
requestAnimationFrame(calculateVisibleTabs);
}, [activeTab, containerRef, calculateVisibleTabs]);
return {
setTabRef,
onTabClick,
handleKeyboard,
visibleTabs: tabs.visible,
overflowTabs: tabs.overflow,
isReady,
};
}
export const useActiveTab = ({ initialTab, tabsId }: UseActiveTabProps) => {
const location = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<string | undefined>(undefined);
const resolvedActiveTab = activeTab ?? initialTab;
const prevActiveTabRef = useRef(resolvedActiveTab);
useEffect(() => {
if (activeTab !== undefined) {
return;
}
setActiveTab(getInitialTab({ initialTab, hash: location.hash, tabsId }));
}, [activeTab, initialTab, location.hash, tabsId]);
useEffect(() => {
const hasActiveTabChanged = prevActiveTabRef.current !== resolvedActiveTab;
if (!tabsId || !hasActiveTabChanged) {
return;
}
prevActiveTabRef.current = resolvedActiveTab;
const nextHashParams = new URLSearchParams(
location.hash.startsWith('#') ? location.hash.slice(1) : location.hash,
);
nextHashParams.set(tabsId, resolvedActiveTab);
const nextHash = `#${nextHashParams.toString()}`;
if (nextHash === location.hash) {
return;
}
navigate(
{
pathname: location.pathname,
search: location.search,
hash: nextHash,
},
{ replace: true },
);
}, [resolvedActiveTab, navigate, location.pathname, location.search, location.hash, tabsId]);
return useMemo(
() => ({
activeTab: resolvedActiveTab,
setActiveTab,
}),
[resolvedActiveTab],
);
};
type GetInitialTabProps = {
initialTab: string;
hash: string;
tabsId?: string;
};
const getInitialTab = ({ initialTab, hash, tabsId }: GetInitialTabProps): string => {
const hashParams = new URLSearchParams(hash.startsWith('#') ? hash.slice(1) : hash);
let resultTab = initialTab;
if (tabsId) {
const tabFromUrl = hashParams.get(tabsId);
resultTab = tabFromUrl ? tabFromUrl : resultTab;
}
return resultTab;
};