/** * Generic virtualized list component for ink-terminal. * * Wraps the useVirtualScroll hook into a declarative component API. * Renders only items in viewport + overscan, using spacer Boxes to maintain * scroll height. Works with any ScrollBox. * * Usage: * ```tsx * * item.id} * renderItem={(item, index) => } * /> * * ``` */ import React, { type RefObject, useMemo } from 'react' import type { ScrollBoxHandle } from '../components/ScrollBox.js' import type { DOMElement } from '../dom.js' import { useVirtualScroll, type VirtualScrollResult, } from '../hooks/use-virtual-scroll.js' import Box from './Box.js' type VirtualListProps = { /** Array of items to render. */ items: readonly T[] /** Ref to the parent ScrollBox handle. */ scrollRef: RefObject /** Terminal column count. Triggers height cache scaling on change. */ columns: number /** Extract a unique key from each item. Must be stable across renders. */ itemKey: (item: T, index: number) => string /** Render function for each visible item. */ renderItem: (item: T, index: number) => React.ReactNode /** * Optional ref for accessing the VirtualScrollResult imperatively. * Useful for scrollToIndex, getItemTop, etc. */ resultRef?: React.MutableRefObject } /** * A generic virtualized list that renders only visible items inside a ScrollBox. * * This component manages: * - Mounting only items in viewport + overscan (80 rows above/below) * - Height measurement and caching via Yoga layout * - Top/bottom spacers to maintain scroll position * - Slide cap (25 items/commit) for smooth fast scrolling * - Column-change scaling for terminal resize */ export function VirtualList({ items, scrollRef, columns, itemKey, renderItem, resultRef, }: VirtualListProps): React.ReactNode { // Build stable key array from items. useMemo so useVirtualScroll's // itemKeys identity only changes when items actually change. const itemKeys = useMemo( () => items.map((item, i) => itemKey(item, i)), [items, itemKey], ) const result = useVirtualScroll(scrollRef, itemKeys, columns) const { range, topSpacer, bottomSpacer, measureRef, spacerRef } = result // Expose result for imperative access. if (resultRef) { resultRef.current = result } const [startIdx, endIdx] = range // Render visible items with measurement refs. const renderedItems: React.ReactNode[] = [] for (let i = startIdx; i < endIdx; i++) { const item = items[i]! const key = itemKeys[i]! renderedItems.push( } flexDirection="column" > {renderItem(item, i)} , ) } return ( <> } height={Math.max(0, Math.round(topSpacer))} /> {renderedItems} ) }