UNPKG

@redocly/theme

Version:

Shared UI components lib

329 lines (277 loc) 10 kB
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, }; }