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,383 lines (1,354 loc) • 41.1 kB
JavaScript
var __defProp = Object.defineProperty;
var __defProps = Object.defineProperties;
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b || (b = {}))
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(b)) {
if (__propIsEnum.call(b, prop))
__defNormalProp(a, prop, b[prop]);
}
return a;
};
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
// src/hooks/use-procedural-scroller.ts
import {
useCallback as useCallback5,
useEffect as useEffect5,
useMemo,
useRef as useRef4,
useState as useState2
} from "react";
// src/types/items.ts
var itemRangeKeys = [
"startPadding",
"startContent",
"content",
"endContent",
"endPadding"
];
// src/lib/error.ts
var ProceduralScrollerError = class extends Error {
constructor(message, data) {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
if (data) {
this.data = data;
}
}
};
// src/validation/number/integer.ts
function asInteger(n) {
if (!isInteger(n)) {
throw new ProceduralScrollerError(
`Expected an integer number, received n=${n}`,
{ n }
);
}
return n;
}
function isInteger(input) {
return !(!Number.isInteger(input) || !isFinite(input) || isNaN(input));
}
// src/validation/number/non-negative-integer.ts
function asNonNegativeInteger(n) {
if (!isNonNegativeInteger(n)) {
throw new ProceduralScrollerError(
`Expected a non-negative integer number, received n=${n}`,
{ n }
);
}
return n;
}
function isNonNegativeInteger(n) {
return !(!Number.isInteger(n) || Number(n) < 0 || !isFinite(n));
}
// src/validation/items.ts
function asItemsRangePointers(input) {
const errorPrefix = `Invalid rangePointers object:`;
if (typeof input !== "object" || input === null || Object.keys(input).length !== itemRangeKeys.length) {
throw new ProceduralScrollerError(
`${errorPrefix} Expected an object with ${itemRangeKeys.length} keys`,
{ input }
);
}
for (const key of itemRangeKeys) {
const inputAsRangePointers = input;
if (!Array.isArray(inputAsRangePointers[key]) || inputAsRangePointers[key].length !== 2 || !isNonNegativeInteger(inputAsRangePointers[key][0]) || !isNonNegativeInteger(inputAsRangePointers[key][1])) {
throw new ProceduralScrollerError(
`${errorPrefix} Invalid pointer array for key=${key}`,
{ input }
);
}
}
return input;
}
function asItems(input) {
const errorPrefix = "Invalid items object:";
const expectedKeys = 2;
if (typeof input !== "object" || input === null || Object.keys(input).length !== expectedKeys) {
throw new ProceduralScrollerError(
`${errorPrefix} Expected an object with ${expectedKeys} keys`,
{ input }
);
}
const inputAsItems = input;
asItemsRangePointers(inputAsItems.rangePointers);
if (!Array.isArray(inputAsItems.indexes)) {
throw new ProceduralScrollerError(
`${errorPrefix} Expected items.indexes to be an array`,
{ input }
);
}
for (let i = 0; i < inputAsItems.indexes.length; i++) {
if (!isInteger(inputAsItems.indexes[i])) {
throw new ProceduralScrollerError(
`${errorPrefix} items.indexes[${i}] is not an integer`,
{ input }
);
}
if (i > 0 && i < inputAsItems.indexes.length - 1) {
if (inputAsItems.indexes[i - 1] !== inputAsItems.indexes[i] - 1) {
throw new ProceduralScrollerError(
`${errorPrefix} items.indexes[${i - 1}] and items.indexes[${i}] are not consecutive`,
{ input }
);
}
if (inputAsItems.indexes[i + 1] !== inputAsItems.indexes[i] + 1) {
throw new ProceduralScrollerError(
`${errorPrefix} items.indexes[${i}] and items.indexes[${i + 1}] are not consecutive`,
{ input }
);
}
}
}
return input;
}
// src/validation/number/non-negative-real.ts
function asNonNegativeReal(n) {
if (!isNonNegativeReal(n)) {
throw new ProceduralScrollerError(
`Expected a non-negative real number, received n=${n}`,
{ n }
);
}
return n;
}
function isNonNegativeReal(n) {
return !(typeof n !== "number" || isNaN(n) || !isFinite(n) || Number(n) < 0);
}
// src/validation/range-scaled-sizes.ts
function asRangeScaledSizes(input) {
const errorPrefix = "Received invalid rangeScaledSizes value:";
if (typeof input !== "object" || Object.keys(input).length !== itemRangeKeys.length) {
throw new ProceduralScrollerError(
`${errorPrefix} Expected object with ${itemRangeKeys.length} keys.`,
{ input, itemRangeKeys }
);
}
itemRangeKeys.forEach((key) => {
if (!isNonNegativeReal(input[key])) {
throw new ProceduralScrollerError(
`${errorPrefix} Expected rangeScaledSizes[${key}] to be a non-negative real number.`,
{ input, itemRangeKeys }
);
}
});
return input;
}
// src/types/scroll.ts
var scrollBlocks = ["start", "center", "end"];
// src/validation/scroll.ts
function asScroll(input) {
function throwError(message) {
throw new ProceduralScrollerError(`Invalid scroll object: ${message}`, {
input
});
}
const expectedKeys = 2;
if (typeof input !== "object" || input === null || Object.keys(input).length !== expectedKeys) {
throwError(`Expected an object with ${expectedKeys} keys`);
}
if (scrollBlocks.indexOf(input == null ? void 0 : input.block) === -1) {
throwError(`Invalid scroll.block value`);
}
if (!isInteger(input == null ? void 0 : input.index)) {
throwError(`Expected scroll.index to be an integer`);
}
return input;
}
// src/lib/items.ts
function throwIndexLimitDistanceError(minIndex, maxIndex) {
throw new ProceduralScrollerError(
"Invalid configuration: the specified minIndex and maxIndex are too close together. There isn\u2019t enough room between them to render the required items.",
{ minIndex, maxIndex }
);
}
function indexesExceedLimits(minIndexLimit, maxIndexLimit, minIndex, maxIndex) {
const minLimitExceeded = typeof minIndexLimit === "number" && minIndex < minIndexLimit;
const maxLimitExceeded = typeof maxIndexLimit === "number" && maxIndex > maxIndexLimit;
if (minLimitExceeded && maxLimitExceeded)
throwIndexLimitDistanceError(minIndex, maxIndex);
if (minLimitExceeded)
return { minLimitExceeded: true, maxLimitExceeded: false };
if (maxLimitExceeded)
return { minLimitExceeded: false, maxLimitExceeded: true };
return { minLimitExceeded: false, maxLimitExceeded: false };
}
function getItems(containerSize, rangeScaledSizes, scroll, getMinItemSize, minIndex = null, maxIndex = null) {
asNonNegativeReal(containerSize);
asRangeScaledSizes(rangeScaledSizes);
asScroll(scroll);
let contentIndexes = [];
if (scroll.block === "start") {
contentIndexes = getRangeIndexes(
rangeScaledSizes["content"],
scroll.index,
1,
containerSize,
getMinItemSize
);
} else if (scroll.block === "end") {
contentIndexes = getRangeIndexes(
rangeScaledSizes["content"],
scroll.index,
-1,
containerSize,
getMinItemSize
);
} else if (scroll.block === "center") {
contentIndexes = [
...getRangeIndexes(
asNonNegativeReal(rangeScaledSizes["content"] * 0.5),
asInteger(scroll.index - 1),
-1,
containerSize,
getMinItemSize
),
scroll.index,
...getRangeIndexes(
asNonNegativeReal(rangeScaledSizes["content"] * 0.5),
asInteger(scroll.index + 1),
1,
containerSize,
getMinItemSize
)
];
} else {
throw new ProceduralScrollerError(
`Invalid: scroll.block = ${scroll.block}`,
scroll
);
}
const startContentIndexes = getRangeIndexes(
rangeScaledSizes["startContent"],
asInteger(contentIndexes[0] - 1),
-1,
containerSize,
getMinItemSize
);
const startPaddingIndexes = getRangeIndexes(
rangeScaledSizes["startPadding"],
asInteger(startContentIndexes[0] - 1),
-1,
containerSize,
getMinItemSize
);
const endContentIndexes = getRangeIndexes(
rangeScaledSizes["endContent"],
asInteger(contentIndexes[contentIndexes.length - 1] + 1),
1,
containerSize,
getMinItemSize
);
const endPaddingIndexes = getRangeIndexes(
rangeScaledSizes["endPadding"],
asInteger(endContentIndexes[endContentIndexes.length - 1] + 1),
1,
containerSize,
getMinItemSize
);
const { minLimitExceeded, maxLimitExceeded } = indexesExceedLimits(
minIndex,
maxIndex,
startPaddingIndexes[0],
endPaddingIndexes[endPaddingIndexes.length - 1]
);
if (minLimitExceeded) {
const minLimitHitItems = asItems(
orderedRangeIndexesToItems(
getIndexLimitItems(
containerSize,
rangeScaledSizes,
getMinItemSize,
asInteger(Number(minIndex)),
1
)
)
);
const { minLimitExceeded: minLimitExceeded2, maxLimitExceeded: maxLimitExceeded2 } = indexesExceedLimits(
minIndex,
maxIndex,
minLimitHitItems.indexes[0],
minLimitHitItems.indexes[minLimitHitItems.indexes.length - 1]
);
if (minLimitExceeded2 || maxLimitExceeded2) {
throwIndexLimitDistanceError(
asInteger(Number(minIndex)),
asInteger(Number(maxIndex))
);
}
return minLimitHitItems;
}
if (maxLimitExceeded) {
const maxLimitHitItems = asItems(
orderedRangeIndexesToItems(
getIndexLimitItems(
containerSize,
rangeScaledSizes,
getMinItemSize,
asInteger(Number(maxIndex)),
-1
)
)
);
const { minLimitExceeded: minLimitExceeded2, maxLimitExceeded: maxLimitExceeded2 } = indexesExceedLimits(
minIndex,
maxIndex,
maxLimitHitItems.indexes[0],
maxLimitHitItems.indexes[maxLimitHitItems.indexes.length - 1]
);
if (minLimitExceeded2 || maxLimitExceeded2) {
throwIndexLimitDistanceError(
asInteger(Number(minIndex)),
asInteger(Number(maxIndex))
);
}
return maxLimitHitItems;
}
return asItems(
orderedRangeIndexesToItems([
startPaddingIndexes,
startContentIndexes,
contentIndexes,
endContentIndexes,
endPaddingIndexes
])
);
}
function itemsAreEqual(itemsA, itemsB) {
asItems(itemsA);
asItems(itemsB);
if (itemsA.indexes.length !== itemsB.indexes.length) {
return false;
}
for (let i = 0; i < itemsA.indexes.length; i++) {
if (itemsA.indexes[i] !== itemsB.indexes[i]) {
return false;
}
}
if (Object.keys(itemsA.rangePointers).length !== Object.keys(itemsB.rangePointers).length) {
return false;
}
for (const pointer of Object.keys(
itemsA.rangePointers
)) {
if (itemsA.rangePointers[pointer].length !== itemsB.rangePointers[pointer].length) {
return false;
}
for (let i = 0; i < itemsA.rangePointers[pointer].length; i++) {
if (itemsA.rangePointers[pointer][i] !== itemsB.rangePointers[pointer][i]) {
return false;
}
}
}
return true;
}
function getRangeIndexes(targetScaledHeight, startIndex, increment, containerSize, getMinItemSize) {
asNonNegativeReal(targetScaledHeight);
asInteger(startIndex);
if (increment !== 1 && increment !== -1) {
throw new ProceduralScrollerError(`Invalid increment: ${increment}`, {
increment
});
}
asNonNegativeReal(containerSize);
const targetHeight = asNonNegativeReal(
targetScaledHeight * containerSize
);
const indexes = [startIndex];
let totalHeight = asNonNegativeReal(0);
while (totalHeight < targetHeight) {
const newIndex = asInteger(
indexes[increment > 0 ? indexes.length - 1 : 0] + increment
);
if (increment > 0) {
indexes.push(newIndex);
} else {
indexes.unshift(newIndex);
}
totalHeight = asNonNegativeReal(totalHeight + getMinItemSize(newIndex));
}
return indexes;
}
function orderedRangeIndexesToItems(orderedRangeIndexes) {
if (itemRangeKeys.length !== orderedRangeIndexes.length) {
throw new ProceduralScrollerError(
`Array length mismatch: ${itemRangeKeys.length} !== ${orderedRangeIndexes.length}`,
{ itemRangeKeys, orderedRangeIndexes }
);
}
let indexes = [];
orderedRangeIndexes.forEach((rangeIndexes) => {
indexes = [...indexes, ...rangeIndexes];
});
const pointers = [];
orderedRangeIndexes.forEach((rangeIndexes) => {
if (pointers.length < 1) {
pointers.push([
asNonNegativeInteger(0),
asNonNegativeInteger(rangeIndexes.length - 1)
]);
} else {
pointers.push([
asNonNegativeInteger(pointers[pointers.length - 1][1] + 1),
asNonNegativeInteger(
pointers[pointers.length - 1][1] + rangeIndexes.length
)
]);
}
});
return asItems({
indexes,
rangePointers: {
startPadding: pointers[0],
startContent: pointers[1],
content: pointers[2],
endContent: pointers[3],
endPadding: pointers[4]
}
});
}
function getIndexLimitItems(containerSize, rangeScaledSizes, getMinItemSize, indexLimit, increment) {
const ranges = increment === 1 ? [...itemRangeKeys] : [...itemRangeKeys].reverse();
let indexTracker = indexLimit;
const result = ranges.map((range) => {
const rangeIndexes = getRangeIndexes(
rangeScaledSizes[range],
indexTracker,
increment,
containerSize,
getMinItemSize
);
indexTracker = asInteger(
(increment === 1 ? rangeIndexes[rangeIndexes.length - 1] : rangeIndexes[0]) + increment
);
return rangeIndexes;
});
return increment === 1 ? result : result.reverse();
}
// src/hooks/use-scroll-handler.ts
import { useEffect } from "react";
function useScrollHandler({
elementRef,
handler
}) {
useEffect(() => {
function safeHandler(ev) {
const element2 = elementRef == null ? void 0 : elementRef.current;
if (!element2) return;
handler(element2, ev);
}
const element = elementRef.current;
if (!element) return;
element.addEventListener("scroll", safeHandler);
return () => {
element.removeEventListener("scroll", safeHandler);
};
}, [elementRef, handler]);
}
// src/hooks/use-deferred-scroll-reset.ts
import { useCallback, useLayoutEffect } from "react";
// src/lib/dimensions.ts
function getElementSize(element, axis, options = {}) {
const {
includePadding = true,
includeBorder = false,
includeMargin = false
} = options;
const isHorizontal = axis === "horizontal";
const style = window.getComputedStyle(element);
let size;
size = isHorizontal ? element.clientWidth : element.clientHeight;
if (!includePadding) {
const paddingStart = parseFloat(
isHorizontal ? style.paddingLeft : style.paddingTop
);
const paddingEnd = parseFloat(
isHorizontal ? style.paddingRight : style.paddingBottom
);
size -= paddingStart + paddingEnd;
}
if (includeBorder) {
const borderStart = parseFloat(
isHorizontal ? style.borderLeftWidth : style.borderTopWidth
);
const borderEnd = parseFloat(
isHorizontal ? style.borderRightWidth : style.borderBottomWidth
);
size += borderStart + borderEnd;
}
if (includeMargin) {
const marginStart = parseFloat(
isHorizontal ? style.marginLeft : style.marginTop
);
const marginEnd = parseFloat(
isHorizontal ? style.marginRight : style.marginBottom
);
size += marginStart + marginEnd;
}
return size;
}
// src/lib/scroll.ts
function getScrollLength(block, container, item, scrollDirection) {
if (scrollBlocks.indexOf(block) === -1) {
throw new ProceduralScrollerError("Invalid scroll block", { block });
}
const containerSize = getElementSize(container, scrollDirection, {
includePadding: true,
includeBorder: false,
includeMargin: false
});
const itemSize = getElementSize(item, scrollDirection, {
includePadding: true,
includeBorder: true,
includeMargin: false
});
const relativeOffset = computeRelativeOffset(
container,
item,
scrollDirection
);
if (block === "start") {
return relativeOffset;
} else if (block === "end") {
return relativeOffset - (containerSize - itemSize);
} else if (block === "center") {
return relativeOffset - (containerSize - itemSize) / 2;
} else {
throw new ProceduralScrollerError(`Invalid scroll block`, { block });
}
}
function scrollToIndexInputToScroll(input) {
if (!input) {
return null;
}
return {
block: input.block,
index: asInteger(input.index)
};
}
function computeRelativeOffset(container, item, scrollDirection) {
const isHorizontal = scrollDirection === "horizontal";
const containerScroll = container[isHorizontal ? "scrollLeft" : "scrollTop"];
const itemViewportOffset = item.getBoundingClientRect()[isHorizontal ? "left" : "top"] + containerScroll;
const containerViewportOffset = container.getBoundingClientRect()[isHorizontal ? "left" : "top"] + (isHorizontal ? container.clientLeft : container.clientTop);
return itemViewportOffset - containerViewportOffset;
}
// src/hooks/use-deferred-scroll-reset.ts
function useDeferredScrollReset({
scroll,
onScrollReset,
containerRef,
items,
getRef,
suppress,
scrollDirection
}) {
const scrollTo = useCallback(
(scroll2, retry = false) => {
var _a;
const item = (_a = getRef(scroll2.index)) == null ? void 0 : _a.current;
const container = containerRef.current;
if (!item || !container) {
if (!retry) {
requestAnimationFrame(() => {
scrollTo(scroll2, true);
});
}
return;
}
container[scrollDirection === "horizontal" ? "scrollLeft" : "scrollTop"] = getScrollLength(scroll2.block, container, item, scrollDirection);
onScrollReset();
},
[containerRef, getRef, onScrollReset, scrollDirection]
);
useLayoutEffect(() => {
if (suppress) return;
if (!(scroll == null ? void 0 : scroll.current)) return;
const scrollReference = __spreadValues({}, scroll.current);
requestAnimationFrame(() => {
if (scrollReference) {
scrollTo(scrollReference);
}
});
}, [scroll, scrollTo, items, suppress]);
}
// src/hooks/use-item-stack.ts
import { createRef, useCallback as useCallback4, useState } from "react";
// src/hooks/use-element-ref-map.ts
import { useCallback as useCallback2, useRef } from "react";
// src/validation/hooks/use-element-ref-map.ts
function asElementRefMapKey(input) {
function throwError(message) {
throw new ProceduralScrollerError(
`Invalid element ref map key: ${message}`,
{ input }
);
}
if (typeof input !== "string" && typeof input !== "number") {
throwError(`Expected key to be a number or a string`);
}
return String(input);
}
// src/validation/number/positive-integer.ts
function asPositiveInteger(n) {
if (!Number.isInteger(n) || Number(n) < 1 || !isFinite(n)) {
throw new ProceduralScrollerError(
`Expected a positive integer number, received n=${n}`,
{ n }
);
}
return n;
}
// src/lib/map.ts
function mapToObject(map) {
const result = {};
map.forEach((value, key) => {
result[key] = value;
});
return result;
}
// src/hooks/use-element-ref-map.ts
var useElementRefMap = ({
cacheLimit = asPositiveInteger(1)
}) => {
const elementRefMap = useRef(
/* @__PURE__ */ new Map()
);
const getRef = useCallback2(
(key) => {
return elementRefMap.current.get(asElementRefMapKey(key));
},
[elementRefMap]
);
const getRefOrError = useCallback2(
(key, requireNonNull) => {
const ref = elementRefMap.current.get(asElementRefMapKey(key));
if (ref && !requireNonNull) {
return ref;
}
if ((ref == null ? void 0 : ref.current) !== null && requireNonNull) {
return ref;
}
throw new ProceduralScrollerError(
`A ref with key=${key} does not exist in elementRefMap`,
mapToObject(elementRefMap.current)
);
},
[elementRefMap]
);
const setRef = useCallback2(
(key, ref) => {
asPositiveInteger(cacheLimit);
const stringKey = asElementRefMapKey(key);
const map = elementRefMap.current;
map.delete(stringKey);
map.set(stringKey, ref);
while (map.size > cacheLimit) {
map.delete(map.keys().next().value);
}
return getRefOrError(key, false);
},
[cacheLimit, getRefOrError, elementRefMap]
);
const getAllRefs = useCallback2(() => {
return mapToObject(elementRefMap.current);
}, [elementRefMap]);
const clearRefs = useCallback2(() => {
elementRefMap.current.clear();
}, [elementRefMap]);
return {
setRef,
getRef,
getRefOrError,
getAllRefs,
clearRefs
};
};
// src/hooks/use-dimension-observer.ts
import { useCallback as useCallback3, useEffect as useEffect2, useRef as useRef2 } from "react";
function useDimensionObserver({
dimensions,
elementRef,
resizeHandler
}) {
const observedDimensions = useRef2(
null
);
const guardedResizeHandler = useCallback3(() => {
function htmlPropertyIsNumeric(value) {
if (typeof value !== "number") {
throw new ProceduralScrollerError(
`${value} must be a number property of a HTMLElement, received: ${value} (${typeof value})`,
{ value }
);
}
return true;
}
const element = elementRef.current;
if (!element) return;
if (observedDimensions.current) {
let didResize = false;
for (const dim of Object.keys(
observedDimensions.current
)) {
if (element[dim] !== observedDimensions.current[dim]) {
didResize = true;
}
if (htmlPropertyIsNumeric(element[dim])) {
observedDimensions.current[dim] = element[dim];
}
}
if (!didResize) return;
} else {
const initialDimensions = {};
dimensions.forEach((dim) => {
if (htmlPropertyIsNumeric(element[dim])) {
initialDimensions[dim] = element[dim];
}
});
observedDimensions.current = initialDimensions;
}
resizeHandler(element);
}, [dimensions, elementRef, resizeHandler]);
useEffect2(() => {
const element = elementRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver(guardedResizeHandler);
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, [elementRef, guardedResizeHandler]);
}
// src/hooks/use-item-stack.ts
function useItemStack({
scrollDirection,
rangeScaledSizes,
containerRef,
scroll,
getMinItemSize,
minIndex,
maxIndex,
initialContainerSize
}) {
var _a;
const [items, setItems] = useState(() => {
const container = containerRef == null ? void 0 : containerRef.current;
let containerSize = typeof initialContainerSize === "number" ? asNonNegativeReal(initialContainerSize) : null;
if (container) {
containerSize = asNonNegativeReal(
getElementSize(container, scrollDirection, {
includePadding: false,
includeBorder: false,
includeMargin: false
})
);
}
return scroll && typeof containerSize === "number" ? getItems(
containerSize,
rangeScaledSizes,
scroll,
getMinItemSize,
minIndex,
maxIndex
) : null;
});
const { getRef, setRef, getRefOrError } = useElementRefMap({
cacheLimit: asPositiveInteger(
typeof ((_a = items == null ? void 0 : items.indexes) == null ? void 0 : _a.length) === "number" ? items.indexes.length * 2 : 1
)
});
if (items == null ? void 0 : items.indexes) {
items.indexes.forEach((index) => {
setRef(index, getRef(index) || createRef());
});
}
const containerResizeHandler = useCallback4(
(container) => {
if (!scroll) {
return;
}
setItems((prevItems) => {
const newItems = getItems(
asNonNegativeReal(
getElementSize(container, scrollDirection, {
includePadding: false,
includeBorder: false,
includeMargin: false
})
),
rangeScaledSizes,
scroll,
getMinItemSize,
minIndex,
maxIndex
);
return prevItems !== null && itemsAreEqual(newItems, prevItems) ? prevItems : newItems;
});
},
[
scroll,
scrollDirection,
rangeScaledSizes,
getMinItemSize,
minIndex,
maxIndex
]
);
useDimensionObserver({
dimensions: [
scrollDirection === "horizontal" ? "clientWidth" : "clientHeight"
],
elementRef: containerRef,
resizeHandler: containerResizeHandler
});
return {
items,
setItems,
getRef,
getRefOrError
};
}
// src/validation/array.ts
function asArrayOfConsecutiveIntegers(array, dir = "asc") {
function throwError(message) {
throw new ProceduralScrollerError(
`Invalid array of consecutive integers: ${message}`,
{ array, dir }
);
}
if (!Array.isArray(array)) {
throwError("Input is not an array");
}
let increment = 1;
if (dir === "asc") {
increment = 1;
} else if (dir === "desc") {
increment = -1;
} else {
throwError("dir is invalid");
}
array.forEach((currentValue, index) => {
asInteger(currentValue);
if (index < array.length - 1) {
const nextValue = asInteger(array[index + 1]);
if (currentValue + increment !== nextValue) {
throwError(
`Non-consecutive values found at indexes ${index} and ${index + 1}.`
);
}
}
});
return array.map((value) => asInteger(value));
}
// src/lib/array.ts
function mergeConsecutiveIntegerArrays(...arrays) {
const sortedArrays = arrays.slice().filter((a) => a.length > 0).sort((a, b) => {
return a[0] - b[0];
}).map((array) => asArrayOfConsecutiveIntegers(array));
const result = [];
sortedArrays.forEach((array) => {
array.forEach((value) => {
if (result.length === 0 || result[result.length - 1] < value) {
result.push(value);
}
});
});
return result;
}
// src/hooks/use-item-size-check.ts
import { useEffect as useEffect3 } from "react";
function useItemSizeCheck({
items,
getMinItemSize,
scrollDirection,
enabled
}) {
useEffect3(() => {
if (!items || !enabled) {
return;
}
items.forEach((itemObj) => {
var _a;
const item = (_a = itemObj == null ? void 0 : itemObj.ref) == null ? void 0 : _a.current;
if (!item) {
return;
}
const itemSize = getElementSize(item, scrollDirection, {
includePadding: true,
includeBorder: true,
includeMargin: true
});
const expectedMinimum = getMinItemSize(itemObj.index);
if (expectedMinimum > itemSize) {
throw new ProceduralScrollerError(
`Invalid item size: Item at index ${itemObj.index} has a height of ${itemSize}px, which is smaller than the expected minimum height of ${expectedMinimum}px (as returned by getMinItemSize(${itemObj.index})).`
);
}
});
}, [items, getMinItemSize, scrollDirection, enabled]);
}
// src/hooks/use-unbounded-height-check.ts
import { useEffect as useEffect4, useRef as useRef3 } from "react";
var checks = 2;
function useUnboundedHeightCheck({
items,
containerRef,
scrollDirection,
enabled
}) {
const count = useRef3(asNonNegativeInteger(0));
useEffect4(() => {
const container = containerRef.current;
if (!container || !enabled) {
return;
}
const scrollSizeAccessor = scrollDirection === "horizontal" ? "scrollWidth" : "scrollHeight";
const clientSizeAccessor = scrollDirection === "horizontal" ? "clientWidth" : "clientHeight";
if (container[scrollSizeAccessor] === container[clientSizeAccessor]) {
count.current = asNonNegativeInteger(count.current + 1);
} else {
count.current = asNonNegativeInteger(0);
}
if (count.current >= checks) {
throw new ProceduralScrollerError(
`Unbounded container detected: The container\u2019s ${scrollSizeAccessor} and ${clientSizeAccessor} were equal for ${checks} consecutive renders. This suggests the container\u2019s size is not constrained, so scrolling cannot occur. This check can be disabled by passing \`validateLayouts = { container: false }\` into useProceduralScroller.`
);
}
}, [containerRef, enabled, items, scrollDirection]);
}
// src/hooks/use-procedural-scroller.ts
var scrollToIndexDebounceDelay = 100;
var useProceduralScroller = ({
getMinItemSize,
scrollAreaScale = 3,
paddingAreaScale = {
start: 1,
end: 1
},
initialScroll = {
block: "center",
index: 0
},
scrollDirection = "vertical",
minIndex: minIndexInput,
maxIndex: maxIndexInput,
initialContainerSize: initialContainerSizeInput,
validateLayouts = {
container: true,
items: true
}
}) => {
const initialContainerSize = useMemo(() => {
if (isNonNegativeReal(initialContainerSizeInput)) {
return asNonNegativeReal(initialContainerSizeInput);
}
return null;
}, [initialContainerSizeInput]);
const minIndex = useMemo(() => {
if (typeof minIndexInput === "number") {
if (typeof maxIndexInput === "number" && minIndexInput > maxIndexInput) {
throw new ProceduralScrollerError(
"minIndex should not be greater than maxIndex",
{ minIndexInput, maxIndexInput }
);
}
return asInteger(minIndexInput);
}
return null;
}, [minIndexInput, maxIndexInput]);
const maxIndex = useMemo(() => {
if (typeof maxIndexInput === "number") {
if (typeof minIndexInput === "number" && minIndexInput > maxIndexInput) {
throw new ProceduralScrollerError(
"minIndex should not be greater than maxIndex",
{ minIndexInput, maxIndexInput }
);
}
return asInteger(maxIndexInput);
}
return null;
}, [minIndexInput, maxIndexInput]);
const rangeScaledSizes = useMemo(() => {
return asRangeScaledSizes({
startPadding: asNonNegativeReal(paddingAreaScale.start),
startContent: asNonNegativeReal((scrollAreaScale - 1) / 2),
content: asNonNegativeReal(1),
endContent: asNonNegativeReal((scrollAreaScale - 1) / 2),
endPadding: asNonNegativeReal(paddingAreaScale.end)
});
}, [scrollAreaScale, paddingAreaScale]);
const scroll = useRef4(
asScroll(__spreadProps(__spreadValues({}, initialScroll), {
index: asInteger(initialScroll.index)
}))
);
const scrollResetting = useRef4(true);
const containerRef = useRef4(null);
const scrollToIndexDebounceRef = useRef4(
null
);
const scrollToIndexInProgressRef = useRef4(false);
const [scrollToIndexInput, setScrollToIndexInput] = useState2(null);
const [itemStackPointer, setItemStackPointer] = useState2(0);
const itemStackA = useItemStack({
scrollDirection,
rangeScaledSizes,
containerRef,
scroll: [scroll.current, scrollToIndexInputToScroll(scrollToIndexInput)][itemStackPointer],
getMinItemSize,
minIndex,
maxIndex,
initialContainerSize
});
const itemStackB = useItemStack({
scrollDirection,
rangeScaledSizes,
containerRef,
scroll: [scroll.current, scrollToIndexInputToScroll(scrollToIndexInput)][Number(!itemStackPointer)],
getMinItemSize,
minIndex,
maxIndex,
initialContainerSize
});
const itemStacks = useMemo(
() => [itemStackA, itemStackB],
[itemStackA, itemStackB]
);
const items = useMemo(
() => itemStacks[itemStackPointer].items,
[itemStackPointer, itemStacks]
);
const setItems = useMemo(
() => itemStacks[itemStackPointer].setItems,
[itemStackPointer, itemStacks]
);
const getPrimaryRef = useMemo(
() => itemStacks[itemStackPointer].getRef,
[itemStackPointer, itemStacks]
);
const secondaryItems = useMemo(
() => itemStacks[Number(!itemStackPointer)].items,
[itemStackPointer, itemStacks]
);
const setSecondaryItems = useMemo(
() => itemStacks[Number(!itemStackPointer)].setItems,
[itemStackPointer, itemStacks]
);
const getSecondaryRef = useMemo(
() => itemStacks[Number(!itemStackPointer)].getRef,
[itemStackPointer, itemStacks]
);
const mergedIndexes = useMemo(() => {
return mergeConsecutiveIntegerArrays(
(items == null ? void 0 : items.indexes) || [],
(secondaryItems == null ? void 0 : secondaryItems.indexes) || []
);
}, [items, secondaryItems]);
const getRef = useCallback5(
(index) => {
const primaryRef = getPrimaryRef(index);
const secondaryRef = getSecondaryRef(index);
if (primaryRef == null ? void 0 : primaryRef.current) {
return primaryRef;
}
if (secondaryRef == null ? void 0 : secondaryRef.current) {
return secondaryRef;
}
return primaryRef || secondaryRef;
},
[getPrimaryRef, getSecondaryRef]
);
const getRefOrError = useCallback5(
(index, requireNonNull) => {
const ref = getRef(index);
if (ref && !requireNonNull) {
return ref;
}
if ((ref == null ? void 0 : ref.current) !== null && requireNonNull) {
return ref;
}
throw new ProceduralScrollerError("Could not find ref", {
index,
ref,
requireNonNull
});
},
[getRef]
);
const updateItems = useCallback5(
(newScroll, container) => {
scroll.current = __spreadValues({}, newScroll);
scrollResetting.current = true;
setItems(
getItems(
asNonNegativeReal(
getElementSize(container, scrollDirection, {
includePadding: false,
includeBorder: false,
includeMargin: false
})
),
rangeScaledSizes,
newScroll,
getMinItemSize,
minIndex,
maxIndex
)
);
},
[
setItems,
scrollDirection,
rangeScaledSizes,
getMinItemSize,
minIndex,
maxIndex
]
);
const completeScrollToIndex = useCallback5(() => {
if (scrollToIndexInProgressRef.current) {
scrollResetting.current = true;
setItemStackPointer((prev) => {
return Number(!prev);
});
}
}, [setItemStackPointer, scrollToIndexInProgressRef]);
useEffect5(() => {
if (scrollToIndexInProgressRef.current) {
setSecondaryItems(null);
}
}, [setSecondaryItems, itemStackPointer]);
useEffect5(() => {
if (secondaryItems === null && scrollToIndexInProgressRef.current) {
setScrollToIndexInput(null);
scrollToIndexInProgressRef.current = false;
scrollToIndexDebounceRef.current = null;
}
}, [
setScrollToIndexInput,
scrollResetting,
scrollToIndexInProgressRef,
secondaryItems
]);
const containerScrollHandler = useCallback5(
(container, ev, isRetry = false) => {
var _a, _b;
if (secondaryItems) {
if (typeof scrollToIndexDebounceRef.current === "number")
clearTimeout(scrollToIndexDebounceRef.current);
scrollToIndexDebounceRef.current = setTimeout(
completeScrollToIndex,
scrollToIndexDebounceDelay
);
return;
}
if (scrollResetting.current || !items) return;
const startContentIndex = items.rangePointers["startContent"][0];
const endContentIndex = items.rangePointers["endContent"][1];
const startContentItem = (_a = getRef(
items.indexes[startContentIndex]
)) == null ? void 0 : _a.current;
const endContentItem = (_b = getRef(items.indexes[endContentIndex])) == null ? void 0 : _b.current;
if (!startContentItem || !endContentItem) {
if (!isRetry) {
requestAnimationFrame(
() => containerScrollHandler(container, ev, true)
);
}
return;
}
const containerScroll = scrollDirection === "horizontal" ? container.scrollLeft : container.scrollTop;
if ((typeof minIndex !== "number" || mergedIndexes[0] > minIndex) && containerScroll < getScrollLength("start", container, startContentItem, scrollDirection)) {
updateItems(
{
block: "start",
index: items.indexes[startContentIndex]
},
container
);
} else if ((typeof maxIndex !== "number" || mergedIndexes[mergedIndexes.length - 1] < maxIndex) && containerScroll > getScrollLength("end", container, endContentItem, scrollDirection)) {
updateItems(
{
block: "end",
index: items.indexes[endContentIndex]
},
container
);
}
},
[
secondaryItems,
items,
getRef,
scrollDirection,
minIndex,
mergedIndexes,
maxIndex,
completeScrollToIndex,
updateItems
]
);
useScrollHandler({
elementRef: containerRef,
handler: containerScrollHandler
});
useDeferredScrollReset({
scroll,
onScrollReset: () => {
scrollResetting.current = false;
},
containerRef,
items,
getRef,
suppress: scrollToIndexInProgressRef.current,
scrollDirection
});
const scrollToIndex = useCallback5(
(input) => {
const container = containerRef == null ? void 0 : containerRef.current;
if (!container) {
throw new ProceduralScrollerError("Could not find container", {
container
});
}
let targetIndex = asInteger(input.index);
if (typeof minIndex === "number") {
targetIndex = asInteger(Math.max(targetIndex, minIndex));
}
if (typeof maxIndex === "number") {
targetIndex = asInteger(Math.min(targetIndex, maxIndex));
}
const targetScroll = asScroll({
block: input.block,
index: targetIndex
});
scroll.current = targetScroll;
scrollResetting.current = true;
scrollToIndexInProgressRef.current = true;
setScrollToIndexInput(__spreadValues(__spreadValues({}, input), targetScroll));
setSecondaryItems(
getItems(
asNonNegativeReal(
getElementSize(container, scrollDirection, {
includePadding: false,
includeBorder: false,
includeMargin: false
})
),
rangeScaledSizes,
targetScroll,
getMinItemSize,
minIndex,
maxIndex
)
);
},
[
getMinItemSize,
maxIndex,
minIndex,
rangeScaledSizes,
scrollDirection,
setSecondaryItems
]
);
useEffect5(() => {
if (!secondaryItems || !scrollToIndexInput) return;
const itemRef = getRefOrError(scrollToIndexInput.index, true);
const item = itemRef == null ? void 0 : itemRef.current;
const container = containerRef == null ? void 0 : containerRef.current;
if (!item || !container) {
throw new ProceduralScrollerError(
`Tried to scroll to index = ${scrollToIndexInput.index} but the element/container has no mounted ref`,
{ container, item }
);
}
const scrollPos = getScrollLength(
scrollToIndexInput.block,
container,
item,
scrollDirection
);
container.scrollTo({
behavior: scrollToIndexInput.behavior || "auto",
[scrollDirection === "horizontal" ? "left" : "top"]: scrollPos
});
scrollToIndexDebounceRef.current = setTimeout(
completeScrollToIndex,
scrollToIndexDebounceDelay
);
}, [
getRefOrError,
secondaryItems,
scrollToIndexInput,
maxIndex,
minIndex,
completeScrollToIndex,
scrollDirection
]);
const itemsResult = useMemo(() => {
if (items) {
return mergedIndexes.map((index) => {
return {
index,
ref: getRefOrError(index, false)
};
});
} else {
return null;
}
}, [getRefOrError, items, mergedIndexes]);
useItemSizeCheck({
items: itemsResult,
getMinItemSize,
scrollDirection,
enabled: (validateLayouts == null ? void 0 : validateLayouts.items) !== false
});
useUnboundedHeightCheck({
items: itemsResult,
containerRef,
scrollDirection,
enabled: (validateLayouts == null ? void 0 : validateLayouts.container) !== false
});
return {
scrollToIndex,
container: {
ref: containerRef
},
items: itemsResult
};
};
export {
useProceduralScroller
};
//# sourceMappingURL=index.js.map