import type { MutableRefObject, RefCallback } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react'; import { useMutationObserver, useResizeObserver } from './useObserver'; /** * Hook to manage overflow of items in a container with a "more" button. * * To use, wire up the `wrapperRef` to the container element, and the `listRef` to the * child element that contains the items to be measured. If autoResize is true, * the list element will have its max-width set to prevent wrapping. The listRef element * requires both position:relative and overflow:hidden styles to work correctly. */ export function useOverflowButton({ autoResize, padding = 4, }: { autoResize?: boolean; padding?: number } = {}) { const [hiddenIndex, setHiddenIndex] = useState(-1); const [hiddenCount, setHiddenCount] = useState(0); const [maxWidth, setMaxWidth] = useState('none'); // This is the item container element. const listRef = useRef(null); // The main recalculation function. const handleRecalculate = useCallback(() => { const listEle = listRef.current; if (!listEle) return; const reset = () => { setHiddenIndex(-1); setHiddenCount(0); setMaxWidth('none'); }; // Calculate the width via the parent element, minus the more button, minus the padding. const maxWidth = (listEle.parentElement?.offsetWidth ?? 0) - (listEle.nextElementSibling?.scrollWidth ?? 0) - padding; if (maxWidth <= 0) { reset(); return; } // Iterate through children until we exceed max width. let visible = 0; let index = 0; let totalWidth = 0; for (const child of listEle.children) { if (child instanceof HTMLElement) { const rightOffset = child.offsetLeft + child.offsetWidth; if (rightOffset <= maxWidth) { visible += 1; totalWidth = rightOffset; } else { break; } } index++; } // All are visible, so remove max-width restriction. if (visible === listEle.children.length) { reset(); return; } // Set the width to avoid wrapping, and set hidden count. setHiddenIndex(index); setHiddenCount(listEle.children.length - visible); setMaxWidth(totalWidth); }, [padding]); useEffect(() => { if (listRef.current && autoResize) { listRef.current.style.maxWidth = typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth; } }, [autoResize, maxWidth]); const { listRefCallback, wrapperRefCallback } = useOverflowObservers({ onRecalculate: handleRecalculate, onListRef: listRef, }); return { hiddenCount, hasOverflow: hiddenCount > 0, wrapperRef: wrapperRefCallback, hiddenIndex, maxWidth, listRef: listRefCallback, recalculate: handleRecalculate, }; } export function useOverflowScroll({ widthOffset = 200, absoluteDistance = false, } = {}) { const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); const bodyRef = useRef(null); // Recalculate scrollable state const handleRecalculate = useCallback(() => { if (!bodyRef.current) { return; } if (getComputedStyle(bodyRef.current).direction === 'rtl') { setCanScrollLeft( bodyRef.current.clientWidth - bodyRef.current.scrollLeft < bodyRef.current.scrollWidth, ); setCanScrollRight(bodyRef.current.scrollLeft < 0); } else { setCanScrollLeft(bodyRef.current.scrollLeft > 0); setCanScrollRight( bodyRef.current.scrollLeft + bodyRef.current.clientWidth < bodyRef.current.scrollWidth, ); } }, []); const { wrapperRefCallback } = useOverflowObservers({ onRecalculate: handleRecalculate, onWrapperRef: bodyRef, }); useEffect(() => { handleRecalculate(); }, [handleRecalculate]); // Handle scroll event using requestAnimationFrame to avoid excessive recalculations. const handleScroll = useCallback(() => { requestAnimationFrame(handleRecalculate); }, [handleRecalculate]); // Jump a full screen minus the width offset so that we don't skip a lot. const handleLeftNav = useCallback(() => { if (!bodyRef.current) { return; } bodyRef.current.scrollLeft -= absoluteDistance ? widthOffset : Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset); }, [absoluteDistance, widthOffset]); const handleRightNav = useCallback(() => { if (!bodyRef.current) { return; } bodyRef.current.scrollLeft += absoluteDistance ? widthOffset : Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset); }, [absoluteDistance, widthOffset]); return { bodyRef: wrapperRefCallback, canScrollLeft, canScrollRight, handleLeftNav, handleRightNav, handleScroll, }; } export function useOverflowObservers({ onRecalculate, onListRef, onWrapperRef, }: { onRecalculate: () => void; onListRef?: RefCallback | MutableRefObject; onWrapperRef?: | RefCallback | MutableRefObject; }) { // This is the item container element. const listRef = useRef(null); const resizeObserver = useResizeObserver(onRecalculate); // Iterate through children and observe them for size changes. const handleChildrenChange = useCallback(() => { const listEle = listRef.current; if (listEle) { for (const child of listEle.children) { if (child instanceof HTMLElement) { resizeObserver.observe(child); } } } onRecalculate(); }, [onRecalculate, resizeObserver]); const mutationObserver = useMutationObserver(handleChildrenChange); // Set up observers. const handleObserve = useCallback(() => { if (wrapperRef.current) { resizeObserver.observe(wrapperRef.current); } if (listRef.current) { mutationObserver.observe(listRef.current, { childList: true }); handleChildrenChange(); } }, [handleChildrenChange, mutationObserver, resizeObserver]); // Watch the wrapper for size changes, and recalculate when it resizes. const wrapperRef = useRef(null); const wrapperRefCallback = useCallback( (node: HTMLElement | null) => { if (node) { wrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955 handleObserve(); if (typeof onWrapperRef === 'function') { onWrapperRef(node); } else if (onWrapperRef && 'current' in onWrapperRef) { onWrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955 } } }, [handleObserve, onWrapperRef], ); // If there are changes to the children, recalculate which are visible. const listRefCallback = useCallback( (node: HTMLElement | null) => { if (node) { listRef.current = node; handleObserve(); if (typeof onListRef === 'function') { onListRef(node); } else if (onListRef && 'current' in onListRef) { onListRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955 } } }, [handleObserve, onListRef], ); return { wrapperRefCallback, listRefCallback, }; }