UNPKG

monday-ui-react-core

Version:

Official monday.com UI resources for application development in React.js

281 lines (257 loc) • 8.69 kB
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, isVerticalScrollbarVisible } from "./virtualized-list-service"; import usePrevious from "../../hooks/usePrevious"; 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, onVerticalScrollbarVisiblityChange }, ref ) => { // states const [listHeight, setListHeight] = useState(0); const [listWidth, setListWidth] = useState(0); // prevs const prevScrollToId = usePrevious(scrollToId); // Refs const componentRef = useRef(null); const isVerticalScrollbarVisibleRef = 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; } // Callbacks const heightGetter = useCallback( (item, index) => { const height = getItemHeight(item, index); if (height === undefined) { console.error("Couldn't get height for item: ", item); } return height; }, [getItemHeight] ); const idGetter = useCallback( (item, index) => { const itemId = getItemId(item, index); if (itemId === undefined) { console.error("Couldn't get id for item: ", item); } return itemId; }, [getItemId] ); // Memos // Creates object of itemId => { item, index, height, offsetTop} const normalizedItems = useMemo(() => { return getNormalizedItems(items, idGetter, heightGetter); }, [items, idGetter, heightGetter]); 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; onScrollToFinished && onScrollToFinished(); } }); }, [scrollDuration, animationData, listRef, maxListOffset, onScrollToFinished]); const startScrollAnimation = useCallback( item => { const { offsetTop } = item; if (animationData.animationStartTime) { // animation already in progress animationData.scrollOffsetFinal = offsetTop; return; } if (animationData.scrollOffsetInitial === offsetTop) { // offset already equals to item offset onScrollToFinished && onScrollToFinished(); return; } animationData.scrollOffsetFinal = offsetTop; animationData.animationStartTime = performance.now(); animateScroll(); }, [animationData, animateScroll, onScrollToFinished] ); 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 heightGetter(item, index); }, [items, heightGetter] ); 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; const data = getOnItemsRenderedData( items, normalizedItems, idGetter, visibleStartIndex, visibleStopIndex, listHeight, scrollTopRef.current ); onItemsRendered(data); }, { wait: onItemsRenderedThrottleMs, trailing: true }, [onItemsRendered, items, normalizedItems, idGetter, listHeight] ); // Effects useEffect(() => { // scroll to specific item if (scrollToId && prevScrollToId !== scrollToId) { const item = normalizedItems[scrollToId]; item && startScrollAnimation(item); } }, [prevScrollToId, scrollToId, startScrollAnimation, normalizedItems]); useEffect(() => { // recalculate row heights if (listRef.current) { listRef.current.resetAfterIndex(0); } }, [normalizedItems]); useEffect(() => { // update vertical scrollbar visibility if (onVerticalScrollbarVisiblityChange) { const isVisible = isVerticalScrollbarVisible(items, normalizedItems, idGetter, listHeight); if (isVerticalScrollbarVisibleRef.current !== isVisible) { isVerticalScrollbarVisibleRef.current = isVisible; onVerticalScrollbarVisiblityChange(isVisible); } } }, [onVerticalScrollbarVisiblityChange, items, normalizedItems, listHeight, idGetter]); 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), itemRenderer: PropTypes.func, getItemHeight: PropTypes.func, getItemId: PropTypes.func, onScrollToFinished: PropTypes.func, overscanCount: PropTypes.number, scrollDuration: PropTypes.number, onItemsRendered: PropTypes.func, onItemsRenderedThrottleMs: PropTypes.number, onSizeUpdate: PropTypes.func, onVerticalScrollbarVisiblityChange: PropTypes.func }; VirtualizedList.defaultProps = { className: "", id: "", items: [], itemRenderer: (item, _index, _style) => item, getItemHeight: (item, _index) => item.height, getItemId: (item, _index) => item.id, onScrollToFinished: NOOP, overscanCount: 0, scrollDuration: 200, onItemsRendered: null, onItemsRenderedThrottleMs: 200, onSizeUpdate: NOOP, onVerticalScrollbarVisiblityChange: null }; export default VirtualizedList;