'use client'; import * as React from 'react'; import { cn } from '@/lib/utils'; export interface VirtualListProps { /** Rows to render. Only the visible slice is mounted. */ data: readonly T[]; /** Pixel height of the scroll viewport. */ height: number; /** Pixel height of one row. Rows must all be this tall. */ itemHeight: number; /** Stable key per row — an index is not enough once the list reorders. */ itemKey: keyof T | ((item: T) => React.Key); children: (item: T, index: number) => React.ReactElement; className?: string; /** * Rows kept mounted above and below the viewport. A few rows of slack mean a * fast scroll reveals content rather than blank space. */ overscan?: number; /** Fires when the viewport scrolls. Use it to trigger infinite loading. */ onScroll?: React.UIEventHandler; 'aria-label'?: string; } /** * Windowed list: renders only the rows in view. * * Reach for it past a few hundred rows, where mounting everything makes * scrolling stutter. Below that, a plain `map` is simpler and behaves better * with find-in-page, which cannot see unmounted rows. * * Rows must be a fixed `itemHeight` — variable heights need measurement that * this component does not do. The full scroll height is reserved by a spacer, * so the scrollbar reflects the whole list even though the DOM holds a dozen * rows; the mounted slice is then offset into place with a transform. * * ```tsx * * {(row) =>
{row.name}
} *
* ``` */ function VirtualList({ data, height, itemHeight, itemKey, children, className, overscan = 3, onScroll, ...props }: VirtualListProps) { const [scrollTop, setScrollTop] = React.useState(0); const handleScroll = (event: React.UIEvent) => { setScrollTop(event.currentTarget.scrollTop); onScroll?.(event); }; const total = data.length; /* `lastVisible` is derived from the viewport's bottom edge rather than from a row count, so a scroll position part-way through a row still covers the row peeking in at the bottom. Both ends are clamped, so a shrinking list — or a scroll offset left over from a longer one — cannot ask for rows past the end, and overscan never pulls the start below zero. */ const firstVisible = Math.floor(scrollTop / itemHeight); const lastVisible = Math.floor((scrollTop + height - 1) / itemHeight); const startIndex = Math.max(0, firstVisible - overscan); const endIndex = Math.min(total, lastVisible + 1 + overscan); const keyOf = (item: T, index: number): React.Key => typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as React.Key) ?? index; const slice = []; for (let index = startIndex; index < endIndex; index += 1) { slice.push( {children(data[index], index)} ); } return (
{/* Reserves the full scroll height so the scrollbar matches the data. */}
{slice}
); } export { VirtualList };