292 lines
8.1 KiB
TypeScript
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 };
|
|
}
|