Virtual scrolling primitive
This commit is contained in:
parent
ff6f1bb81f
commit
de1c9a1cb8
169
packages/core/src/primitives/create-virtualizer.ts
Normal file
169
packages/core/src/primitives/create-virtualizer.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
|||||||
57
packages/core/tests/primitives/create-virtualizer.test.ts
Normal file
57
packages/core/tests/primitives/create-virtualizer.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user