105 lines
3.2 KiB
TypeScript
105 lines
3.2 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type Key, type ReactNode } from "react";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
|
|
interface VirtualizedGridProps<T> {
|
|
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 = <T,>({
|
|
items,
|
|
className,
|
|
minColumnWidth,
|
|
maxColumnWidth,
|
|
gap = 8,
|
|
overscan = 3,
|
|
itemAspectRatio = 0.86,
|
|
empty = null,
|
|
itemKey,
|
|
renderItem
|
|
}: VirtualizedGridProps<T>) => {
|
|
const scrollRef = useRef<HTMLDivElement | null>(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 <div className={className}>{empty}</div>;
|
|
}
|
|
|
|
return (
|
|
<div ref={scrollRef} className={className}>
|
|
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
const row = rows[virtualRow.index] ?? [];
|
|
const startIndex = virtualRow.index * columns;
|
|
return (
|
|
<div
|
|
key={virtualRow.key}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
display: "grid",
|
|
gap: `${gap}px`,
|
|
justifyContent: "start",
|
|
gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`,
|
|
transform: `translateY(${virtualRow.start}px)`
|
|
}}
|
|
>
|
|
{row.map((item, offset) => (
|
|
<div key={itemKey ? itemKey(item, startIndex + offset) : `${virtualRow.index}-${offset}`}>
|
|
{renderItem(item, startIndex + offset)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|