diff --git a/package-lock.json b/package-lock.json index c384c6844..c11370b6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@floating-ui/react": "^0.26.28", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.11.0", + "@tanstack/react-virtual": "^3.10.8", "blueimp-md5": "^2.19.0", "focus-trap": "^7.6.2", "lodash": "^4.17.21", @@ -6241,6 +6242,31 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", + "integrity": "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==", + "dependencies": { + "@tanstack/virtual-core": "3.10.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz", + "integrity": "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", diff --git a/package.json b/package.json index 62b8cb861..3cbe21964 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@floating-ui/react": "^0.26.28", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.11.0", + "@tanstack/react-virtual": "^3.10.8", "blueimp-md5": "^2.19.0", "focus-trap": "^7.6.2", "lodash": "^4.17.21", diff --git a/src/components/lab/Virtualizer/Virtualizer.tsx b/src/components/lab/Virtualizer/Virtualizer.tsx new file mode 100644 index 000000000..1dfeb2b42 --- /dev/null +++ b/src/components/lab/Virtualizer/Virtualizer.tsx @@ -0,0 +1,317 @@ +'use client'; + +import React from 'react'; + +import type { + Range, + Rect, + VirtualItem, + Virtualizer as VirtualizerInstance, +} from '@tanstack/react-virtual'; +import {defaultRangeExtractor, useVirtualizer} from '@tanstack/react-virtual'; + +import {useForkRef} from '../../../hooks'; +import type {Key} from '../../types'; + +import {useLoadMore} from './useLoadMore'; +import type {Loadable} from './useLoadMore'; + +type Item = {index: number; key: Key}; + +export type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'; + +export interface VirtualizerApi { + scrollToOffset: (offset: number, align?: ScrollAlignment) => void; + scrollToIndex: (index: number, align?: ScrollAlignment) => void; + scrollOffset: number | null; + scrollRect: Rect | null; +} + +interface VirtualizerProps extends Loadable, React.HTMLAttributes { + /** The ref of the virtualizer api. */ + apiRef?: React.Ref; + /** The ref of the scroll container element. */ + containerRef?: React.Ref; + /** The number of first level items in the list. */ + count: number; + /** The size of the item in the list. Size should include all children. For children items parentKey is passed. */ + getItemSize: (index: number, parentKey?: Key) => number; + /** The key of the item in the list. For children items parentKey is passed. */ + getItemKey: (index: number, parentKey?: Key) => Key; + /** Disables virtualization of the list. This might be useful for small lists. */ + disableVirtualization?: boolean; + /** Renders the row of the list. */ + renderRow: ( + /** The item of the row. + * @param item.index The index of the item in current level. + * @param item.key The key of the item in the list. + */ + item: Item, + /** The key of the parent item in the list. */ + parentKey: Key | undefined, + /** Renders the children of the row. + * @param options.count The number of children items. + * @param options.height The self height of the row. + */ + renderChildren: (options: {count: number; height: number}) => React.ReactNode, + ) => React.ReactNode; + /** The indexes of the persisted items. Each item is an array of indexes in the hierarchy. */ + persistedIndexes?: Array; +} + +export function Virtualizer({ + apiRef, + containerRef, + count, + getItemSize, + getItemKey, + disableVirtualization, + renderRow, + loading, + onLoadMore, + persistedIndexes, + ...props +}: VirtualizerProps) { + const scrollContainerRef = React.useRef(null); + const ref = useForkRef(containerRef, scrollContainerRef); + + const {rangeExtractor, persistedChildren} = + getRangeExtractorAndChildrenIndexes(persistedIndexes); + const virtualizer = useVirtualizer({ + count, + getScrollElement: () => scrollContainerRef.current, + getItemKey, + estimateSize: getItemSize, + rangeExtractor, + overscan: disableVirtualization ? count : 0, + }); + + React.useImperativeHandle( + apiRef, + () => ({ + scrollToOffset: (offset: number, align: ScrollAlignment = 'auto') => { + virtualizer.scrollToOffset(virtualizer.getOffsetForAlignment(offset, align)); + }, + scrollToIndex: (index: number, align: ScrollAlignment = 'auto') => { + virtualizer.scrollToIndex(index, {align}); + }, + get scrollOffset() { + return virtualizer.scrollOffset; + }, + get scrollRect() { + return virtualizer.scrollRect; + }, + }), + [virtualizer], + ); + + const visibleItems = virtualizer.getVirtualItems(); + + useLoadMore(scrollContainerRef, {onLoadMore, loading}); + + return ( +
+ {renderRows({ + totalHeight: virtualizer.getTotalSize(), + start: 0, + items: visibleItems, + scrollContainer: virtualizer.scrollElement, + parentKey: undefined, + renderRow, + getItemSize, + getItemKey, + disableVirtualization, + persistedChildren, + measureElement: virtualizer.measureElement, + })} +
+ ); +} + +function renderRows({ + totalHeight, + start, + parentKey, + getItemSize, + getItemKey, + renderRow, + items, + scrollContainer, + disableVirtualization, + persistedChildren, + measureElement, +}: { + totalHeight: number; + start: number; + parentKey?: Key; + getItemSize: (index: number, key?: Key) => number; + getItemKey: (index: number, key?: Key) => Key; + renderRow: ( + item: Item, + parentKey: Key | undefined, + renderChildren: (options: {count: number; height: number}) => React.ReactNode, + ) => React.ReactNode; + items: VirtualItem[]; + scrollContainer: HTMLElement | null; + disableVirtualization?: boolean; + persistedChildren?: Map>; + measureElement?: VirtualizerInstance['measureElement']; +}) { + return ( +
+ {items.map((virtualRow) => ( +
+ {renderRow(virtualRow as Item, parentKey, ({height, count}) => ( + + ))} +
+ ))} +
+ ); +} + +function ChildrenVirtualizer(props: { + start: number; + scrollContainer: HTMLElement | null; + count: number; + getItemSize: (index: number, key?: Key) => number; + getItemKey: (index: number, key?: Key) => Key; + parentKey: Key; + renderRow: ( + item: Item, + parentKey: Key | undefined, + renderChildren: (options: {count: number; height: number}) => React.ReactNode, + ) => React.ReactNode; + disableVirtualization?: boolean; + persistedIndexes?: Array; +}) { + const { + start, + scrollContainer, + count, + getItemSize, + getItemKey, + renderRow, + parentKey, + disableVirtualization, + persistedIndexes, + } = props; + const {rangeExtractor, persistedChildren} = + getRangeExtractorAndChildrenIndexes(persistedIndexes); + const virtualizer = useVirtualizer({ + count, + getScrollElement: () => scrollContainer, + estimateSize: (index) => getItemSize(index, parentKey), + getItemKey: (index) => getItemKey(index, parentKey), + scrollToFn: () => {}, // parent element controls scroll, so disable it here + paddingStart: start, + rangeExtractor, + overscan: 0, + enabled: !disableVirtualization, + }); + + let items = virtualizer.getVirtualItems(); + let height = virtualizer.getTotalSize() - start; + if (disableVirtualization) { + height = 0; + items = new Array(count).fill(0).map((_, index) => { + height += getItemSize(index, parentKey); + return { + index, + key: getItemKey(index), + start: 0, + end: 0, + size: 0, + lane: 0, + }; + }); + } + + return renderRows({ + getItemKey, + getItemSize, + totalHeight: height, + start, + items, + scrollContainer, + parentKey, + renderRow, + disableVirtualization, + persistedChildren, + }); +} + +function getRangeExtractorAndChildrenIndexes(persistedIndexes?: Array) { + if (!persistedIndexes) { + return {}; + } + const persistedChildren = new Map>(); + const persist: number[] = []; + for (const [index, ...childrenIndexes] of persistedIndexes) { + if (index >= 0) { + persist.push(index); + const children = persistedChildren.get(index) ?? []; + children.push(childrenIndexes); + persistedChildren.set(index, children); + } + } + + if (persist.length === 0) { + return {}; + } + + const rangeExtractor = (range: Range) => { + const next = new Set( + persist.filter((i) => i < range.count).concat(defaultRangeExtractor(range)), + ); + return Array.from(next).sort((a, b) => a - b); + }; + + return {rangeExtractor, persistedChildren}; +} diff --git a/src/components/lab/Virtualizer/useLoadMore.tsx b/src/components/lab/Virtualizer/useLoadMore.tsx new file mode 100644 index 000000000..d029f2a8b --- /dev/null +++ b/src/components/lab/Virtualizer/useLoadMore.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +export interface Loadable { + /** Whether the items are currently loading. */ + loading?: boolean; + /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ + onLoadMore?: () => void; +} + +export interface LoadMoreOptions extends Loadable { + /** + * The amount of offset from bottom that should trigger load more. + * The value is multiplied to the size of the visible area. + * + * @default 1 + */ + scrollOffset?: number; +} + +export function useLoadMore( + scrollContainerRef: React.RefObject, + options: LoadMoreOptions, +) { + const {onLoadMore, loading, scrollOffset = 1} = options; + + const isLoadingRef = React.useRef(loading); + React.useEffect(() => { + const element = scrollContainerRef.current; + if (!element || typeof onLoadMore !== 'function') { + return undefined; + } + + const onScroll = () => { + if (isLoadingRef.current) { + return; + } + + const shouldLoadMore = + element.scrollHeight - element.scrollTop - element.clientHeight < + element.clientHeight * scrollOffset; + if (shouldLoadMore) { + isLoadingRef.current = true; + onLoadMore(); + } + }; + element.addEventListener('scroll', onScroll); + return () => { + element.removeEventListener('scroll', onScroll); + }; + }, [scrollContainerRef, onLoadMore, scrollOffset]); + + const prevLoadingPropRef = React.useRef(loading); + React.useLayoutEffect(() => { + if (loading !== prevLoadingPropRef.current) { + isLoadingRef.current = loading; + prevLoadingPropRef.current = loading; + } + + const element = scrollContainerRef.current; + if (!element || typeof onLoadMore !== 'function') { + return; + } + + const shouldLoadMore = + !isLoadingRef.current && element.scrollHeight === element.clientHeight; + if (shouldLoadMore) { + isLoadingRef.current = true; + onLoadMore(); + } + }, [loading, onLoadMore, scrollContainerRef]); +}