1 import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
3 type UseResponsiveItemsOptions = { gap: number; maxChildWidth: number };
5 /** Tracks seen visible counts against children width to optimize reconciliation.
6 * Monitors previously observed visible counts and ensures they align with constraints.
7 * If the container width has not been reached and the current visible count has not been
8 * observed, attempts to add a visible item; otherwise, removes one. The process stops early
9 * when decreasing to a previously seen visible count. This incremental resolution optimizes
10 * the adjustment of the visible item count based on the container dimensions. */
11 export const useResponsiveHorizontalList = <T>(items: T[], options: UseResponsiveItemsOptions) => {
12 const [visibleCount, setVisibleCount] = useState(0);
13 const ref = useRef<HTMLDivElement>(null);
14 const seen = useRef<Map<number, number>>(new Map());
15 const max = useRef<number>(items.length);
16 max.current = items.length;
18 const reconciliate = useCallback(() => {
19 if (!ref.current) return;
21 const children = Array.from(ref.current?.children ?? []) as HTMLElement[];
22 const maxWidth = ref.current?.offsetWidth ?? 0;
23 if (maxWidth === 0) return;
25 const checkLower = (n: number): number => {
26 const widthForCount = seen.current.get(n);
27 if (widthForCount === undefined) return n;
28 return widthForCount > maxWidth ? checkLower(n - 1) : n;
31 const checkUpper = (n: number): number => {
32 const widthForCount = seen.current.get(n);
33 if (widthForCount === undefined) return n;
34 return widthForCount < maxWidth ? checkUpper(n + 1) : n - 1;
37 setVisibleCount((currentCount) => {
38 const cached = seen.current.get(currentCount);
39 const width = cached ?? children.reduce((total, { offsetWidth }) => total + offsetWidth + options.gap, 0);
41 /* children may have not rendered yet */
42 if (width !== 0) seen.current.set(currentCount, width);
44 if (width > maxWidth) return Math.max(0, checkLower(currentCount - 1));
45 if (width < maxWidth) return Math.min(max.current, checkUpper(currentCount + 1));
51 useLayoutEffect(() => {
52 setVisibleCount(() => {
53 const maxWidth = ref.current?.offsetWidth ?? 0;
54 return Math.min(max.current, Math.floor(maxWidth / (options.maxChildWidth + options.gap)));
57 window.addEventListener('resize', reconciliate);
58 return () => window.removeEventListener('resize', reconciliate);
61 useLayoutEffect(() => {
66 useLayoutEffect(reconciliate, [visibleCount]);
71 visible: items.slice(0, visibleCount),
72 hidden: items.slice(visibleCount),