src/tabs/collapsible-tabs.tsx (227 lines of code) (raw):

import {useState, useRef, useCallback, useEffect, memo, type ReactElement, type ReactNode} from 'react'; import classNames from 'classnames'; import fastdom from 'fastdom'; import {FakeMoreButton, MoreButton} from './collapsible-more'; import getTabTitles from './collapsible-tab'; import {type TabProps} from './tab'; import styles from './tabs.css'; const DEFAULT_DEBOUNCE_INTERVAL = 100; const MEASURE_TOLERANCE = 0.5; export interface CollapsibleTabsProps { children: ReactElement<TabProps>[]; selected?: string | undefined; onSelect?: ((key: string) => () => void) | undefined; onLastVisibleIndexChange?: ((index: number) => void) | null | undefined; moreClassName?: string | null | undefined; moreActiveClassName?: string | null | undefined; morePopupClassName?: string | null | undefined; morePopupItemClassName?: string | undefined; initialVisibleItems?: number | null | undefined; morePopupBeforeEnd?: ReactNode; } interface Sizes { tabs: number[]; more: number | undefined; } interface PreparedElements { visible: ReactElement<TabProps>[]; hidden: ReactElement<TabProps>[]; ready?: boolean; } export const CollapsibleTabs = ({ children, selected, onSelect, onLastVisibleIndexChange, moreClassName, moreActiveClassName, morePopupClassName, morePopupBeforeEnd, morePopupItemClassName, initialVisibleItems, }: CollapsibleTabsProps) => { const [sizes, setSizes] = useState<Sizes>({tabs: [], more: undefined}); const [lastVisibleIndex, setLastVisibleIndex] = useState<number | null>(null); const elements = {sizes, lastVisibleIndex}; const [preparedElements, setPreparedElements] = useState<PreparedElements>({ visible: [], hidden: [], }); const measureRef = useRef<HTMLDivElement>(null); const selectedIndex = children.filter(tab => tab.props.alwaysHidden !== true).findIndex(tab => tab.props.id === selected) ?? null; let items; if (preparedElements.ready) { items = preparedElements.visible; } else { items = initialVisibleItems ? children.filter(item => item.props.alwaysHidden !== true).slice(0, initialVisibleItems) : []; } const visibleElements = getTabTitles({ items, selected, onSelect, }); const hiddenElements = (() => { if (preparedElements.ready) { return preparedElements.hidden; } if (initialVisibleItems) { return children.filter(item => !visibleElements.some(visibleItem => visibleItem.props.child === item)); } return []; })(); const adjustTabs = useCallback( (entry: ResizeObserverEntry) => { const containerWidth = entry.contentRect.width; const {tabs: tabsSizes, more = 0} = elements.sizes; let renderMore = children.some(tab => tab.props.alwaysHidden); const tabsToRender: number[] = []; let filledWidth = renderMore ? (more ?? 0) : 0; for (let i = 0; i < tabsSizes.length; i++) { if (filledWidth + tabsSizes[i] < containerWidth + MEASURE_TOLERANCE) { filledWidth += tabsSizes[i]; tabsToRender.push(tabsSizes[i]); } else { break; } } if (tabsToRender.length < tabsSizes.length && !renderMore) { for (let i = tabsToRender.length - 1; i >= 0; i--) { if (filledWidth + more < containerWidth + MEASURE_TOLERANCE) { filledWidth += more; renderMore = true; break; } else { filledWidth -= tabsToRender[i]; tabsToRender.pop(); } } } if (selectedIndex > tabsToRender.length - 1) { const selectedWidth = tabsSizes[selectedIndex]; for (let i = tabsToRender.length - 1; i >= 0; i--) { if (filledWidth + selectedWidth < containerWidth + MEASURE_TOLERANCE) { filledWidth += selectedWidth; break; } else { filledWidth -= tabsToRender[i]; tabsToRender.pop(); } } } const newLastVisibleIndex = tabsToRender.length - 1; if (elements.lastVisibleIndex !== newLastVisibleIndex) { setLastVisibleIndex(newLastVisibleIndex); onLastVisibleIndexChange?.(newLastVisibleIndex); } }, [children, elements.lastVisibleIndex, elements.sizes, onLastVisibleIndexChange, selectedIndex], ); // Prepare list of visible and hidden elements useEffect(() => { const timeout = setTimeout(() => { const res = children.reduce( (accumulator: PreparedElements, tab) => { if (tab.props.alwaysHidden !== true && accumulator.visible.length - 1 < (elements.lastVisibleIndex ?? 0)) { accumulator.visible.push(tab); } else { accumulator.hidden.push(tab); } return accumulator; }, {visible: [], hidden: [], ready: elements.lastVisibleIndex !== null}, ); if (selectedIndex > (elements.lastVisibleIndex ?? 0)) { const selectedItem = children.find(tab => !tab.props.alwaysHidden && tab.props.id === selected); if (selectedItem !== null && selectedItem !== undefined) { res.visible.push(selectedItem); } } const allVisibleTheSame = res.visible.length === preparedElements.visible.length && res.visible.every((item, index) => item === preparedElements.visible[index]); const allHiddenTheSame = res.hidden.length === preparedElements.hidden.length && res.hidden.every((item, index) => item === preparedElements.hidden[index]); if (!allVisibleTheSame || !allHiddenTheSame || preparedElements.ready !== res.ready) { fastdom.mutate(() => setPreparedElements(res)); } }, DEFAULT_DEBOUNCE_INTERVAL); return () => { clearTimeout(timeout); }; }, [children, elements.lastVisibleIndex, preparedElements, selected, selectedIndex]); // Get list of all possibly visible elements to render in a measure container const childItems = children.filter(tab => tab.props.alwaysHidden !== true); const childrenToMeasure = getTabTitles({items: childItems, tabIndex: -1}); // Initial measure for tabs and more button sizes useEffect(() => { if (measureRef.current === null) { return undefined; } const measureTask = fastdom.measure(() => { const container = measureRef.current; const descendants = [...(container?.children ?? [])] as HTMLElement[]; const moreButton = descendants.pop(); let moreButtonWidth = moreButton?.offsetWidth ?? 0; const {marginLeft: moreButtonMarginLeft = '0', marginRight: moreButtonMarginRight = '0'} = moreButton ? getComputedStyle(moreButton) : {}; moreButtonWidth += +moreButtonMarginLeft.replace('px', '') + +moreButtonMarginRight.replace('px', ''); const tabsWidth = descendants.map(node => { const {marginLeft, marginRight} = getComputedStyle(node); const width = node.getBoundingClientRect().width; return width + +marginLeft.replace('px', '') + +marginRight.replace('px', ''); }); const newSummaryWidth = tabsWidth.reduce((acc, curr) => acc + curr, 0); const oldSummaryWidth = elements.sizes.tabs.reduce((acc, curr) => acc + curr, 0); if (elements.sizes.more !== moreButtonWidth || newSummaryWidth !== oldSummaryWidth) { fastdom.mutate(() => setSizes({more: moreButtonWidth, tabs: tabsWidth})); } }); return () => { fastdom.clear(measureTask); }; }, [children, elements.sizes.more, elements.sizes.tabs]); // Start observers to listen resizing and mutation useEffect(() => { if (measureRef.current === null) { return undefined; } let measureTask = () => {}; const resizeObserver = new ResizeObserver(entries => { entries.forEach(entry => { fastdom.clear(measureTask); measureTask = fastdom.mutate(() => adjustTabs(entry)); }); }); resizeObserver.observe(measureRef.current); return () => { fastdom.clear(measureTask); resizeObserver.disconnect(); }; }, [adjustTabs]); const isAdjusted = (elements.lastVisibleIndex !== null && preparedElements.ready) || initialVisibleItems; const className = classNames(styles.titles, styles.autoCollapse, isAdjusted && styles.adjusted); return ( <div className={styles.autoCollapseContainer}> <div className={classNames(className, styles.rendered)}> {visibleElements} <MoreButton moreClassName={moreClassName} moreActiveClassName={moreActiveClassName} morePopupClassName={morePopupClassName} morePopupBeforeEnd={morePopupBeforeEnd} morePopupItemClassName={morePopupItemClassName} items={hiddenElements} selected={selected} onSelect={onSelect} /> </div> <div ref={measureRef} className={classNames(className, styles.measure)}> {childrenToMeasure} <FakeMoreButton hasActiveChildren={hiddenElements.some(item => item.props.alwaysHidden && item.props.id === selected)} moreClassName={moreClassName} moreActiveClassName={moreActiveClassName} /> </div> </div> ); }; export default memo(CollapsibleTabs);