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

205 lines
6 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;
}
2025-12-13 02:34:34 +00:00
export const VirtualizedList = React.forwardRef<
HTMLDivElement,
VirtualizedListProps<any>
>((props, ref) => {
const {
items,
itemHeight,
containerHeight,
renderItem,
className = '',
overscan = 5,
onScroll,
onItemsRendered,
} = props;
const internalRef = useRef<HTMLDivElement>(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<NodeJS.Timeout | null>(null);
const virtualizer = useVirtualizer({
count: items.length,
2025-12-13 02:34:34 +00:00
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
2025-12-13 02:34:34 +00:00
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
2025-12-13 02:34:34 +00:00
scrollTimeoutRef.current = setTimeout(() => {
setIsScrolling(false);
}, 150);
scrollOffsetRef.current = scrollTop; // Update scroll offset
2025-12-13 02:34:34 +00:00
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(() => {
2025-12-13 02:34:34 +00:00
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;
2025-12-13 02:34:34 +00:00
const paddingBottom =
totalSize -
(virtualItems.length > 0
? virtualItems[virtualItems.length - 1]?.end || 0
: 0);
return (
<div
2025-12-13 02:34:34 +00:00
ref={internalRef}
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>
);
2025-12-13 02:34:34 +00:00
}) as <T>(
props: VirtualizedListProps<T> & { ref?: React.Ref<HTMLDivElement> },
) => React.ReactElement;
// Hook for infinite scrolling
export function useInfiniteScroll<T>(
items: T[],
hasNextPage: boolean,
isFetching: boolean,
fetchNextPage: () => void,
2025-12-13 02:34:34 +00:00
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);
},
2025-12-13 02:34:34 +00:00
[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]);
2025-12-13 02:34:34 +00:00
const saveScrollPosition = useCallback(
(position: number) => {
setScrollPosition(position);
sessionStorage.setItem(`scroll-${key}`, position.toString());
},
[key],
);
2025-12-13 02:34:34 +00:00
const restoreScrollPosition = useCallback(
(element: HTMLElement | null) => {
if (element && scrollPosition > 0) {
element.scrollTop = scrollPosition;
}
},
[scrollPosition],
);
return { scrollPosition, saveScrollPosition, restoreScrollPosition };
}