monday-ui-react-core
Version:
Official monday.com UI resources for application development in React.js
227 lines (207 loc) • 7.08 kB
JSX
import React, { useRef, forwardRef, useCallback, useMemo, useEffect, useState } from "react";
import PropTypes from "prop-types";
import NOOP from "lodash/noop";
import cx from "classnames";
import { VariableSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import { getNormalizedItems, easeInOutQuint, getMaxOffset, getOnItemsRenderedData } from "./virtualized-list-service";
import useThrottledCallback from "../../hooks/useThrottledCallback";
import useMergeRefs from "../../hooks/useMergeRefs";
import "./VirtualizedList.scss";
const VirtualizedList = forwardRef(
(
{
className,
id,
items,
itemRenderer,
getItemHeight,
onScroll,
overscanCount,
getItemId,
scrollToId,
scrollDuration,
onScrollToFinished,
onItemsRendered,
onItemsRenderedThrottleMs,
onSizeUpdate
},
ref
) => {
// states
const [listHeight, setListHeight] = useState(0);
const [listWidth, setListWidth] = useState(0);
// Refs
const componentRef = useRef(null);
const listRef = useRef(null);
const scrollTopRef = useRef(0);
const animationDataRef = useRef({});
const mergedRef = useMergeRefs({ refs: [ref, componentRef] });
const animationData = animationDataRef.current;
if (!animationData.initialized) {
animationData.initialized = true;
animationData.scrollOffsetInitial = 0;
animationData.scrollOffsetFinal = 0;
animationData.animationStartTime = 0;
}
// Memos
// Creates object of itemId => { item, index, height, offsetTop}
const normalizedItems = useMemo(() => {
return getNormalizedItems(items, getItemId, getItemHeight);
}, [items, getItemId, getItemHeight]);
const maxListOffset = useMemo(() => {
return getMaxOffset(listHeight, normalizedItems);
}, [listHeight, normalizedItems]);
// Callbacks
const onScrollCB = useCallback(
({ scrollDirection, scrollOffset, scrollUpdateWasRequested }) => {
scrollTopRef.current = scrollOffset;
if (!scrollUpdateWasRequested) {
animationData.scrollOffsetInitial = scrollOffset;
}
onScroll && onScroll(scrollDirection, scrollOffset, scrollUpdateWasRequested);
},
[onScroll, scrollTopRef, animationData]
);
const animateScroll = useCallback(() => {
requestAnimationFrame(() => {
const now = performance.now();
const ellapsed = now - animationData.animationStartTime;
const scrollDelta = animationData.scrollOffsetFinal - animationData.scrollOffsetInitial;
const easedTime = easeInOutQuint(Math.min(1, ellapsed / scrollDuration));
const scrollOffset = animationData.scrollOffsetInitial + scrollDelta * easedTime;
const finalOffsetValue = Math.min(maxListOffset, scrollOffset);
scrollTopRef.current = finalOffsetValue;
listRef.current.scrollTo(finalOffsetValue);
if (ellapsed < scrollDuration) {
animateScroll();
} else {
animationData.animationStartTime = undefined;
animationData.scrollOffsetInitial = animationData.scrollOffsetFinal;
onScrollToFinished && onScrollToFinished();
}
});
}, [scrollDuration, animationData, listRef, maxListOffset, onScrollToFinished]);
const startScrollAnimation = useCallback(
item => {
const { offsetTop } = item;
if (animationData.animationStartTime || animationData.scrollOffsetFinal === offsetTop) {
// animation already in progress or final offset equals to item offset
return;
}
animationData.scrollOffsetFinal = offsetTop;
animationData.animationStartTime = performance.now();
animateScroll();
},
[animationData, animateScroll]
);
const rowRenderer = useCallback(
({ index, style }) => {
const item = items[index];
return itemRenderer(item, index, style);
},
[items, itemRenderer]
);
const calcItemHeight = useCallback(
index => {
const item = items[index];
return getItemHeight(item, index);
},
[items, getItemHeight]
);
const updateListSize = useCallback(
(width, height) => {
if (height !== listHeight || width !== listWidth) {
setTimeout(() => {
setListHeight(height);
setListWidth(width);
onSizeUpdate(width, height);
}, 0);
}
},
[listHeight, listWidth, onSizeUpdate]
);
const onItemsRenderedCB = useThrottledCallback(
({ visibleStartIndex, visibleStopIndex }) => {
if (!onItemsRendered) return;
// data = { firstItemId, lastItemId, centerItemId }
const data = getOnItemsRenderedData(
items,
normalizedItems,
getItemId,
visibleStartIndex,
visibleStopIndex,
listHeight,
scrollTopRef.current
);
onItemsRendered(data);
},
{ wait: onItemsRenderedThrottleMs, trailing: true },
[onItemsRendered, items, normalizedItems, getItemId, listHeight]
);
// Effects
useEffect(() => {
// scroll to specific item
if (scrollToId) {
const item = normalizedItems[scrollToId];
item && startScrollAnimation(item);
}
}, [scrollToId, startScrollAnimation, normalizedItems]);
useEffect(() => {
// recalculate row heights
if (listRef.current) {
listRef.current.resetAfterIndex(0);
}
}, [normalizedItems]);
return (
<div ref={mergedRef} className={cx("virtualized-list--wrapper", className)} id={id}>
<AutoSizer>
{({ height, width }) => {
updateListSize(width, height);
return (
<List
ref={listRef}
height={height}
width={width}
itemCount={items.length}
itemSize={calcItemHeight}
onScroll={onScrollCB}
overscanCount={overscanCount}
onItemsRendered={onItemsRenderedCB}
>
{rowRenderer}
</List>
);
}}
</AutoSizer>
</div>
);
}
);
VirtualizedList.propTypes = {
className: PropTypes.string,
id: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.object),
getItemHeight: PropTypes.func,
getItemId: PropTypes.func,
onScrollToFinished: PropTypes.func,
overscanCount: PropTypes.number,
scrollDuration: PropTypes.number,
onItemsRendered: PropTypes.func,
onItemsRenderedThrottleMs: PropTypes.number,
onSizeUpdate: PropTypes.func
};
VirtualizedList.defaultProps = {
className: "",
id: "",
items: [],
getItemHeight: (item, _index) => item.height,
getItemId: (item, _index) => item.id,
onScrollToFinished: NOOP,
overscanCount: 0,
scrollDuration: 200,
onItemsRendered: null,
onItemsRenderedThrottleMs: 200,
onSizeUpdate: NOOP
};
export default VirtualizedList;