132 lines
4 KiB
TypeScript
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;
|