From c2ce508565a973c1442e72ff9009eabd1fea0bd3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:41:17 +0200 Subject: [PATCH] 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. --- .../components/InfiniteScrollArea.module.css | 22 ++++++ ui/src/components/InfiniteScrollArea.tsx | 74 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 ui/src/components/InfiniteScrollArea.module.css create mode 100644 ui/src/components/InfiniteScrollArea.tsx diff --git a/ui/src/components/InfiniteScrollArea.module.css b/ui/src/components/InfiniteScrollArea.module.css new file mode 100644 index 00000000..078e78fb --- /dev/null +++ b/ui/src/components/InfiniteScrollArea.module.css @@ -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; +} diff --git a/ui/src/components/InfiniteScrollArea.tsx b/ui/src/components/InfiniteScrollArea.tsx new file mode 100644 index 00000000..7d085260 --- /dev/null +++ b/ui/src/components/InfiniteScrollArea.tsx @@ -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; + className?: string; +} + +export function InfiniteScrollArea({ + onEndReached, + onTopVisibilityChange, + isFetchingNextPage, + hasNextPage, + maxHeight = 360, + children, + scrollRef, + className, +}: InfiniteScrollAreaProps) { + const internalRef = useRef(null); + const containerRef = scrollRef ?? internalRef; + const topSentinel = useRef(null); + const bottomSentinel = useRef(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 ( +
+