@redocly/theme
Version:
Shared UI components lib
262 lines • 11.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.useActiveTab = void 0;
exports.useTabs = useTabs;
const react_1 = require("react");
const react_router_dom_1 = require("react-router-dom");
const MORE_BUTTON_WIDTH = 80;
const TABS_GAP = 8;
function useTabs({ activeTab, onTabChange, totalTabs, containerRef, }) {
const [tabs, setTabs] = (0, react_1.useState)({
visible: Array.from({ length: totalTabs }, (_, i) => i),
overflow: [],
});
const [isReady, setIsReady] = (0, react_1.useState)(false);
const isFirstCalculation = (0, react_1.useRef)(true);
const tabRefs = (0, react_1.useRef)([]);
const tabWidthsRef = (0, react_1.useRef)([]);
const tabLabelsRef = (0, react_1.useRef)([]);
const activeTabRef = (0, react_1.useRef)(activeTab);
const calculateVisibleTabsRef = (0, react_1.useRef)(null);
// Synchronously update ref before any callbacks or effects run
activeTabRef.current = activeTab;
const setTabRef = (0, react_1.useCallback)((element, index) => {
tabRefs.current[index] = element;
const width = element === null || element === void 0 ? void 0 : element.offsetWidth;
if (width) {
tabWidthsRef.current[index] = width;
}
const label = element === null || element === void 0 ? void 0 : 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 = (0, react_1.useCallback)((index) => {
const currentElement = tabRefs.current[index];
currentElement === null || currentElement === void 0 ? void 0 : currentElement.focus();
}, []);
const onTabSelect = (0, react_1.useCallback)((index) => {
var _a;
focusTab(index);
const label = (_a = tabRefs.current[index]) === null || _a === void 0 ? void 0 : _a.getAttribute('data-label');
if (label)
onTabChange(label);
}, [onTabChange, focusTab]);
const handleKeyboard = (0, react_1.useCallback)((event, index) => {
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 = (0, react_1.useCallback)((clickedIndex) => {
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 = (0, react_1.useCallback)((labelOrIndex) => {
const clickedIndex = typeof labelOrIndex === 'string'
? tabRefs.current.findIndex((ref) => (ref === null || ref === void 0 ? void 0 : 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 = (0, react_1.useCallback)(() => {
const container = containerRef === null || containerRef === void 0 ? void 0 : 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)
(0, react_1.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
(0, react_1.useEffect)(() => {
const container = containerRef === null || containerRef === void 0 ? void 0 : containerRef.current;
if (!container)
return;
let resizeTimeout = 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
(0, react_1.useEffect)(() => {
if (!(containerRef === null || containerRef === void 0 ? void 0 : containerRef.current) || isFirstCalculation.current)
return;
requestAnimationFrame(calculateVisibleTabs);
}, [activeTab, containerRef, calculateVisibleTabs]);
return {
setTabRef,
onTabClick,
handleKeyboard,
visibleTabs: tabs.visible,
overflowTabs: tabs.overflow,
isReady,
};
}
const useActiveTab = ({ initialTab, tabsId }) => {
const location = (0, react_router_dom_1.useLocation)();
const navigate = (0, react_router_dom_1.useNavigate)();
const [activeTab, setActiveTab] = (0, react_1.useState)(undefined);
const resolvedActiveTab = activeTab !== null && activeTab !== void 0 ? activeTab : initialTab;
const prevActiveTabRef = (0, react_1.useRef)(resolvedActiveTab);
(0, react_1.useEffect)(() => {
if (activeTab !== undefined) {
return;
}
setActiveTab(getInitialTab({ initialTab, hash: location.hash, tabsId }));
}, [activeTab, initialTab, location.hash, tabsId]);
(0, react_1.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 (0, react_1.useMemo)(() => ({
activeTab: resolvedActiveTab,
setActiveTab,
}), [resolvedActiveTab]);
};
exports.useActiveTab = useActiveTab;
const getInitialTab = ({ initialTab, hash, tabsId }) => {
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;
};
//# sourceMappingURL=use-tabs.js.map