veza/apps/web/src/features/tracks/hooks/useInfiniteScroll.ts
senke 09bb663659 chore: enable noUncheckedIndexedAccess, isolate ghost MSW handlers, document go-clamd tech debt
- 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>
2026-02-12 23:12:35 +01:00

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;