veza/apps/web/src/components/ui/virtualized-list.tsx

170 lines
4.7 KiB
TypeScript
Raw Normal View History

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 };
}