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 const VirtualizedList = React.forwardRef< HTMLDivElement, VirtualizedListProps >((props, ref) => { const { items, itemHeight, containerHeight, renderItem, className = '', overscan = 5, onScroll, onItemsRendered, } = props; const internalRef = useRef(null); // Use forwarded ref if available, otherwise internal fallback // This is a simple merge strategy: we need the ref internally for virtualizer // So we'll assign to both if forwarded ref is object, or call if function. // Actually, easiest is to just use one ref and sync or useImperativeHandle. // But virtualizer needs a RefObject. // Let's use internalRef as primary and expose it via useImperativeHandle or sync. React.useImperativeHandle(ref, () => internalRef.current as HTMLDivElement); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_isScrolling, setIsScrolling] = useState(false); const scrollOffsetRef = useRef(0); const scrollTimeoutRef = useRef(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => internalRef.current, estimateSize: () => itemHeight, overscan, }); const virtualItems = virtualizer.getVirtualItems(); // Handle scroll events with debouncing const handleScroll = useCallback(() => { const scrollTop = internalRef.current?.scrollTop || 0; // eslint-disable-next-line @typescript-eslint/no-unused-vars const _isScrolling = Math.abs(scrollTop - (scrollOffsetRef.current || 0)) > 0; setIsScrolling(true); // Keep this to trigger the debounced state if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); } scrollTimeoutRef.current = setTimeout(() => { setIsScrolling(false); }, 150); scrollOffsetRef.current = scrollTop; // Update scroll offset if (onScroll && internalRef.current) { onScroll(internalRef.current.scrollTop); } if (onItemsRendered && virtualItems.length > 0) { const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); const endIndex = virtualItems[virtualItems.length - 1].index; onItemsRendered(startIndex, endIndex); } }, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]); // Added itemHeight, overscan to dependencies useEffect(() => { const scrollElement = internalRef.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 &&
}
); }) as ( props: VirtualizedListProps & { ref?: React.Ref }, ) => React.ReactElement; // Hook for infinite scrolling export function useInfiniteScroll( items: T[], hasNextPage: boolean, isFetching: boolean, fetchNextPage: () => void, threshold: number = 5, ) { const [isNearBottom, setIsNearBottom] = useState(false); // eslint-disable-next-line @typescript-eslint/no-unused-vars 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 }; }