Files
cameleer-server/ui/src/components/InfiniteScrollArea.tsx

81 lines
2.5 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
import styles from './InfiniteScrollArea.module.css';
export interface InfiniteScrollAreaProps {
onEndReached: () => void;
onTopVisibilityChange?: (atTop: boolean) => void;
isFetchingNextPage: boolean;
hasNextPage: boolean;
isLoading?: boolean;
hasItems?: boolean;
maxHeight?: number | string;
children: ReactNode;
/** Optional caller-owned scroll container ref (e.g. for scroll-to-top on refresh). */
scrollRef?: RefObject<HTMLDivElement | null>;
className?: string;
}
export function InfiniteScrollArea({
onEndReached,
onTopVisibilityChange,
isFetchingNextPage,
hasNextPage,
isLoading = false,
hasItems = true,
maxHeight = 360,
children,
scrollRef,
className,
}: InfiniteScrollAreaProps) {
const internalRef = useRef<HTMLDivElement | null>(null);
const containerRef = scrollRef ?? internalRef;
const topSentinel = useRef<HTMLDivElement | null>(null);
const bottomSentinel = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!onTopVisibilityChange) return;
const root = containerRef.current;
const target = topSentinel.current;
if (!root || !target) return;
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) onTopVisibilityChange(e.isIntersecting);
},
{ root, threshold: 1.0 },
);
obs.observe(target);
return () => obs.disconnect();
}, [containerRef, onTopVisibilityChange]);
useEffect(() => {
if (!hasNextPage) return;
const root = containerRef.current;
const target = bottomSentinel.current;
if (!root || !target) return;
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) if (e.isIntersecting) onEndReached();
},
{ root, rootMargin: '100px', threshold: 0 },
);
obs.observe(target);
return () => obs.disconnect();
}, [containerRef, onEndReached, hasNextPage]);
return (
<div
ref={containerRef}
className={`${styles.scrollArea}${className ? ` ${className}` : ''}`}
style={{ maxHeight }}
>
<div ref={topSentinel} className={styles.sentinel} aria-hidden="true" />
{children}
<div ref={bottomSentinel} className={styles.sentinel} aria-hidden="true" />
{isFetchingNextPage && <div className={styles.loadingMore}>Loading more</div>}
{!hasNextPage && !isLoading && hasItems && (
<div className={styles.endOfStream}>End of stream</div>
)}
</div>
);
}