Auto-scroll component for websocket/grpc/sse

This commit is contained in:
Gregory Schier
2025-02-03 07:05:14 -08:00
parent be0ef7afce
commit 17dc1991f1
11 changed files with 208 additions and 137 deletions

View File

@@ -0,0 +1,89 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import type { ReactElement, UIEvent } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { IconButton } from './IconButton';
interface Props<T> {
data: T[];
render: (item: T, index: number) => ReactElement<HTMLElement>;
}
export function AutoScroller<T>({ data, render }: Props<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(true);
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
});
// Scroll to new items
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
// Set auto-scroll when container is scrolled
const pixelsFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
const newAutoScroll = pixelsFromBottom <= 0;
if (newAutoScroll !== autoScroll) {
setAutoScroll(newAutoScroll);
}
},
[autoScroll],
);
// Scroll to bottom on count change
useLayoutEffect(() => {
if (!autoScroll) return;
const el = containerRef.current;
if (el == null) return;
el.scrollTop = el.scrollHeight;
}, [autoScroll, data.length]);
return (
<div className="h-full w-full relative">
{!autoScroll && (
<div className="absolute bottom-0 right-0 m-2">
<IconButton
title="Lock scroll to bottom"
icon="arrow_down"
size="sm"
iconSize="md"
variant="border"
className={'!bg-surface z-10'}
onClick={() => setAutoScroll((v) => !v)}
/>
</div>
)}
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{render(data[virtualItem.index]!, virtualItem.index)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -61,6 +61,7 @@ const icons = {
keyboard: lucide.KeyboardIcon,
left_panel_hidden: lucide.PanelLeftOpenIcon,
left_panel_visible: lucide.PanelLeftCloseIcon,
lock: lucide.LockIcon,
magic_wand: lucide.Wand2Icon,
minus: lucide.MinusIcon,
minus_circle: lucide.MinusCircleIcon,

View File

@@ -84,7 +84,7 @@ export const JsonAttributeTree = ({
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
const labelEl = (
<span className={classNames(labelClassName, 'select-text group-hover:text-text-subtle')}>
<span className={classNames(labelClassName, 'cursor-text select-text group-hover:text-text-subtle')}>
{label}
</span>
);
@@ -115,7 +115,7 @@ export const JsonAttributeTree = ({
</button>
) : (
<>
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap select-text">
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap cursor-text select-text">
{attrKey}:
</span>
{labelEl}