/ src / components / VirtualGrid.tsx
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  }