import React, { useRef, useEffect, useState, useCallback } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; export interface VirtualizedListProps { items: T[]; itemHeight: number; containerHeight: number; renderItem: (item: T, index: number) => React.ReactNode; className?: string; overscan?: number; onScroll?: (scrollTop: number) => void; onItemsRendered?: (startIndex: number, endIndex: number) => void; } export function VirtualizedList({ items, itemHeight, containerHeight, renderItem, className = '', overscan = 5, onScroll, onItemsRendered, }: VirtualizedListProps) { const parentRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isScrolling, setIsScrolling] = useState(false); const scrollTimeoutRef = useRef(); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => itemHeight, overscan, }); const virtualItems = virtualizer.getVirtualItems(); // Handle scroll events with debouncing const handleScroll = useCallback(() => { setIsScrolling(true); if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); } scrollTimeoutRef.current = setTimeout(() => { setIsScrolling(false); }, 150); if (onScroll && parentRef.current) { onScroll(parentRef.current.scrollTop); } if (onItemsRendered && virtualItems.length > 0) { const startIndex = virtualItems[0].index; const endIndex = virtualItems[virtualItems.length - 1].index; onItemsRendered(startIndex, endIndex); } }, [onScroll, onItemsRendered, virtualItems]); useEffect(() => { const scrollElement = parentRef.current; if (scrollElement) { scrollElement.addEventListener('scroll', handleScroll, { passive: true }); return () => scrollElement.removeEventListener('scroll', handleScroll); } }, [handleScroll]); // Cleanup timeout on unmount useEffect(() => { return () => { if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); } }; }, []); const totalSize = virtualizer.getTotalSize(); const paddingTop = virtualItems.length > 0 ? virtualItems[0]?.start || 0 : 0; const paddingBottom = totalSize - (virtualItems.length > 0 ? virtualItems[virtualItems.length - 1]?.end || 0 : 0); return (
{paddingTop > 0 &&
} {virtualItems.map((virtualItem) => (
{renderItem(items[virtualItem.index], virtualItem.index)}
))} {paddingBottom > 0 &&
}
); } // Hook for infinite scrolling export function useInfiniteScroll( items: T[], hasNextPage: boolean, isFetching: boolean, fetchNextPage: () => void, threshold: number = 5 ) { const [isNearBottom, setIsNearBottom] = useState(false); const handleItemsRendered = useCallback( (startIndex: number, endIndex: number) => { const isNearEnd = endIndex >= items.length - threshold; setIsNearBottom(isNearEnd); }, [items.length, threshold] ); useEffect(() => { if (isNearBottom && hasNextPage && !isFetching) { fetchNextPage(); } }, [isNearBottom, hasNextPage, isFetching, fetchNextPage]); return { handleItemsRendered }; } // Hook for scroll position restoration export function useScrollPosition(key: string) { const [scrollPosition, setScrollPosition] = useState(0); useEffect(() => { const saved = sessionStorage.getItem(`scroll-${key}`); if (saved) { setScrollPosition(parseInt(saved, 10)); } }, [key]); const saveScrollPosition = useCallback((position: number) => { setScrollPosition(position); sessionStorage.setItem(`scroll-${key}`, position.toString()); }, [key]); const restoreScrollPosition = useCallback((element: HTMLElement | null) => { if (element && scrollPosition > 0) { element.scrollTop = scrollPosition; } }, [scrollPosition]); return { scrollPosition, saveScrollPosition, restoreScrollPosition }; }