UNPKG

@redocly/theme

Version:

Shared UI components lib

352 lines (294 loc) 10.6 kB
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; };