import { useVirtualizer } from '@tanstack/react-virtual'; import cn from 'classnames'; import mergeRefs from 'merge-refs'; import { ForwardedRef, forwardRef, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Spinner } from '@snack-uikit/loaders'; import { Scroll } from '@snack-uikit/scroll'; import { extractSupportProps } from '@snack-uikit/utils'; import { ListEmptyState, useEmptyState } from '../../../helperComponents'; import { stopPropagation } from '../../../utils'; import { ItemId, PinBottomGroupItem, PinTopGroupItem, SearchItem, useRenderItems } from '../../Items'; import { useNewListContext, useSelectionContext } from '../contexts'; import commonStyles from '../styles.module.scss'; import { ListPrivateProps } from '../types'; import { ALL_SIZES } from './constants'; import styles from './styles.module.scss'; type ScrollState = { virtualizer: ItemId | null; browser: ItemId | null; measured: boolean; }; export const ListPrivate = forwardRef( ( { items, pinTop, pinBottom, onKeyDown, onBlur, onFocus, tabIndex, active, scroll, nested, search, searchItem, scrollRef, scrollContainerRef, onScroll, footer, loading, limitedScrollHeight, untouchableScrollbars, className, noDataState, noResultsState, errorDataState, dataError, dataFiltered, scrollToSelectedItem = false, virtualized = false, scrollContainerClassName, barHideStrategy = 'never', ...props }: ListPrivateProps, ref: ForwardedRef, ) => { const { size = 's', flattenItems, focusFlattenItems } = useNewListContext(); const { value, isSelectionSingle } = useSelectionContext(); const innerScrollRef = useRef(null); const itemsJSX = useRenderItems(items); const itemsPinTopJSX = useRenderItems(pinTop); const itemsPinBottomJSX = useRenderItems(pinBottom); const [scrollState, setScrollState] = useState({ virtualizer: null, browser: null, measured: false }); const emptyStates = useEmptyState({ noDataState, noResultsState, errorDataState }); const hasNoItems = items.length === 0; const { selectedItemIndex, selectedItem } = useMemo(() => { const result = { selectedItemIndex: -1, selectedItem: undefined, }; if (!scrollToSelectedItem || !value) { return result; } const selectedItem = isSelectionSingle ? flattenItems[value] : flattenItems[value[0]]; if (!selectedItem?.id) { return result; } const allFocusFlattenItems = Object.values(focusFlattenItems); const index = allFocusFlattenItems.findIndex(item => item.originalId === selectedItem.id); if (index < 0) { return result; } return { selectedItemIndex: index, selectedItem: allFocusFlattenItems[index], }; }, [flattenItems, focusFlattenItems, isSelectionSingle, scrollToSelectedItem, value]); const virtualizer = useVirtualizer({ count: itemsJSX.length, getScrollElement: () => (scroll ? innerScrollRef.current : null), estimateSize: () => ALL_SIZES[size], enabled: virtualized, overscan: 5, // Amount of elements in DOM before/after visible ones }); const virtualItems = virtualizer.getVirtualItems(); useEffect(() => { if (scrollState.measured) { return; } virtualizer.measure(); // TODO: перезамерять размер setScrollState(prevState => ({ ...prevState, measured: true, })); }, [scrollState.measured, virtualizer]); const isScrollToItemEnabled = scroll && scrollToSelectedItem && virtualized; useEffect(() => { if (isScrollToItemEnabled) { if (!scrollState.measured) { return; // Not measured yet } if (selectedItemIndex < 0 || !selectedItem) { return; // Cannot scroll to non-existing item } if (scrollState.virtualizer === selectedItem.originalId) { return; // No need to re-scroll to the same item during re-renders } if (selectedItem?.itemRef && innerScrollRef.current?.contains(selectedItem?.itemRef.current)) { return; // No need to scroll to manually clicked item currently present in DOM } virtualizer.scrollToIndex(selectedItemIndex, { align: 'center' }); setScrollState(prevState => ({ ...prevState, virtualizer: selectedItem.originalId, })); } }, [isScrollToItemEnabled, scrollState, selectedItem, selectedItemIndex, virtualizer]); const isTargetPresentInDom = Boolean(selectedItem?.itemRef?.current); useEffect(() => { if (!selectedItem) { return; } if (scrollState.virtualizer === null) { return; // Not scrolled by virtualizer yet, no need for additional scroll } if (!isTargetPresentInDom) { return; // Target element is not present in DOM yet, additional scroll does not work without it } if (scrollState.virtualizer === scrollState.browser) { return; // Virtualizer scroll has not been executed => no need for additional scroll } selectedItem.itemRef?.current?.scrollIntoView({ block: 'center' }); setScrollState(prevState => ({ ...prevState, browser: selectedItem.originalId, })); }, [scrollState, selectedItem, isTargetPresentInDom, selectedItemIndex]); const loadingJSX = useMemo( () => loading && (
), [hasNoItems, loading, size], ); const content = useMemo( () => ( <> {virtualized ? (
{virtualItems.map(virtualRow => (
{itemsJSX[virtualRow.index]}
))}
) : ( itemsJSX )} {loadingJSX} ), [ dataError, dataFiltered, emptyStates, hasNoItems, itemsJSX, loading, loadingJSX, search?.value, size, virtualItems, virtualized, virtualizer, ], ); const onScrollInitialized = useCallback(() => { if (!selectedItem) { return; } selectedItem?.itemRef?.current?.scrollIntoView({ block: 'center' }); }, [selectedItem]); const listJSX = (
    } onKeyDown={onKeyDown} tabIndex={tabIndex} onFocus={onFocus} onBlur={onBlur} data-active={active || undefined} role='menu' {...extractSupportProps(props)} > {(Number(pinTop?.length) > 0 || search) && ( {search && } {Number(pinTop?.length) > 0 && itemsPinTopJSX} )} {scroll ? ( {content}
    } /> ) : ( <>{content} )} {Number(pinBottom?.length) > 0 && {itemsPinBottomJSX}} {footer && (
    {footer}
    )}
); if (!nested) { return listJSX; } return (
  • {listJSX}
  • ); }, );