import React, { useRef, useLayoutEffect, useCallback } from "react"; type RenderTruncatorFn = (state: { hiddenItemsCount: number }) => React.ReactNode; type OnResizeFn = (bag: { truncate: () => void }) => void; export type TruncatedListProps = { renderTruncator: RenderTruncatorFn; alwaysShowTruncator?: boolean; onResize?: OnResizeFn; className?: string; style?: React.CSSProperties; children?: React.ReactNode; }; const rectContainsRect = (parent: DOMRect, child: DOMRect) => { return ( child.top >= parent.top && child.bottom <= parent.bottom && child.left >= parent.left && child.right <= parent.right ); }; export const TruncatedList = ({ renderTruncator, alwaysShowTruncator, onResize, className, style, children, }: TruncatedListProps) => { const containerRef = useRef(null); const truncate = useCallback(() => { if (!containerRef.current) { return; } const childNodes = Array.from(containerRef.current.children) as HTMLElement[]; // // Put the list in its initial state. // // Change from a scrollable container to overflow: hidden during hydration containerRef.current.style.overflow = "hidden"; // Show all items, hide all truncators. for (let i = 0; i < childNodes.length; ++i) { childNodes[i].hidden = i % 2 === 0; } // If there are no items (the last truncator is always included). if (childNodes.length === 1) { return; } // // Test if truncation is necessary. // if (alwaysShowTruncator) { // if the last truncator fits, exit const truncatorEl = childNodes[childNodes.length - 1]; truncatorEl.hidden = false; if (rectContainsRect(containerRef.current.getBoundingClientRect(), truncatorEl.getBoundingClientRect())) { return; } truncatorEl.hidden = true; } else { // if the last item fits, exit const itemEl = childNodes[childNodes.length - 2]; if (rectContainsRect(containerRef.current.getBoundingClientRect(), itemEl.getBoundingClientRect())) { return; } } // // Truncation is necessary - binary search to find the last truncator that can fit. // const numTruncators = Math.floor((childNodes.length - 1) / 2); let left = 0; let right = numTruncators - 1; let truncatorIndex: number | null = null; while (left <= right) { const middle = Math.floor((left + right) / 2); // show all items before the truncator for (let i = 0; i < middle; i += 1) { childNodes[i * 2 + 1].hidden = false; } // hide all items after the truncator for (let i = middle; i < numTruncators; i += 1) { childNodes[i * 2 + 1].hidden = true; } const truncatorEl = childNodes[middle * 2]; truncatorEl.hidden = false; // check if this truncator fits if (rectContainsRect(containerRef.current.getBoundingClientRect(), truncatorEl.getBoundingClientRect())) { truncatorIndex = middle; left = middle + 1; } else { right = middle - 1; } truncatorEl.hidden = true; } // If we didn't find a truncator that fits, everything will be hidden at this point and we can exit early if (truncatorIndex === null) { return; } // // Now we have found the last truncator that fits, show it. // // show all items before the truncator for (let i = 0; i < truncatorIndex; i += 1) { childNodes[i * 2 + 1].hidden = false; } // hide all items after truncator for (let i = truncatorIndex; i < numTruncators; i += 1) { childNodes[i * 2 + 1].hidden = true; } const truncatorEl = childNodes[truncatorIndex * 2]; truncatorEl.hidden = false; }, [alwaysShowTruncator]); // Set up a resize observer useLayoutEffect(() => { const resizeObserver = new ResizeObserver(() => { if (onResize) { onResize({ truncate }); } else { truncate(); } }); const containerEl = containerRef.current; if (!containerEl) { throw new Error("assert: container is mounted"); } resizeObserver.observe(containerEl); truncate(); return () => { resizeObserver.unobserve(containerEl); }; // trigger if children change also }, [truncate, onResize, children]); const childArray = React.Children.toArray(children); const items = childArray.map((item, i) => (
  • {item}
  • )); return ( ); };