170 lines
4.7 KiB
TypeScript
170 lines
4.7 KiB
TypeScript
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||
|
|
|
||
|
|
export interface VirtualizedListProps<T> {
|
||
|
|
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<T>({
|
||
|
|
items,
|
||
|
|
itemHeight,
|
||
|
|
containerHeight,
|
||
|
|
renderItem,
|
||
|
|
className = '',
|
||
|
|
overscan = 5,
|
||
|
|
onScroll,
|
||
|
|
onItemsRendered,
|
||
|
|
}: VirtualizedListProps<T>) {
|
||
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
|
|
const [isScrolling, setIsScrolling] = useState(false);
|
||
|
|
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
|
||
|
|
|
||
|
|
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 (
|
||
|
|
<div
|
||
|
|
ref={parentRef}
|
||
|
|
className={`overflow-auto ${className}`}
|
||
|
|
style={{ height: containerHeight }}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
height: totalSize,
|
||
|
|
width: '100%',
|
||
|
|
position: 'relative',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{paddingTop > 0 && <div style={{ height: paddingTop }} />}
|
||
|
|
{virtualItems.map((virtualItem) => (
|
||
|
|
<div
|
||
|
|
key={virtualItem.key}
|
||
|
|
data-index={virtualItem.index}
|
||
|
|
ref={virtualizer.measureElement}
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
top: 0,
|
||
|
|
left: 0,
|
||
|
|
width: '100%',
|
||
|
|
transform: `translateY(${virtualItem.start}px)`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{renderItem(items[virtualItem.index], virtualItem.index)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
{paddingBottom > 0 && <div style={{ height: paddingBottom }} />}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Hook for infinite scrolling
|
||
|
|
export function useInfiniteScroll<T>(
|
||
|
|
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 };
|
||
|
|
}
|