import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'; import type { ReactElement, ReactNode, UIEvent } from 'react'; import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { IconButton } from './IconButton'; interface Props { data: T[]; render: (item: T, index: number) => ReactElement; header?: ReactNode; /** Make container focusable for keyboard navigation */ focusable?: boolean; /** Callback to expose the virtualizer for keyboard navigation */ onVirtualizerReady?: (virtualizer: Virtualizer) => void; } export function AutoScroller({ data, render, header, focusable = false, onVirtualizerReady, }: Props) { const containerRef = useRef(null); const [autoScroll, setAutoScroll] = useState(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 }); // Expose virtualizer to parent for keyboard navigation useLayoutEffect(() => { onVirtualizerReady?.(rowVirtualizer); }, [rowVirtualizer, onVirtualizerReady]); // Scroll to new items const handleScroll = useCallback( (e: UIEvent) => { 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; data.length; // Make linter happy. We want to refresh when length changes const el = containerRef.current; if (el == null) return; el.scrollTop = el.scrollHeight; }, [autoScroll, data.length]); return (
{!autoScroll && (
setAutoScroll((v) => !v)} />
)} {header ?? }
{rowVirtualizer.getVirtualItems().map((virtualItem) => { const item = data[virtualItem.index]; return ( item != null && (
{render(item, virtualItem.index)}
) ); })}
); }