- Enable TypeScript noUncheckedIndexedAccess and fix 133 resulting errors across 46 files with proper null guards, optional chaining, and fallbacks - Extract education/gamification ghost feature MSW handlers into handlers-ghost.ts - Add Storybook test plugin documentation in vitest.config.ts - Document abandoned go-clamd dependency (2017) as tech debt in upload_validator.go Co-authored-by: Cursor <cursoragent@cursor.com>
151 lines
3.9 KiB
TypeScript
151 lines
3.9 KiB
TypeScript
/**
|
|
* Hook useInfiniteScroll
|
|
* Détecte le scroll et charge automatiquement plus de contenu en utilisant Intersection Observer
|
|
*/
|
|
|
|
import { useEffect, useRef, useCallback, RefObject } from 'react';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
export interface UseInfiniteScrollOptions {
|
|
/**
|
|
* Callback appelé quand on atteint le seuil de scroll
|
|
*/
|
|
onLoadMore: () => void | Promise<void>;
|
|
/**
|
|
* Si le chargement est en cours
|
|
*/
|
|
isLoading?: boolean;
|
|
/**
|
|
* Si on a atteint la fin (plus de contenu à charger)
|
|
*/
|
|
hasMore?: boolean;
|
|
/**
|
|
* Seuil de déclenchement (en pixels ou ratio)
|
|
* Par défaut: 100px avant la fin
|
|
*/
|
|
threshold?: number;
|
|
/**
|
|
* Root margin pour l'Intersection Observer
|
|
* Par défaut: "100px"
|
|
*/
|
|
rootMargin?: string;
|
|
/**
|
|
* Élément root pour l'Intersection Observer
|
|
* Par défaut: viewport
|
|
*/
|
|
root?: Element | null;
|
|
/**
|
|
* Désactiver le hook
|
|
*/
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface UseInfiniteScrollReturn {
|
|
/**
|
|
* Ref à attacher à l'élément sentinelle (élément de déclenchement)
|
|
*/
|
|
sentinelRef: RefObject<HTMLDivElement>;
|
|
/**
|
|
* Ref à attacher au conteneur scrollable (optionnel)
|
|
*/
|
|
containerRef: RefObject<HTMLDivElement>;
|
|
}
|
|
|
|
/**
|
|
* Hook pour implémenter le scroll infini
|
|
* Utilise Intersection Observer pour détecter quand l'utilisateur approche de la fin
|
|
*/
|
|
export function useInfiniteScroll({
|
|
onLoadMore,
|
|
isLoading = false,
|
|
hasMore = true,
|
|
threshold = 100,
|
|
rootMargin,
|
|
root = null,
|
|
disabled = false,
|
|
}: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
const isLoadingRef = useRef(false);
|
|
|
|
// Mettre à jour la ref pour éviter les closures
|
|
useEffect(() => {
|
|
isLoadingRef.current = isLoading;
|
|
}, [isLoading]);
|
|
|
|
// Callback pour charger plus de contenu
|
|
const handleLoadMore = useCallback(async () => {
|
|
// Éviter les appels multiples simultanés
|
|
if (isLoadingRef.current || !hasMore || disabled) {
|
|
return;
|
|
}
|
|
|
|
isLoadingRef.current = true;
|
|
try {
|
|
await onLoadMore();
|
|
} catch (error) {
|
|
logger.error('Error loading more content:', { error });
|
|
} finally {
|
|
// Laisser un petit délai avant de permettre un nouveau chargement
|
|
setTimeout(() => {
|
|
isLoadingRef.current = false;
|
|
}, 100);
|
|
}
|
|
}, [onLoadMore, hasMore, disabled]);
|
|
|
|
// Configuration de l'Intersection Observer
|
|
useEffect(() => {
|
|
if (disabled || !hasMore) {
|
|
// Nettoyer l'observer si désactivé ou plus de contenu
|
|
if (observerRef.current) {
|
|
observerRef.current.disconnect();
|
|
observerRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const sentinel = sentinelRef.current;
|
|
if (!sentinel) {
|
|
return;
|
|
}
|
|
|
|
// Calculer rootMargin si non fourni
|
|
const margin = rootMargin || `${threshold}px`;
|
|
|
|
// Créer l'observer
|
|
observerRef.current = new IntersectionObserver(
|
|
(entries) => {
|
|
const [entry] = entries;
|
|
if (!entry) return;
|
|
// Si l'élément sentinelle est visible et qu'on n'est pas déjà en train de charger
|
|
if (entry.isIntersecting && !isLoadingRef.current && hasMore) {
|
|
handleLoadMore();
|
|
}
|
|
},
|
|
{
|
|
root: root || containerRef.current,
|
|
rootMargin: margin,
|
|
threshold: 0.1, // Déclencher quand 10% de l'élément est visible
|
|
},
|
|
);
|
|
|
|
// Observer l'élément sentinelle
|
|
observerRef.current.observe(sentinel);
|
|
|
|
// Cleanup
|
|
return () => {
|
|
if (observerRef.current) {
|
|
observerRef.current.disconnect();
|
|
observerRef.current = null;
|
|
}
|
|
};
|
|
}, [disabled, hasMore, handleLoadMore, root, rootMargin, threshold]);
|
|
|
|
return {
|
|
sentinelRef,
|
|
containerRef,
|
|
};
|
|
}
|
|
|
|
export default useInfiniteScroll;
|