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:
hsiegeln
2026-04-17 12:41:17 +02:00
parent a7f53c8993
commit c2ce508565
2 changed files with 96 additions and 0 deletions

View 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;
}

View 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>
);
}