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

292 lines
8.1 KiB
TypeScript

import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
/**
* VirtualizedListProps - Propriétés du composant VirtualizedList
*
* @interface VirtualizedListProps
* @template T - Type des éléments de la liste
*/
export interface VirtualizedListProps<T> {
/**
* Tableau d'éléments à afficher
*/
items: T[];
/**
* Hauteur de chaque élément en pixels (doit être constante)
*/
itemHeight: number;
/**
* Hauteur du conteneur de la liste en pixels
*/
containerHeight: number;
/**
* Fonction pour rendre chaque élément
*
* @param {T} item - Élément à rendre
* @param {number} index - Index de l'élément
* @returns {React.ReactNode} Élément React à afficher
*/
renderItem: (item: T, index: number) => React.ReactNode;
/**
* Classes CSS personnalisées pour le conteneur
*/
className?: string;
/**
* Nombre d'éléments à rendre en dehors de la zone visible (pour le smooth scrolling)
*
* @default 5
*/
overscan?: number;
/**
* Fonction appelée lors du scroll
*
* @param {number} scrollTop - Position de scroll en pixels
*/
onScroll?: (scrollTop: number) => void;
/**
* Fonction appelée lorsque les éléments rendus changent
*
* @param {number} startIndex - Index du premier élément visible
* @param {number} endIndex - Index du dernier élément visible
*/
onItemsRendered?: (startIndex: number, endIndex: number) => void;
}
/**
* VirtualizedList - Composant de liste virtualisée pour grandes listes
*
* Composant de liste optimisé pour afficher de grandes quantités d'éléments
* en ne rendant que les éléments visibles. Utilise @tanstack/react-virtual
* pour la virtualisation.
*
* @example
* ```tsx
* // Liste virtualisée simple
* <VirtualizedList
* items={largeArray}
* itemHeight={50}
* containerHeight={400}
* renderItem={(item, index) => (
* <div key={index} className="p-4 border">
* {item.name}
* </div>
* )}
* />
* ```
*
* @example
* ```tsx
* // Avec callbacks
* <VirtualizedList
* items={tracks}
* itemHeight={80}
* containerHeight={600}
* renderItem={(track) => <TrackItem track={track} />}
* onScroll={(scrollTop) => console.log('Scrolled:', scrollTop)}
* onItemsRendered={(start, end) => console.log(`Rendering ${start}-${end}`)}
* />
* ```
*
* @component
* @template T - Type des éléments de la liste
* @param {VirtualizedListProps<T>} props - Propriétés du composant
* @returns {JSX.Element} Liste virtualisée avec scroll optimisé
*/
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,
getScrollElement: () => internalRef.current,
estimateSize: () => itemHeight,
overscan,
});
const virtualItems = virtualizer.getVirtualItems();
// Handle scroll events with debouncing
const handleScroll = useCallback(() => {
const scrollTop = internalRef.current?.scrollTop || 0;
// Check if scrolling (value not used but calculation needed for side effect)
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);
}
return undefined;
}, [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={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>
);
}) 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,
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 };
}