Virtual scrolling primitive

This commit is contained in:
Mats Bosson 2026-03-29 21:06:06 +07:00
parent ff6f1bb81f
commit de1c9a1cb8
3 changed files with 232 additions and 0 deletions

View File

@ -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<number>;
/** The scrollable container element. */
getScrollElement: Accessor<HTMLElement | null>;
/** 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<VirtualItem[]>;
/** Total scrollable size in pixels. */
totalSize: Accessor<number>;
/** Imperatively scroll to an item by index. */
scrollToIndex: (index: number, options?: { align?: "start" | "center" | "end" }) => void;
/** Current scroll offset. */
scrollOffset: Accessor<number>;
/** 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<number, number>();
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<number> = () => {
const count = options.count();
let total = 0;
for (let i = 0; i < count; i++) {
total += getSize(i);
}
return total;
};
const virtualItems: Accessor<VirtualItem[]> = () => {
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 };
}

View File

@ -10,3 +10,9 @@ export type {
ListNavigationState, ListNavigationState,
} from "./create-list-navigation"; } from "./create-list-navigation";
export { createRegisterId } from "./create-register-id"; export { createRegisterId } from "./create-register-id";
export { createVirtualizer } from "./create-virtualizer";
export type {
CreateVirtualizerOptions,
VirtualizerState,
VirtualItem,
} from "./create-virtualizer";

View File

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