UNPKG

react-procedural-scroller

Version:

A headless React hook for building infinite scroller components that display procedurally generated data, perfect for use cases like date scrollers, where row data is generated dynamically rather than fetched from a dataset. Notable features include: full

1 lines 84.1 kB
{"version":3,"sources":["../src/index.ts","../src/hooks/use-procedural-scroller.ts","../src/types/items.ts","../src/lib/error.ts","../src/validation/number/integer.ts","../src/validation/number/non-negative-integer.ts","../src/validation/items.ts","../src/validation/number/non-negative-real.ts","../src/validation/range-scaled-sizes.ts","../src/types/scroll.ts","../src/validation/scroll.ts","../src/lib/items.ts","../src/hooks/use-scroll-handler.ts","../src/hooks/use-deferred-scroll-reset.ts","../src/lib/dimensions.ts","../src/lib/scroll.ts","../src/hooks/use-item-stack.ts","../src/hooks/use-element-ref-map.ts","../src/validation/hooks/use-element-ref-map.ts","../src/validation/number/positive-integer.ts","../src/lib/map.ts","../src/hooks/use-dimension-observer.ts","../src/validation/array.ts","../src/lib/array.ts","../src/hooks/use-item-size-check.ts","../src/hooks/use-unbounded-height-check.ts"],"sourcesContent":["import { useProceduralScroller } from \"./hooks/use-procedural-scroller\";\n\nexport { useProceduralScroller };\n\nexport type Item<ItemType extends HTMLElement> = NonNullable<\n ReturnType<typeof useProceduralScroller<HTMLElement, ItemType>>[\"items\"]\n>[number];\n","import {\n RefObject,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { Scroll } from \"../types/scroll\";\nimport { Integer } from \"../types/number/integer\";\nimport { getItems } from \"../lib/items\";\nimport { useScrollHandler } from \"./use-scroll-handler\";\nimport { useDeferredScrollReset } from \"./use-deferred-scroll-reset\";\nimport { RangeScaledSizes } from \"../types/range-scaled-sizes\";\nimport { asRangeScaledSizes } from \"../validation/range-scaled-sizes\";\nimport {\n asNonNegativeReal,\n isNonNegativeReal,\n} from \"../validation/number/non-negative-real\";\nimport { asInteger } from \"../validation/number/integer\";\nimport {\n ScrollToIndexInput,\n UseProceduralScrollerProps,\n UseProceduralScrollerResult,\n} from \"../types/hooks/use-procedural-scroller\";\nimport { asScroll } from \"../validation/scroll\";\nimport { useItemStack } from \"./use-item-stack\";\nimport { mergeConsecutiveIntegerArrays } from \"../lib/array\";\nimport { UseElementRefMapResult } from \"../types/hooks/use-element-ref-map\";\nimport { ProceduralScrollerError } from \"../lib/error\";\nimport { getScrollLength, scrollToIndexInputToScroll } from \"../lib/scroll\";\nimport { NonNegativeReal } from \"../types/number/non-negative-real\";\nimport { useItemSizeCheck } from \"./use-item-size-check\";\nimport { getElementSize } from \"../lib/dimensions\";\nimport { useUnboundedHeightCheck } from \"./use-unbounded-height-check\";\n\nconst scrollToIndexDebounceDelay = 100;\n\nexport const useProceduralScroller = <\n ContainerType extends HTMLElement,\n ItemType extends HTMLElement,\n>({\n getMinItemSize,\n scrollAreaScale = 3,\n paddingAreaScale = {\n start: 1,\n end: 1,\n },\n initialScroll = {\n block: \"center\",\n index: 0,\n },\n scrollDirection = \"vertical\",\n minIndex: minIndexInput,\n maxIndex: maxIndexInput,\n initialContainerSize: initialContainerSizeInput,\n validateLayouts = {\n container: true,\n items: true,\n },\n}: UseProceduralScrollerProps): UseProceduralScrollerResult<\n ContainerType,\n ItemType\n> => {\n /*\n * Derived State:\n * `rangeScaledSizes` maps each section of the virtualized scroller (padding, content) to a normalized height/width\n * value relative to the container's total height/width.\n * `dimensions` selects DOM scroll properties based on scroll direction, abstracting axis-specific access.\n * `minIndex` / `maxIndex` are the optional bounds of items to render.\n * `initialContainerSize` is an optional prop that allows items to render on the first page load\n * without waiting for the container to mount and be measured.\n */\n const initialContainerSize = useMemo((): NonNegativeReal | null => {\n if (isNonNegativeReal(initialContainerSizeInput as number)) {\n return asNonNegativeReal(initialContainerSizeInput as number);\n }\n return null;\n }, [initialContainerSizeInput]);\n const minIndex = useMemo((): Integer | null => {\n if (typeof minIndexInput === \"number\") {\n if (typeof maxIndexInput === \"number\" && minIndexInput > maxIndexInput) {\n throw new ProceduralScrollerError(\n \"minIndex should not be greater than maxIndex\",\n { minIndexInput, maxIndexInput },\n );\n }\n return asInteger(minIndexInput);\n }\n return null;\n }, [minIndexInput, maxIndexInput]);\n const maxIndex = useMemo((): Integer | null => {\n if (typeof maxIndexInput === \"number\") {\n if (typeof minIndexInput === \"number\" && minIndexInput > maxIndexInput) {\n throw new ProceduralScrollerError(\n \"minIndex should not be greater than maxIndex\",\n { minIndexInput, maxIndexInput },\n );\n }\n return asInteger(maxIndexInput);\n }\n return null;\n }, [minIndexInput, maxIndexInput]);\n const rangeScaledSizes = useMemo((): RangeScaledSizes => {\n return asRangeScaledSizes({\n startPadding: asNonNegativeReal(paddingAreaScale.start),\n startContent: asNonNegativeReal((scrollAreaScale - 1) / 2),\n content: asNonNegativeReal(1),\n endContent: asNonNegativeReal((scrollAreaScale - 1) / 2),\n endPadding: asNonNegativeReal(paddingAreaScale.end),\n });\n }, [scrollAreaScale, paddingAreaScale]);\n\n /*\n * Refs:\n * `scroll` stores scroll alignment and index to preserve visible position during list updates.\n * `scrollResetting` is used to suppress scroll handlers whilst a list update is in progress.\n * `containerRef` references the container DOM element.\n * `scrollToIndexDebounceRef` stores the timeout ID used to detect the end of smooth-scroll animations: when no\n * scroll event occurs for `scrollToIndexDebounceDelay`ms, the animation is considered complete.\n */\n const scroll = useRef<Scroll>(\n asScroll({\n ...initialScroll,\n index: asInteger(initialScroll.index),\n }),\n );\n const scrollResetting = useRef<boolean>(true);\n const containerRef = useRef<ContainerType>(null);\n const scrollToIndexDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(\n null,\n );\n const scrollToIndexInProgressRef = useRef<boolean>(false);\n\n /*\n * State:\n *\n * Two item stacks (`itemStackA` and `itemStackB`) are used for double buffering:\n * - One stack stores the currently displayed items in the container.\n * - The other stack stores items for a pending scrollToIndex animation, allowing\n * the container to jump between stacks without rendering all intermediate items.\n *\n * `itemStackPointer` (0 or 1) indicates which stack is currently active for display.\n *\n * `items` / `setItems` / `getPrimaryRef` refer to the active stack.\n * `secondaryItems` / `setSecondaryItems` / `getSecondaryRef` refer to the inactive stack.\n *\n * `mergedIndexes` is the array of item indexes returned by the hook for the library consumer\n * to render DOM elements. It combines the indexes from both stacks into a single list\n */\n const [scrollToIndexInput, setScrollToIndexInput] =\n useState<ScrollToIndexInput | null>(null);\n const [itemStackPointer, setItemStackPointer] = useState<0 | 1>(0);\n const itemStackA = useItemStack<ContainerType, ItemType>({\n scrollDirection,\n rangeScaledSizes,\n containerRef,\n scroll: [scroll.current, scrollToIndexInputToScroll(scrollToIndexInput)][\n itemStackPointer\n ],\n getMinItemSize,\n minIndex,\n maxIndex,\n initialContainerSize,\n });\n const itemStackB = useItemStack<ContainerType, ItemType>({\n scrollDirection,\n rangeScaledSizes,\n containerRef,\n scroll: [scroll.current, scrollToIndexInputToScroll(scrollToIndexInput)][\n Number(!itemStackPointer)\n ],\n getMinItemSize,\n minIndex,\n maxIndex,\n initialContainerSize,\n });\n const itemStacks = useMemo(\n () => [itemStackA, itemStackB],\n [itemStackA, itemStackB],\n );\n const items = useMemo(\n () => itemStacks[itemStackPointer].items,\n [itemStackPointer, itemStacks],\n );\n const setItems = useMemo(\n () => itemStacks[itemStackPointer].setItems,\n [itemStackPointer, itemStacks],\n );\n const getPrimaryRef = useMemo(\n () => itemStacks[itemStackPointer].getRef,\n [itemStackPointer, itemStacks],\n );\n const secondaryItems = useMemo(\n () => itemStacks[Number(!itemStackPointer)].items,\n [itemStackPointer, itemStacks],\n );\n const setSecondaryItems = useMemo(\n () => itemStacks[Number(!itemStackPointer)].setItems,\n [itemStackPointer, itemStacks],\n );\n const getSecondaryRef = useMemo(\n () => itemStacks[Number(!itemStackPointer)].getRef,\n [itemStackPointer, itemStacks],\n );\n const mergedIndexes = useMemo((): Integer[] => {\n return mergeConsecutiveIntegerArrays(\n items?.indexes || [],\n secondaryItems?.indexes || [],\n );\n }, [items, secondaryItems]);\n\n /*\n * Item ref accessors:\n *\n * `getRef` returns the element ref for a given index from either item stack.\n * `getRefOrError` behaves like `getRef` but throws an error if no ref is found.\n */\n const getRef: UseElementRefMapResult<ItemType>[\"getRef\"] = useCallback(\n (index) => {\n const primaryRef = getPrimaryRef(index);\n const secondaryRef = getSecondaryRef(index);\n if (primaryRef?.current) {\n return primaryRef;\n }\n if (secondaryRef?.current) {\n return secondaryRef;\n }\n return primaryRef || secondaryRef;\n },\n [getPrimaryRef, getSecondaryRef],\n );\n const getRefOrError: UseElementRefMapResult<ItemType>[\"getRefOrError\"] =\n useCallback(\n <RequireNonNull extends boolean>(\n index: string | number,\n requireNonNull: RequireNonNull,\n ) => {\n const ref = getRef(index);\n if (ref && !requireNonNull) {\n return ref as RequireNonNull extends true\n ? RefObject<ItemType>\n : RefObject<ItemType | null>;\n }\n if (ref?.current !== null && requireNonNull) {\n return ref as RequireNonNull extends true\n ? RefObject<ItemType>\n : RefObject<ItemType | null>;\n }\n throw new ProceduralScrollerError(\"Could not find ref\", {\n index,\n ref,\n requireNonNull,\n });\n },\n [getRef],\n );\n\n /*\n * `updateItems` recalculates the active items in the container based on the updated scroll position,\n * It also adjusts the container's scroll offset, creating an illusion of continuous scrolling.\n */\n const updateItems = useCallback(\n (newScroll: Scroll, container: ContainerType) => {\n scroll.current = { ...newScroll };\n scrollResetting.current = true;\n setItems(\n getItems(\n asNonNegativeReal(\n getElementSize(container, scrollDirection, {\n includePadding: false,\n includeBorder: false,\n includeMargin: false,\n }),\n ),\n rangeScaledSizes,\n newScroll,\n getMinItemSize,\n minIndex,\n maxIndex,\n ),\n );\n },\n [\n setItems,\n scrollDirection,\n rangeScaledSizes,\n getMinItemSize,\n minIndex,\n maxIndex,\n ],\n );\n\n /*\n * The following logic finalises a scrollToIndex operation in three steps:\n * 1.) `completeScrollToIndex` swaps the active item stack\n * 2.) After `itemStackPointer` updates the first useEffect sets the old item stack (`secondaryItems`) to null\n * 3.) After `secondaryItems` updates a second useEffect resets cross-render scrollToIndex related variables\n */\n const completeScrollToIndex = useCallback(() => {\n if (scrollToIndexInProgressRef.current) {\n scrollResetting.current = true;\n setItemStackPointer((prev) => {\n return Number(!prev) as 0 | 1;\n });\n }\n }, [setItemStackPointer, scrollToIndexInProgressRef]);\n useEffect(() => {\n if (scrollToIndexInProgressRef.current) {\n setSecondaryItems(null);\n }\n }, [setSecondaryItems, itemStackPointer]);\n useEffect(() => {\n if (secondaryItems === null && scrollToIndexInProgressRef.current) {\n setScrollToIndexInput(null);\n scrollToIndexInProgressRef.current = false;\n scrollToIndexDebounceRef.current = null;\n }\n }, [\n setScrollToIndexInput,\n scrollResetting,\n scrollToIndexInProgressRef,\n secondaryItems,\n ]);\n\n /*\n * Container onScroll logic:\n *\n * If the scroll position moves outside the 'contentItem' range, this triggers\n * a scroll reset to realign the viewport and re-render items, creating an illusion\n * of continuous scrolling.\n */\n const containerScrollHandler = useCallback(\n (container: ContainerType, ev: Event, isRetry: boolean = false): void => {\n if (secondaryItems) {\n if (typeof scrollToIndexDebounceRef.current === \"number\")\n clearTimeout(scrollToIndexDebounceRef.current);\n scrollToIndexDebounceRef.current = setTimeout(\n completeScrollToIndex,\n scrollToIndexDebounceDelay,\n );\n return;\n }\n if (scrollResetting.current || !items) return;\n const startContentIndex = items.rangePointers[\"startContent\"][0];\n const endContentIndex = items.rangePointers[\"endContent\"][1];\n const startContentItem = getRef(\n items.indexes[startContentIndex],\n )?.current;\n const endContentItem = getRef(items.indexes[endContentIndex])?.current;\n if (!startContentItem || !endContentItem) {\n if (!isRetry) {\n requestAnimationFrame(() =>\n containerScrollHandler(container, ev, true),\n );\n }\n return;\n }\n const containerScroll =\n scrollDirection === \"horizontal\"\n ? container.scrollLeft\n : container.scrollTop;\n if (\n (typeof minIndex !== \"number\" || mergedIndexes[0] > minIndex) &&\n containerScroll <\n getScrollLength(\"start\", container, startContentItem, scrollDirection)\n ) {\n updateItems(\n {\n block: \"start\",\n index: items.indexes[startContentIndex],\n },\n container,\n );\n } else if (\n (typeof maxIndex !== \"number\" ||\n mergedIndexes[mergedIndexes.length - 1] < maxIndex) &&\n containerScroll >\n getScrollLength(\"end\", container, endContentItem, scrollDirection)\n ) {\n updateItems(\n {\n block: \"end\",\n index: items.indexes[endContentIndex],\n },\n container,\n );\n }\n },\n [\n secondaryItems,\n items,\n getRef,\n scrollDirection,\n minIndex,\n mergedIndexes,\n maxIndex,\n completeScrollToIndex,\n updateItems,\n ],\n );\n useScrollHandler({\n elementRef: containerRef,\n handler: containerScrollHandler,\n });\n\n /*\n * On every update to the `items` list, this hook resets the scroll position to maintain\n * the expected viewport alignment using the latest scroll reference.\n */\n useDeferredScrollReset({\n scroll,\n onScrollReset: () => {\n scrollResetting.current = false;\n },\n containerRef,\n items,\n getRef,\n suppress: scrollToIndexInProgressRef.current,\n scrollDirection,\n });\n\n /*\n * External API for manually scrolling to a specific item by index and alignment.\n * The scrollToIndex function works as follows:\n * 1.) The secondary item stack is updated to contain the target items.\n * 2.) Following this state update, the browser 'scrollTo' api is used to scroll the container to the target position.\n * 3.) The `completeScrollToIndex` function is called to finalise the scroll and swap the item stacks such that the\n * one containing the target items is now the primary.\n */\n const scrollToIndex = useCallback(\n (input: ScrollToIndexInput) => {\n const container = containerRef?.current;\n if (!container) {\n throw new ProceduralScrollerError(\"Could not find container\", {\n container,\n });\n }\n let targetIndex: Integer = asInteger(input.index);\n if (typeof minIndex === \"number\") {\n targetIndex = asInteger(Math.max(targetIndex, minIndex));\n }\n if (typeof maxIndex === \"number\") {\n targetIndex = asInteger(Math.min(targetIndex, maxIndex));\n }\n const targetScroll = asScroll({\n block: input.block,\n index: targetIndex,\n });\n scroll.current = targetScroll;\n scrollResetting.current = true;\n scrollToIndexInProgressRef.current = true;\n setScrollToIndexInput({\n ...input,\n ...targetScroll,\n });\n setSecondaryItems(\n getItems(\n asNonNegativeReal(\n getElementSize(container, scrollDirection, {\n includePadding: false,\n includeBorder: false,\n includeMargin: false,\n }),\n ),\n rangeScaledSizes,\n targetScroll,\n getMinItemSize,\n minIndex,\n maxIndex,\n ),\n );\n },\n [\n getMinItemSize,\n maxIndex,\n minIndex,\n rangeScaledSizes,\n scrollDirection,\n setSecondaryItems,\n ],\n );\n\n /*\n * This effect runs immediately after `scrollToIndex` updates `secondaryItems`.\n * Its role is to carry out the actual DOM-level scroll:\n */\n useEffect(() => {\n if (!secondaryItems || !scrollToIndexInput) return;\n const itemRef = getRefOrError(scrollToIndexInput.index, true);\n const item = itemRef?.current;\n const container = containerRef?.current;\n if (!item || !container) {\n throw new ProceduralScrollerError(\n `Tried to scroll to index = ${scrollToIndexInput.index} but the element/container has no mounted ref`,\n { container, item },\n );\n }\n const scrollPos = getScrollLength(\n scrollToIndexInput.block,\n container,\n item,\n scrollDirection,\n );\n container.scrollTo({\n behavior: scrollToIndexInput.behavior || \"auto\",\n [scrollDirection === \"horizontal\" ? \"left\" : \"top\"]: scrollPos,\n });\n scrollToIndexDebounceRef.current = setTimeout(\n completeScrollToIndex,\n scrollToIndexDebounceDelay,\n );\n }, [\n getRefOrError,\n secondaryItems,\n scrollToIndexInput,\n maxIndex,\n minIndex,\n completeScrollToIndex,\n scrollDirection,\n ]);\n\n /*\n * Compute items result:\n */\n const itemsResult = useMemo(() => {\n if (items) {\n return mergedIndexes.map((index: number) => {\n return {\n index,\n ref: getRefOrError(index, false),\n };\n });\n } else {\n return null;\n }\n }, [getRefOrError, items, mergedIndexes]);\n\n /*\n * Layout validation:\n * After mounting, `useItemSizeCheck` re-runs `getMinItemSize` to ensure each\n * item's size meets or exceeds the computed minimum. `useUnboundedHeightCheck`\n * detects when the container height is unbounded leading to re-render loops.\n */\n useItemSizeCheck({\n items: itemsResult,\n getMinItemSize,\n scrollDirection,\n enabled: validateLayouts?.items !== false,\n });\n useUnboundedHeightCheck({\n items: itemsResult,\n containerRef,\n scrollDirection,\n enabled: validateLayouts?.container !== false,\n });\n\n /*\n * Hook return value:\n */\n return {\n scrollToIndex,\n container: {\n ref: containerRef,\n },\n items: itemsResult,\n };\n};\n","import { Integer } from \"./number/integer\";\nimport { NonNegativeInteger } from \"./number/non-negative-integer\";\n\nexport const itemRangeKeys = [\n \"startPadding\",\n \"startContent\",\n \"content\",\n \"endContent\",\n \"endPadding\",\n] as const;\n\nexport type ItemRange = (typeof itemRangeKeys)[number];\n\nexport type Items = {\n indexes: Integer[];\n rangePointers: {\n [key in ItemRange]: [NonNegativeInteger, NonNegativeInteger];\n };\n};\n","export class ProceduralScrollerError<\n T extends Record<string, unknown>,\n> extends Error {\n public readonly data?: T;\n constructor(message: string, data?: T) {\n super(message);\n this.name = this.constructor.name;\n Object.setPrototypeOf(this, new.target.prototype); // Required for subclassing Error in ES5\n if (data) {\n this.data = data;\n }\n }\n}\n","import { Integer } from \"../../types/number/integer\";\nimport { ProceduralScrollerError } from \"../../lib/error\";\n\nexport function asInteger(n: number): Integer {\n if (!isInteger(n)) {\n throw new ProceduralScrollerError(\n `Expected an integer number, received n=${n}`,\n { n },\n );\n }\n return n as Integer;\n}\n\nexport function isInteger(input: number): input is Integer {\n return !(!Number.isInteger(input) || !isFinite(input) || isNaN(input));\n}\n","import { NonNegativeInteger } from \"../../types/number/non-negative-integer\";\nimport { ProceduralScrollerError } from \"../../lib/error\";\n\nexport function asNonNegativeInteger(n: number): NonNegativeInteger {\n if (!isNonNegativeInteger(n)) {\n throw new ProceduralScrollerError(\n `Expected a non-negative integer number, received n=${n}`,\n { n },\n );\n }\n return n as NonNegativeInteger;\n}\n\nexport function isNonNegativeInteger(n: number): n is NonNegativeInteger {\n return !(!Number.isInteger(n) || Number(n) < 0 || !isFinite(n));\n}\n","import { isInteger } from \"./number/integer\";\nimport { isNonNegativeInteger } from \"./number/non-negative-integer\";\nimport { itemRangeKeys, Items } from \"../types/items\";\nimport { ProceduralScrollerError } from \"../lib/error\";\n\nfunction asItemsRangePointers(\n input: Items[\"rangePointers\"],\n): Items[\"rangePointers\"] {\n const errorPrefix = `Invalid rangePointers object:`;\n if (\n typeof input !== \"object\" ||\n input === null ||\n Object.keys(input).length !== itemRangeKeys.length\n ) {\n throw new ProceduralScrollerError(\n `${errorPrefix} Expected an object with ${itemRangeKeys.length} keys`,\n { input },\n );\n }\n for (const key of itemRangeKeys) {\n const inputAsRangePointers = input as Items[\"rangePointers\"];\n if (\n !Array.isArray(inputAsRangePointers[key]) ||\n inputAsRangePointers[key].length !== 2 ||\n !isNonNegativeInteger(inputAsRangePointers[key][0]) ||\n !isNonNegativeInteger(inputAsRangePointers[key][1])\n ) {\n throw new ProceduralScrollerError(\n `${errorPrefix} Invalid pointer array for key=${key}`,\n { input },\n );\n }\n }\n return input as Items[\"rangePointers\"];\n}\n\nexport function asItems(input: Items): Items {\n const errorPrefix = \"Invalid items object:\";\n const expectedKeys = 2;\n if (\n typeof input !== \"object\" ||\n input === null ||\n Object.keys(input).length !== expectedKeys\n ) {\n throw new ProceduralScrollerError(\n `${errorPrefix} Expected an object with ${expectedKeys} keys`,\n { input },\n );\n }\n const inputAsItems = input as Items;\n asItemsRangePointers(inputAsItems.rangePointers);\n if (!Array.isArray(inputAsItems.indexes)) {\n throw new ProceduralScrollerError(\n `${errorPrefix} Expected items.indexes to be an array`,\n { input },\n );\n }\n for (let i = 0; i < inputAsItems.indexes.length; i++) {\n if (!isInteger(inputAsItems.indexes[i])) {\n throw new ProceduralScrollerError(\n `${errorPrefix} items.indexes[${i}] is not an integer`,\n { input },\n );\n }\n if (i > 0 && i < inputAsItems.indexes.length - 1) {\n if (inputAsItems.indexes[i - 1] !== inputAsItems.indexes[i] - 1) {\n throw new ProceduralScrollerError(\n `${errorPrefix} items.indexes[${i - 1}] and items.indexes[${i}] are not consecutive`,\n { input },\n );\n }\n if (inputAsItems.indexes[i + 1] !== inputAsItems.indexes[i] + 1) {\n throw new ProceduralScrollerError(\n `${errorPrefix} items.indexes[${i}] and items.indexes[${i + 1}] are not consecutive`,\n { input },\n );\n }\n }\n }\n return input;\n}\n","import { ProceduralScrollerError } from \"../../lib/error\";\n\nexport type NonNegativeReal = number & { __nonNegativeReal: true };\n\nexport function asNonNegativeReal(n: number): NonNegativeReal {\n if (!isNonNegativeReal(n)) {\n throw new ProceduralScrollerError(\n `Expected a non-negative real number, received n=${n}`,\n { n },\n );\n }\n return n as NonNegativeReal;\n}\n\nexport function isNonNegativeReal(n: number): n is NonNegativeReal {\n return !(typeof n !== \"number\" || isNaN(n) || !isFinite(n) || Number(n) < 0);\n}\n","import { isNonNegativeReal } from \"./number/non-negative-real\";\nimport { itemRangeKeys } from \"../types/items\";\nimport { RangeScaledSizes } from \"../types/range-scaled-sizes\";\nimport { ProceduralScrollerError } from \"../lib/error\";\n\nexport function asRangeScaledSizes(input: RangeScaledSizes): RangeScaledSizes {\n const errorPrefix = \"Received invalid rangeScaledSizes value:\";\n if (\n typeof input !== \"object\" ||\n Object.keys(input).length !== itemRangeKeys.length\n ) {\n throw new ProceduralScrollerError(\n `${errorPrefix} Expected object with ${itemRangeKeys.length} keys.`,\n { input, itemRangeKeys },\n );\n }\n itemRangeKeys.forEach((key) => {\n if (!isNonNegativeReal(input[key])) {\n throw new ProceduralScrollerError(\n `${errorPrefix} Expected rangeScaledSizes[${key}] to be a non-negative real number.`,\n { input, itemRangeKeys },\n );\n }\n });\n return input;\n}\n","import { Integer } from \"./number/integer\";\n\nexport const scrollBlocks = [\"start\", \"center\", \"end\"] as const;\n\nexport type Scroll = {\n block: (typeof scrollBlocks)[number];\n index: Integer;\n};\n","import { Scroll, scrollBlocks } from \"../types/scroll\";\nimport { isInteger } from \"./number/integer\";\nimport { ProceduralScrollerError } from \"../lib/error\";\n\nexport function asScroll(input: unknown): Scroll {\n function throwError(message: string) {\n throw new ProceduralScrollerError(`Invalid scroll object: ${message}`, {\n input,\n });\n }\n const expectedKeys = 2;\n if (\n typeof input !== \"object\" ||\n input === null ||\n Object.keys(input).length !== expectedKeys\n ) {\n throwError(`Expected an object with ${expectedKeys} keys`);\n }\n if (scrollBlocks.indexOf((input as Scroll)?.block) === -1) {\n throwError(`Invalid scroll.block value`);\n }\n if (!isInteger((input as Scroll)?.index)) {\n throwError(`Expected scroll.index to be an integer`);\n }\n return input as Scroll;\n}\n","import { Items, itemRangeKeys } from \"../types/items\";\nimport { NonNegativeReal } from \"../types/number/non-negative-real\";\nimport { Scroll } from \"../types/scroll\";\nimport { Integer } from \"../types/number/integer\";\nimport { NonNegativeInteger } from \"../types/number/non-negative-integer\";\nimport { RangeScaledSizes } from \"../types/range-scaled-sizes\";\nimport { asItems } from \"../validation/items\";\nimport { asNonNegativeReal } from \"../validation/number/non-negative-real\";\nimport { asInteger } from \"../validation/number/integer\";\nimport { asNonNegativeInteger } from \"../validation/number/non-negative-integer\";\nimport { UseProceduralScrollerProps } from \"../types/hooks/use-procedural-scroller\";\nimport { asRangeScaledSizes } from \"../validation/range-scaled-sizes\";\nimport { asScroll } from \"../validation/scroll\";\nimport { ProceduralScrollerError } from \"./error\";\n\nfunction throwIndexLimitDistanceError(minIndex: Integer, maxIndex: Integer) {\n throw new ProceduralScrollerError(\n \"Invalid configuration: the specified minIndex and maxIndex are too close together. There isn’t enough room between them to render the required items.\",\n { minIndex, maxIndex },\n );\n}\n\nfunction indexesExceedLimits(\n minIndexLimit: Integer | null,\n maxIndexLimit: Integer | null,\n minIndex: Integer,\n maxIndex: Integer,\n):\n | {\n minLimitExceeded: true;\n maxLimitExceeded: false;\n }\n | {\n minLimitExceeded: false;\n maxLimitExceeded: true;\n }\n | {\n minLimitExceeded: false;\n maxLimitExceeded: false;\n } {\n const minLimitExceeded =\n typeof minIndexLimit === \"number\" && minIndex < minIndexLimit;\n const maxLimitExceeded =\n typeof maxIndexLimit === \"number\" && maxIndex > maxIndexLimit;\n if (minLimitExceeded && maxLimitExceeded)\n throwIndexLimitDistanceError(minIndex, maxIndex);\n if (minLimitExceeded)\n return { minLimitExceeded: true, maxLimitExceeded: false };\n if (maxLimitExceeded)\n return { minLimitExceeded: false, maxLimitExceeded: true };\n return { minLimitExceeded: false, maxLimitExceeded: false };\n}\n\nexport function getItems(\n containerSize: NonNegativeReal,\n rangeScaledSizes: RangeScaledSizes,\n scroll: Scroll,\n getMinItemSize: UseProceduralScrollerProps[\"getMinItemSize\"],\n minIndex: Integer | null = null,\n maxIndex: Integer | null = null,\n): Items {\n asNonNegativeReal(containerSize);\n asRangeScaledSizes(rangeScaledSizes);\n asScroll(scroll);\n\n // Get index arrays:\n let contentIndexes: Integer[] = [];\n if (scroll.block === \"start\") {\n contentIndexes = getRangeIndexes(\n rangeScaledSizes[\"content\"],\n scroll.index,\n 1,\n containerSize,\n getMinItemSize,\n );\n } else if (scroll.block === \"end\") {\n contentIndexes = getRangeIndexes(\n rangeScaledSizes[\"content\"],\n scroll.index,\n -1,\n containerSize,\n getMinItemSize,\n );\n } else if (scroll.block === \"center\") {\n contentIndexes = [\n ...getRangeIndexes(\n asNonNegativeReal(rangeScaledSizes[\"content\"] * 0.5),\n asInteger(scroll.index - 1),\n -1,\n containerSize,\n getMinItemSize,\n ),\n scroll.index,\n ...getRangeIndexes(\n asNonNegativeReal(rangeScaledSizes[\"content\"] * 0.5),\n asInteger(scroll.index + 1),\n 1,\n containerSize,\n getMinItemSize,\n ),\n ];\n } else {\n throw new ProceduralScrollerError(\n `Invalid: scroll.block = ${scroll.block}`,\n scroll,\n );\n }\n const startContentIndexes = getRangeIndexes(\n rangeScaledSizes[\"startContent\"],\n asInteger(contentIndexes[0] - 1),\n -1,\n containerSize,\n getMinItemSize,\n );\n const startPaddingIndexes = getRangeIndexes(\n rangeScaledSizes[\"startPadding\"],\n asInteger(startContentIndexes[0] - 1),\n -1,\n containerSize,\n getMinItemSize,\n );\n const endContentIndexes = getRangeIndexes(\n rangeScaledSizes[\"endContent\"],\n asInteger(contentIndexes[contentIndexes.length - 1] + 1),\n 1,\n containerSize,\n getMinItemSize,\n );\n const endPaddingIndexes = getRangeIndexes(\n rangeScaledSizes[\"endPadding\"],\n asInteger(endContentIndexes[endContentIndexes.length - 1] + 1),\n 1,\n containerSize,\n getMinItemSize,\n );\n const { minLimitExceeded, maxLimitExceeded } = indexesExceedLimits(\n minIndex,\n maxIndex,\n startPaddingIndexes[0],\n endPaddingIndexes[endPaddingIndexes.length - 1],\n );\n if (minLimitExceeded) {\n const minLimitHitItems = asItems(\n orderedRangeIndexesToItems(\n getIndexLimitItems(\n containerSize,\n rangeScaledSizes,\n getMinItemSize,\n asInteger(Number(minIndex)),\n 1,\n ),\n ),\n );\n const { minLimitExceeded, maxLimitExceeded } = indexesExceedLimits(\n minIndex,\n maxIndex,\n minLimitHitItems.indexes[0],\n minLimitHitItems.indexes[minLimitHitItems.indexes.length - 1],\n );\n if (minLimitExceeded || maxLimitExceeded) {\n throwIndexLimitDistanceError(\n asInteger(Number(minIndex)),\n asInteger(Number(maxIndex)),\n );\n }\n return minLimitHitItems;\n }\n if (maxLimitExceeded) {\n const maxLimitHitItems = asItems(\n orderedRangeIndexesToItems(\n getIndexLimitItems(\n containerSize,\n rangeScaledSizes,\n getMinItemSize,\n asInteger(Number(maxIndex)),\n -1,\n ),\n ),\n );\n const { minLimitExceeded, maxLimitExceeded } = indexesExceedLimits(\n minIndex,\n maxIndex,\n maxLimitHitItems.indexes[0],\n maxLimitHitItems.indexes[maxLimitHitItems.indexes.length - 1],\n );\n if (minLimitExceeded || maxLimitExceeded) {\n throwIndexLimitDistanceError(\n asInteger(Number(minIndex)),\n asInteger(Number(maxIndex)),\n );\n }\n return maxLimitHitItems;\n }\n return asItems(\n orderedRangeIndexesToItems([\n startPaddingIndexes,\n startContentIndexes,\n contentIndexes,\n endContentIndexes,\n endPaddingIndexes,\n ]),\n );\n}\n\nexport function itemsAreEqual(itemsA: Items, itemsB: Items): boolean {\n asItems(itemsA);\n asItems(itemsB);\n // Check indexes array\n if (itemsA.indexes.length !== itemsB.indexes.length) {\n return false;\n }\n for (let i = 0; i < itemsA.indexes.length; i++) {\n if (itemsA.indexes[i] !== itemsB.indexes[i]) {\n return false;\n }\n }\n // Check rangePointers object\n if (\n Object.keys(itemsA.rangePointers).length !==\n Object.keys(itemsB.rangePointers).length\n ) {\n return false;\n }\n for (const pointer of Object.keys(\n itemsA.rangePointers,\n ) as (keyof typeof itemsA.rangePointers)[]) {\n if (\n itemsA.rangePointers[pointer].length !==\n itemsB.rangePointers[pointer].length\n ) {\n return false;\n }\n for (let i = 0; i < itemsA.rangePointers[pointer].length; i++) {\n if (\n itemsA.rangePointers[pointer][i] !== itemsB.rangePointers[pointer][i]\n ) {\n return false;\n }\n }\n }\n return true;\n}\n\nfunction getRangeIndexes(\n targetScaledHeight: NonNegativeReal,\n startIndex: Integer,\n increment: 1 | -1,\n containerSize: NonNegativeReal,\n getMinItemSize: UseProceduralScrollerProps[\"getMinItemSize\"],\n): Integer[] {\n asNonNegativeReal(targetScaledHeight);\n asInteger(startIndex);\n if (increment !== 1 && increment !== -1) {\n throw new ProceduralScrollerError(`Invalid increment: ${increment}`, {\n increment,\n });\n }\n asNonNegativeReal(containerSize);\n\n const targetHeight: NonNegativeReal = asNonNegativeReal(\n targetScaledHeight * containerSize,\n );\n const indexes = [startIndex];\n let totalHeight: NonNegativeReal = asNonNegativeReal(0);\n while (totalHeight < targetHeight) {\n const newIndex = asInteger(\n indexes[increment > 0 ? indexes.length - 1 : 0] + increment,\n );\n if (increment > 0) {\n indexes.push(newIndex);\n } else {\n indexes.unshift(newIndex);\n }\n totalHeight = asNonNegativeReal(totalHeight + getMinItemSize(newIndex));\n }\n return indexes;\n}\n\nfunction orderedRangeIndexesToItems(orderedRangeIndexes: Integer[][]): Items {\n if (itemRangeKeys.length !== orderedRangeIndexes.length) {\n throw new ProceduralScrollerError(\n `Array length mismatch: ${itemRangeKeys.length} !== ${orderedRangeIndexes.length}`,\n { itemRangeKeys, orderedRangeIndexes },\n );\n }\n let indexes: Integer[] = [];\n orderedRangeIndexes.forEach((rangeIndexes): void => {\n indexes = [...indexes, ...rangeIndexes];\n });\n const pointers: [NonNegativeInteger, NonNegativeInteger][] = [];\n orderedRangeIndexes.forEach((rangeIndexes: Integer[]) => {\n if (pointers.length < 1) {\n pointers.push([\n asNonNegativeInteger(0),\n asNonNegativeInteger(rangeIndexes.length - 1),\n ]);\n } else {\n pointers.push([\n asNonNegativeInteger(pointers[pointers.length - 1][1] + 1),\n asNonNegativeInteger(\n pointers[pointers.length - 1][1] + rangeIndexes.length,\n ),\n ]);\n }\n });\n return asItems({\n indexes: indexes,\n rangePointers: {\n startPadding: pointers[0],\n startContent: pointers[1],\n content: pointers[2],\n endContent: pointers[3],\n endPadding: pointers[4],\n },\n });\n}\n\nfunction getIndexLimitItems(\n containerSize: NonNegativeReal,\n rangeScaledSizes: RangeScaledSizes,\n getMinItemSize: UseProceduralScrollerProps[\"getMinItemSize\"],\n indexLimit: Integer,\n increment: 1 | -1,\n): Integer[][] {\n const ranges =\n increment === 1 ? [...itemRangeKeys] : [...itemRangeKeys].reverse();\n let indexTracker: Integer = indexLimit;\n const result = ranges.map((range) => {\n const rangeIndexes = getRangeIndexes(\n rangeScaledSizes[range],\n indexTracker,\n increment,\n containerSize,\n getMinItemSize,\n );\n indexTracker = asInteger(\n (increment === 1\n ? rangeIndexes[rangeIndexes.length - 1]\n : rangeIndexes[0]) + increment,\n );\n return rangeIndexes;\n });\n return increment === 1 ? result : result.reverse();\n}\n","import { useEffect } from \"react\";\nimport { UseScrollHandlerProps } from \"../types/hooks/use-scroll-handler\";\n\nexport function useScrollHandler<ElementType extends HTMLElement>({\n elementRef,\n handler,\n}: UseScrollHandlerProps<ElementType>) {\n useEffect(() => {\n function safeHandler(this: ElementType, ev: Event): void {\n const element = elementRef?.current;\n if (!element) return;\n handler(element, ev);\n }\n const element = elementRef.current;\n if (!element) return;\n element.addEventListener(\"scroll\", safeHandler);\n return () => {\n element.removeEventListener(\"scroll\", safeHandler);\n };\n }, [elementRef, handler]);\n}\n","import { useCallback, useLayoutEffect } from \"react\";\nimport { Scroll } from \"../types/scroll\";\nimport { UseDeferredScrollResetProps } from \"../types/hooks/use-deferred-scroll-reset\";\nimport { getScrollLength } from \"../lib/scroll\";\n\nexport function useDeferredScrollReset<\n ContainerType extends HTMLElement,\n ItemType extends HTMLElement,\n>({\n scroll,\n onScrollReset,\n containerRef,\n items,\n getRef,\n suppress,\n scrollDirection,\n}: UseDeferredScrollResetProps<ContainerType, ItemType>): void {\n /*\n * Scrolls the container to the specified item with the desired alignment.\n */\n const scrollTo = useCallback(\n (scroll: Scroll, retry: boolean = false) => {\n const item = getRef(scroll.index)?.current;\n const container = containerRef.current;\n if (!item || !container) {\n if (!retry) {\n requestAnimationFrame(() => {\n scrollTo(scroll, true);\n });\n }\n return;\n }\n container[scrollDirection === \"horizontal\" ? \"scrollLeft\" : \"scrollTop\"] =\n getScrollLength(scroll.block, container, item, scrollDirection);\n onScrollReset();\n },\n [containerRef, getRef, onScrollReset, scrollDirection],\n );\n\n /*\n * On every update to the `items` list, resets the scroll position to maintain\n * the expected viewport alignment using the latest scroll reference.\n * Uses `requestAnimationFrame` to defer the scroll adjustment until after the DOM updates.\n */\n useLayoutEffect(() => {\n if (suppress) return;\n if (!scroll?.current) return;\n const scrollReference = { ...scroll.current };\n requestAnimationFrame(() => {\n if (scrollReference) {\n scrollTo(scrollReference);\n }\n });\n }, [scroll, scrollTo, items, suppress]);\n}\n","type Axis = \"horizontal\" | \"vertical\";\n\ninterface SizeOptions {\n includePadding?: boolean;\n includeBorder?: boolean;\n includeMargin?: boolean;\n}\n\nexport function getElementSize(\n element: HTMLElement,\n axis: Axis,\n options: SizeOptions = {},\n): number {\n const {\n includePadding = true,\n includeBorder = false,\n includeMargin = false,\n } = options;\n\n const isHorizontal = axis === \"horizontal\";\n\n const style = window.getComputedStyle(element);\n\n let size: number;\n\n // Start with content size (clientWidth/clientHeight includes padding)\n size = isHorizontal ? element.clientWidth : element.clientHeight;\n\n // Remove padding if not included\n if (!includePadding) {\n const paddingStart = parseFloat(\n isHorizontal ? style.paddingLeft : style.paddingTop,\n );\n const paddingEnd = parseFloat(\n isHorizontal ? style.paddingRight : style.paddingBottom,\n );\n size -= paddingStart + paddingEnd;\n }\n\n // Add border if included\n if (includeBorder) {\n const borderStart = parseFloat(\n isHorizontal ? style.borderLeftWidth : style.borderTopWidth,\n );\n const borderEnd = parseFloat(\n isHorizontal ? style.borderRightWidth : style.borderBottomWidth,\n );\n size += borderStart + borderEnd;\n }\n\n // Add margin if included\n if (includeMargin) {\n const marginStart = parseFloat(\n isHorizontal ? style.marginLeft : style.marginTop,\n );\n const marginEnd = parseFloat(\n isHorizontal ? style.marginRight : style.marginBottom,\n );\n size += marginStart + marginEnd;\n }\n\n return size;\n}\n","import { Scroll, scrollBlocks } from \"../types/scroll\";\nimport { ProceduralScrollerError } from \"./error\";\nimport { ScrollToIndexInput } from \"../types/hooks/use-procedural-scroller\";\nimport { asInteger } from \"../validation/number/integer\";\nimport { getElementSize } from \"./dimensions\";\n\nexport function getScrollLength<\n ContainerType extends HTMLElement,\n ItemType extends HTMLElement,\n>(\n block: Scroll[\"block\"],\n container: ContainerType,\n item: ItemType,\n scrollDirection: \"horizontal\" | \"vertical\",\n) {\n // Validation\n if (scrollBlocks.indexOf(block) === -1) {\n throw new ProceduralScrollerError(\"Invalid scroll block\", { block });\n }\n // Compute scroll length\n const containerSize = getElementSize(container, scrollDirection, {\n includePadding: true,\n includeBorder: false,\n includeMargin: false,\n });\n const itemSize = getElementSize(item, scrollDirection, {\n includePadding: true,\n includeBorder: true,\n includeMargin: false,\n });\n const relativeOffset = computeRelativeOffset(\n container,\n item,\n scrollDirection,\n );\n if (block === \"start\") {\n return relativeOffset;\n } else if (block === \"end\") {\n return relativeOffset - (containerSize - itemSize);\n } else if (block === \"center\") {\n return relativeOffset - (containerSize - itemSize) / 2;\n } else {\n throw new ProceduralScrollerError(`Invalid scroll block`, { block });\n }\n}\n\nexport function scrollToIndexInputToScroll(\n input: ScrollToIndexInput | null,\n): Scroll | null {\n if (!input) {\n return null;\n }\n return {\n block: input.block,\n index: asInteger(input.index),\n };\n}\n\nexport function computeRelativeOffset<\n ContainerType extends HTMLElement,\n ItemType extends HTMLElement,\n>(\n container: ContainerType,\n item: ItemType,\n scrollDirection: \"horizontal\" | \"vertical\",\n): number {\n const isHorizontal = scrollDirection === \"horizontal\";\n const containerScroll = container[isHorizontal ? \"scrollLeft\" : \"scrollTop\"];\n const itemViewportOffset =\n item.getBoundingClientRect()[isHorizontal ? \"left\" : \"top\"] +\n containerScroll;\n const containerViewportOffset =\n container.getBoundingClientRect()[isHorizontal ? \"left\" : \"top\"] +\n (isHorizontal ? container.clientLeft : container.clientTop);\n return itemViewportOffset - containerViewportOffset;\n}\n","import {\n UseItemStackProps,\n UseItemStackResult,\n} from \"../types/hooks/use-item-stack\";\nimport { createRef, useCallback, useState } from \"react\";\nimport { Items } from \"../types/items\";\nimport { getItems, itemsAreEqual } from \"../lib/items\";\nimport { asNonNegativeReal } from \"../validation/number/non-negative-real\";\nimport { useElementRefMap } from \"./use-element-ref-map\";\nimport { asPositiveInteger } from \"../validation/number/positive-integer\";\nimport { useDimensionObserver } from \"./use-dimension-observer\";\nimport { getElementSize } from \"../lib/dimensions\";\nimport { NonNegativeReal } from \"../types/number/non-negative-real\";\n\nexport function useItemStack<\n ContainerType extends HTMLElement,\n ItemType extends HTMLElement,\n>({\n scrollDirection,\n rangeScaledSizes,\n containerRef,\n scroll,\n getMinItemSize,\n minIndex,\n maxIndex,\n initialContainerSize,\n}: UseItemStackProps<ContainerType>): UseItemStackResult<ItemType> {\n /*\n * State:\n * `items` contains the array of indexes to be rendered.\n */\n const [items, setItems] = useState<Items | null>(() => {\n const container = containerRef?.current;\n let containerSize: NonNegativeReal | null =\n typeof initialContainerSize === \"number\"\n ? asNonNegativeReal(initialContainerSize)\n : null;\n if (container) {\n containerSize = asNonNegativeReal(\n getElementSize(container, scrollDirection, {\n includePadding: false,\n includeBorder: false,\n includeMargin: false,\n }),\n );\n }\n return scroll && typeof containerSize === \"number\"\n ? getItems(\n containerSize,\n rangeScaledSizes,\n scroll,\n getMinItemSize,\n minIndex,\n maxIndex,\n )\n : null;\n });\n\n /*\n * Item Refs:\n * Initializes a ref for each index to be rendered\n */\n const { getRef, setRef, getRefOrError } = useElementRefMap<ItemType>({\n cacheLimit: asPositiveInteger(\n typeof items?.indexes?.length === \"number\" ? items.indexes.length * 2 : 1,\n ),\n });\n if (items?.indexes) {\n items.indexes.forEach((index) => {\n setRef(index, getRef(index) || createRef<ItemType>());\n });\n }\n\n /*\n * Container resize logic:\n * `containerResizeHandler` Should only run when the observed dimension of the container changes\n */\n const containerResizeHandler = useCallback(\n (container: ContainerType) => {\n if (!scroll) {\n return;\n }\n setItems((prevItems) => {\n const newItems = getItems(\n asNonNegativeReal(\n getElementSize(container, scrollDirection, {\n includePadding: false,\n includeBorder: false,\n includeMargin: false,\n }),\n ),\n rangeScaledSizes,\n scroll,\n getMinItemSize,\n minIndex,\n maxIndex,\n );\n return prevItems !== null && itemsAreEqual(newItems, prevItems)\n ? prevItems\n : newItems;\n });\n },\n [\n scroll,\n scrollDirection,\n rangeScaledSizes,\n getMinItemSize,\n minIndex,\n maxIndex,\n ],\n );\n useDimensionObserver({\n dimensions: [\n scrollDirection === \"horizontal\" ? \"clientWidth\" : \"clientHeight\",\n ],\n elementRef: containerRef,\n resizeHandler: containerResizeHandler,\n });\n\n return {\n items,\n setItems,\n getRef,\n getRefOrError,\n };\n}\n","import { RefObject, useCallback, useRef } from \"react\";\nimport {\n UseElementRefMapProps,\n UseElementRefMapResult,\n} from \"../types/hooks/use-element-ref-map\";\nimport { asElementRefMapKey } from \"../validation/hooks/use-element-ref-map\";\nimport { asPositiveInteger } from \"../validation/number/positive-integer\";\nimport { ProceduralScrollerError } from \"../lib/error\";\nimport { mapToObject } from \"../lib/map\";\n\nexport const useElementRefMap = <\n ElementType extends HTMLElement = HTMLElement,\n>({\n cacheLimit = asPositiveInteger(1),\n}: UseElementRefMapProps): UseElementRefMapResult<ElementType> => {\n const elementRefMap = useRef<Map<string, RefObject<ElementType | null>>>(\n new Map(),\n );\n\n const getRef: UseElementRefMapResult<ElementType>[\"getRef\"] = useCallback(\n (key) => {\n return elementRefMap.current.get(asElementRefMapKey(key));\n },\n [elementRefMap],\n );\n\n const getRefOrError: UseElementRefMapResult<ElementType>[\"getRefOrError\"] =\n useCallback(\n <RequireNonNull extends boolean>(\n key: string | number,\n requireNonNull: RequireNonNull,\n ) => {\n const ref = elementRefMap.current.get(asElementRefMapKey(key));\n if (ref && !requireNonNull) {\n return ref as RequireNonNull extends true\n ? RefObject<ElementType>\n : RefObject<ElementType | null>;\n }\n if (ref?.current !== null && requireNonNull) {\n return ref as RequireNonNull extends true\n ? RefObject<ElementType>\n : RefObject<ElementType | null>;\n }\n throw new ProceduralScrollerError(\n `A ref with key=${key} does not exist in elementRefMap`,\n mapToObject(elementRefMap.current),\n );\n },\n [elementRefMap],\n );\n\n const setRef: UseElementRefMapResult<ElementType>[\"setRef\"] = useCallback(\n (key, ref) => {\n asPositiveInteger(cacheLimit);\n const stringKey = asElementRefMapKey(key);\n const map = elementRefMap.current;\n map.delete(stringKey); // Delete first