import { useEffect, useMemo, useRef, useState, type Key, type ReactNode } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; interface VirtualizedGridProps { items: T[]; className?: string; minColumnWidth: number; maxColumnWidth?: number; gap?: number; overscan?: number; itemAspectRatio?: number; empty?: ReactNode; itemKey?: (item: T, index: number) => Key; renderItem: (item: T, index: number) => ReactNode; } const fallbackWidth = 720; export const VirtualizedGrid = ({ items, className, minColumnWidth, maxColumnWidth, gap = 8, overscan = 3, itemAspectRatio = 0.86, empty = null, itemKey, renderItem }: VirtualizedGridProps) => { const scrollRef = useRef(null); const [width, setWidth] = useState(fallbackWidth); useEffect(() => { const element = scrollRef.current; if (!element || typeof ResizeObserver === "undefined") { return; } const measure = () => setWidth(Math.max(element.clientWidth, minColumnWidth)); measure(); const observer = new ResizeObserver(() => measure()); observer.observe(element); return () => observer.disconnect(); }, [minColumnWidth]); const columns = Math.max(1, Math.floor((width + gap) / (minColumnWidth + gap))); const rowCount = Math.ceil(items.length / columns); const computedCellWidth = Math.max(minColumnWidth, (width - gap * Math.max(columns - 1, 0)) / columns); const cellWidth = maxColumnWidth ? Math.min(computedCellWidth, maxColumnWidth) : computedCellWidth; const rowHeight = Math.max(1, cellWidth / itemAspectRatio); const rows = useMemo(() => { const grouped: T[][] = []; for (let index = 0; index < items.length; index += columns) { grouped.push(items.slice(index, index + columns)); } return grouped; }, [columns, items]); const virtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight + gap, overscan }); if (items.length === 0) { return
{empty}
; } return (
{virtualizer.getVirtualItems().map((virtualRow) => { const row = rows[virtualRow.index] ?? []; const startIndex = virtualRow.index * columns; return (
{row.map((item, offset) => (
{renderItem(item, startIndex + offset)}
))}
); })}
); };