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,
|
||||
} from "./create-list-navigation";
|
||||
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