goodgrief/apps/admin/src/features/live/VirtualizedGrid.tsx

105 lines
3.2 KiB
TypeScript
Raw Normal View History

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>
);
};