VirtualGrid.tsx
1 import { 2 createMemo, 3 createSignal, 4 For, 5 onCleanup, 6 onMount, 7 Show, 8 createEffect, 9 type JSX, 10 } from "solid-js"; 11 import scroll from "../stores/scroll"; 12 13 export interface VirtualGridProps<T> { 14 items: T[]; 15 itemHeight: number; 16 itemWidth: number; 17 gap: number; 18 19 /** 20 * Optional key used to persist/restore scroll position (window.scrollY) 21 * across navigations (e.g. home -> detail -> home). 22 */ 23 scrollKey?: string; 24 25 children: (item: T) => JSX.Element; 26 fallback?: JSX.Element; 27 } 28 29 export default function VirtualGrid<T>(props: VirtualGridProps<T>) { 30 let containerRef: HTMLDivElement | undefined; 31 const [containerWidth, setContainerWidth] = createSignal(0); 32 const [windowHeight, setWindowHeight] = createSignal( 33 typeof window !== "undefined" ? window.innerHeight : 0, 34 ); 35 const [scrollY, setScrollY] = createSignal(0); 36 37 // Restore scroll position on mount (best-effort). We do it in onMount so the DOM exists. 38 onMount(() => { 39 const key = props.scrollKey; 40 if (key && typeof window !== "undefined") { 41 const saved = scroll.get(key, 0); 42 if (Number.isFinite(saved)) { 43 // Use rAF to avoid restoring too early while layout is still settling. 44 requestAnimationFrame(() => window.scrollTo(0, saved)); 45 } 46 } 47 48 const handleScroll = () => { 49 const y = window.scrollY; 50 setScrollY(y); 51 52 const k = props.scrollKey; 53 if (k) { 54 scroll.set(k, y); 55 } 56 }; 57 58 const handleResize = () => setWindowHeight(window.innerHeight); 59 60 window.addEventListener("resize", handleResize); 61 window.addEventListener("scroll", handleScroll); 62 63 handleScroll(); 64 handleResize(); 65 66 if (containerRef) { 67 const observer = new ResizeObserver((entries) => { 68 const entry = entries[0]; 69 if (entry) { 70 setContainerWidth(entry.contentRect.width); 71 } 72 }); 73 observer.observe(containerRef); 74 onCleanup(() => observer.disconnect()); 75 } 76 77 onCleanup(() => { 78 window.removeEventListener("resize", handleResize); 79 window.removeEventListener("scroll", handleScroll); 80 }); 81 }); 82 83 // If the dataset changes (filters/search), keep the saved scroll position sane. 84 createEffect(() => { 85 props.items.length; 86 const key = props.scrollKey; 87 if (!key) return; 88 89 // After list changes, clamp any saved scrollY to current max scroll. 90 queueMicrotask(() => { 91 const doc = document.documentElement; 92 const maxY = Math.max(0, doc.scrollHeight - window.innerHeight); 93 const y = Math.min(window.scrollY, maxY); 94 95 scroll.set(key, y); 96 if (y !== window.scrollY) window.scrollTo(0, y); 97 }); 98 }); 99 100 const columns = createMemo(() => { 101 const width = containerWidth(); 102 if (!width) return 1; 103 return Math.max( 104 1, 105 Math.floor((width + props.gap) / (props.itemWidth + props.gap)), 106 ); 107 }); 108 109 const rows = createMemo(() => { 110 const cols = columns(); 111 const list = props.items; 112 const result = []; 113 for (let i = 0; i < list.length; i += cols) { 114 result.push(list.slice(i, i + cols)); 115 } 116 return result; 117 }); 118 119 const virtualRows = createMemo(() => { 120 const y = scrollY(); 121 const offsetTop = containerRef?.offsetTop || 0; 122 const effectiveScrollY = Math.max(0, y - offsetTop); 123 124 const height = windowHeight(); 125 const allRows = rows(); 126 127 const startIndex = Math.floor(effectiveScrollY / props.itemHeight); 128 const endIndex = Math.min( 129 allRows.length, 130 Math.ceil((effectiveScrollY + height) / props.itemHeight), 131 ); 132 133 const overscan = 2; 134 const start = Math.max(0, startIndex - overscan); 135 const end = Math.min(allRows.length, endIndex + overscan); 136 137 return { 138 items: allRows.slice(start, end), 139 start, 140 end, 141 totalHeight: allRows.length * props.itemHeight, 142 }; 143 }); 144 145 return ( 146 <div 147 class="w-full relative" 148 ref={containerRef} 149 style={{ height: `${virtualRows().totalHeight}px` }} 150 > 151 <Show when={rows().length > 0} fallback={props.fallback}> 152 <div 153 class="absolute w-full top-0 left-0 flex flex-col" 154 style={{ 155 transform: `translateY(${ 156 virtualRows().start * props.itemHeight 157 }px)`, 158 }} 159 > 160 <For each={virtualRows().items}> 161 {(row) => ( 162 <div 163 class="shrink-0 flex text-left" 164 style={{ 165 height: `${props.itemHeight}px`, 166 gap: `${props.gap}px`, 167 "margin-bottom": `${props.gap}px`, 168 }} 169 > 170 <For each={row}>{(item) => props.children(item)}</For> 171 </div> 172 )} 173 </For> 174 </div> 175 </Show> 176 </div> 177 ); 178 }