feat(ui): add InfiniteScrollArea component
Scrollable container with top/bottom IntersectionObserver sentinels. Fires onTopVisibilityChange when the top is fully in view and onEndReached when the bottom is within 100px. Used by infinite log and event streams.
This commit is contained in:
22
ui/src/components/InfiniteScrollArea.module.css
Normal file
22
ui/src/components/InfiniteScrollArea.module.css
Normal file
@@ -0,0 +1,22 @@
|
||||
.scrollArea {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loadingMore,
|
||||
.endOfStream {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.endOfStream {
|
||||
opacity: 0.5;
|
||||
}
|
||||
74
ui/src/components/InfiniteScrollArea.tsx
Normal file
74
ui/src/components/InfiniteScrollArea.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
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,
|
||||
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 && <div className={styles.endOfStream}>End of stream</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user