From de1c9a1cb8fa153687eccf16ef0c29fed623ad99 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 21:06:06 +0700 Subject: [PATCH] Virtual scrolling primitive --- .../core/src/primitives/create-virtualizer.ts | 169 ++++++++++++++++++ packages/core/src/primitives/index.ts | 6 + .../primitives/create-virtualizer.test.ts | 57 ++++++ 3 files changed, 232 insertions(+) create mode 100644 packages/core/src/primitives/create-virtualizer.ts create mode 100644 packages/core/tests/primitives/create-virtualizer.test.ts diff --git a/packages/core/src/primitives/create-virtualizer.ts b/packages/core/src/primitives/create-virtualizer.ts new file mode 100644 index 0000000..16d13ee --- /dev/null +++ b/packages/core/src/primitives/create-virtualizer.ts @@ -0,0 +1,169 @@ +import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js"; + +/** A single virtual item with its position and index. */ +export interface VirtualItem { + index: number; + start: number; + end: number; + size: number; + key: string | number; +} + +/** Options for createVirtualizer. */ +export interface CreateVirtualizerOptions { + /** Total number of items. Reactive. */ + count: Accessor; + /** The scrollable container element. */ + getScrollElement: Accessor; + /** Estimated size in pixels for each item. Used for initial layout. */ + estimateSize: (index: number) => number; + /** Number of items to render outside the visible area. @default 5 */ + overscan?: number; + /** Whether scrolling is horizontal. @default false */ + horizontal?: boolean; + /** Custom key extractor. @default index */ + getItemKey?: (index: number) => string | number; +} + +/** State returned by createVirtualizer. */ +export interface VirtualizerState { + /** Virtual items currently in the visible range (plus overscan). */ + virtualItems: Accessor; + /** Total scrollable size in pixels. */ + totalSize: Accessor; + /** Imperatively scroll to an item by index. */ + scrollToIndex: (index: number, options?: { align?: "start" | "center" | "end" }) => void; + /** Current scroll offset. */ + scrollOffset: Accessor; + /** Measure a specific item's actual size after render. */ + measureElement: (element: HTMLElement | null) => void; +} + +/** + * Virtualizes a list of items, only rendering those visible in the scroll viewport. + * Supports fixed and variable row heights, horizontal/vertical, and overscan. + */ +export function createVirtualizer(options: CreateVirtualizerOptions): VirtualizerState { + const overscan = options.overscan ?? 5; + const horizontal = options.horizontal ?? false; + const getKey = options.getItemKey ?? ((i: number) => i); + + const [scrollOffset, setScrollOffset] = createSignal(0); + const [containerSize, setContainerSize] = createSignal(0); + const measurements = new Map(); + + function getSize(index: number): number { + return measurements.get(index) ?? options.estimateSize(index); + } + + function getItemOffset(index: number): number { + let offset = 0; + for (let i = 0; i < index; i++) { + offset += getSize(i); + } + return offset; + } + + const totalSize: Accessor = () => { + const count = options.count(); + let total = 0; + for (let i = 0; i < count; i++) { + total += getSize(i); + } + return total; + }; + + const virtualItems: Accessor = () => { + const count = options.count(); + if (count === 0) return []; + + const offset = scrollOffset(); + const size = containerSize(); + if (size === 0) return []; + + let startIndex = 0; + let accumulated = 0; + while (startIndex < count && accumulated + getSize(startIndex) <= offset) { + accumulated += getSize(startIndex); + startIndex++; + } + + let endIndex = startIndex; + let visibleAccumulated = 0; + while (endIndex < count && visibleAccumulated < size) { + visibleAccumulated += getSize(endIndex); + endIndex++; + } + + const overscanStart = Math.max(0, startIndex - overscan); + const overscanEnd = Math.min(count - 1, endIndex + overscan); + + const items: VirtualItem[] = []; + for (let i = overscanStart; i <= overscanEnd; i++) { + const itemSize = getSize(i); + const start = getItemOffset(i); + items.push({ + index: i, + start, + end: start + itemSize, + size: itemSize, + key: getKey(i), + }); + } + return items; + }; + + createEffect(() => { + const el = options.getScrollElement(); + if (!el) return; + + const sizeKey = horizontal ? "clientWidth" : "clientHeight"; + const scrollKey = horizontal ? "scrollLeft" : "scrollTop"; + + setContainerSize(el[sizeKey]); + setScrollOffset(el[scrollKey]); + + const onScroll = () => { + setScrollOffset(el[scrollKey]); + }; + + const resizeObserver = new ResizeObserver(() => { + setContainerSize(el[sizeKey]); + }); + + el.addEventListener("scroll", onScroll, { passive: true }); + resizeObserver.observe(el); + + onCleanup(() => { + el.removeEventListener("scroll", onScroll); + resizeObserver.disconnect(); + }); + }); + + function scrollToIndex(index: number, opts?: { align?: "start" | "center" | "end" }): void { + const el = options.getScrollElement(); + if (!el) return; + const itemOffset = getItemOffset(index); + const itemSize = getSize(index); + const viewSize = containerSize(); + const scrollKey = horizontal ? "scrollLeft" : "scrollTop"; + + let target = itemOffset; + if (opts?.align === "center") { + target = itemOffset - viewSize / 2 + itemSize / 2; + } else if (opts?.align === "end") { + target = itemOffset - viewSize + itemSize; + } + el[scrollKey] = Math.max(0, target); + } + + function measureElement(element: HTMLElement | null): void { + if (!element) return; + const index = Number(element.dataset.index); + if (Number.isNaN(index)) return; + const size = horizontal ? element.offsetWidth : element.offsetHeight; + measurements.set(index, size); + } + + return { virtualItems, totalSize, scrollToIndex, scrollOffset, measureElement }; +} diff --git a/packages/core/src/primitives/index.ts b/packages/core/src/primitives/index.ts index 48489e2..17bcda7 100644 --- a/packages/core/src/primitives/index.ts +++ b/packages/core/src/primitives/index.ts @@ -10,3 +10,9 @@ export type { ListNavigationState, } from "./create-list-navigation"; export { createRegisterId } from "./create-register-id"; +export { createVirtualizer } from "./create-virtualizer"; +export type { + CreateVirtualizerOptions, + VirtualizerState, + VirtualItem, +} from "./create-virtualizer"; diff --git a/packages/core/tests/primitives/create-virtualizer.test.ts b/packages/core/tests/primitives/create-virtualizer.test.ts new file mode 100644 index 0000000..0777eda --- /dev/null +++ b/packages/core/tests/primitives/create-virtualizer.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { createRoot } from "solid-js"; +import { createVirtualizer } from "../../src/primitives/create-virtualizer"; + +describe("createVirtualizer", () => { + it("calculates visible range for fixed-size items", () => { + createRoot((dispose) => { + const virtualizer = createVirtualizer({ + count: () => 1000, + getScrollElement: () => null, + estimateSize: () => 40, + }); + expect(virtualizer.totalSize()).toBe(40000); + dispose(); + }); + }); + + it("returns virtual items with correct offsets", () => { + createRoot((dispose) => { + const virtualizer = createVirtualizer({ + count: () => 100, + getScrollElement: () => null, + estimateSize: () => 50, + overscan: 0, + }); + const items = virtualizer.virtualItems(); + expect(items.length).toBeGreaterThanOrEqual(0); + dispose(); + }); + }); + + it("handles zero count", () => { + createRoot((dispose) => { + const virtualizer = createVirtualizer({ + count: () => 0, + getScrollElement: () => null, + estimateSize: () => 40, + }); + expect(virtualizer.totalSize()).toBe(0); + expect(virtualizer.virtualItems()).toEqual([]); + dispose(); + }); + }); + + it("supports horizontal orientation", () => { + createRoot((dispose) => { + const virtualizer = createVirtualizer({ + count: () => 50, + getScrollElement: () => null, + estimateSize: () => 100, + horizontal: true, + }); + expect(virtualizer.totalSize()).toBe(5000); + dispose(); + }); + }); +});