glam/frontend/src/hooks/useInfiniteScroll.ts
2025-12-16 20:27:39 +01:00

132 lines
4 KiB
TypeScript

/**
* useInfiniteScroll Hook
*
* Provides infinite scrolling functionality for conversation visualization components.
* Automatically loads more results when user scrolls near the bottom of a container.
*
* Usage:
* ```tsx
* const { scrollContainerRef, isNearBottom } = useInfiniteScroll({
* onLoadMore: handleLoadMoreResults,
* hasMore: paginationState?.hasMore ?? false,
* isLoading: isLoadingMore,
* threshold: 200, // pixels from bottom
* });
*
* return <div ref={scrollContainerRef}>...</div>
* ```
*/
import { useCallback, useEffect, useRef, useState } from 'react';
export interface UseInfiniteScrollOptions {
/** Callback to load more results */
onLoadMore: () => Promise<void> | void;
/** Whether there are more results to load */
hasMore: boolean;
/** Whether currently loading */
isLoading: boolean;
/** Distance from bottom (in pixels) to trigger load */
threshold?: number;
/** Whether infinite scroll is enabled (allows toggle between button and scroll) */
enabled?: boolean;
/** Debounce delay in milliseconds */
debounceMs?: number;
}
export interface UseInfiniteScrollReturn {
/** Ref to attach to the scrollable container */
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
/** Whether user is near the bottom of the container */
isNearBottom: boolean;
/** Manually trigger scroll position check */
checkScrollPosition: () => void;
}
export function useInfiniteScroll({
onLoadMore,
hasMore,
isLoading,
threshold = 200,
enabled = true,
debounceMs = 150,
}: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isNearBottom, setIsNearBottom] = useState(false);
const loadingRef = useRef(false);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Check if user has scrolled near the bottom
const checkScrollPosition = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const nearBottom = distanceFromBottom < threshold;
setIsNearBottom(nearBottom);
// Trigger load if conditions are met
if (enabled && nearBottom && hasMore && !isLoading && !loadingRef.current) {
loadingRef.current = true;
// Call onLoadMore and reset loading ref when done
const result = onLoadMore();
if (result instanceof Promise) {
result.finally(() => {
loadingRef.current = false;
});
} else {
// For synchronous handlers, reset after a short delay
setTimeout(() => {
loadingRef.current = false;
}, 100);
}
}
}, [onLoadMore, hasMore, isLoading, threshold, enabled]);
// Debounced scroll handler
const handleScroll = useCallback(() => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
checkScrollPosition();
}, debounceMs);
}, [checkScrollPosition, debounceMs]);
// Attach scroll listener
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || !enabled) return;
container.addEventListener('scroll', handleScroll, { passive: true });
// Initial check in case content is already scrolled or container is small
checkScrollPosition();
return () => {
container.removeEventListener('scroll', handleScroll);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [handleScroll, checkScrollPosition, enabled]);
// Re-check when hasMore or isLoading changes
useEffect(() => {
if (enabled && hasMore && !isLoading) {
checkScrollPosition();
}
}, [hasMore, isLoading, enabled, checkScrollPosition]);
return {
scrollContainerRef,
isNearBottom,
checkScrollPosition,
};
}
export default useInfiniteScroll;