VirtualList component

This commit is contained in:
Mats Bosson 2026-03-29 21:15:40 +07:00
parent 896819526e
commit f270ef64af
5 changed files with 140 additions and 0 deletions

View File

@ -0,0 +1,4 @@
import { VirtualListRoot } from "./virtual-list-root";
export const VirtualList = Object.assign(VirtualListRoot, {});
export type { VirtualListRootProps } from "./virtual-list.props";
export { VirtualListRootPropsSchema, VirtualListMeta } from "./virtual-list.props";

View File

@ -0,0 +1,56 @@
import { type JSX, createSignal, splitProps } from "solid-js";
import { For } from "solid-js";
import { createVirtualizer } from "../../primitives/create-virtualizer";
import type { VirtualListRootProps } from "./virtual-list.props";
/** Root scroll container for VirtualList. Renders only visible items via createVirtualizer. */
export function VirtualListRoot(props: VirtualListRootProps): JSX.Element {
const [local, rest] = splitProps(props, ["count", "estimateSize", "overscan", "horizontal", "children", "style"]);
const [scrollEl, setScrollEl] = createSignal<HTMLElement | null>(null);
const estimateFn = () => {
const e = local.estimateSize;
if (typeof e === "function") return e;
const size = typeof e === "number" ? e : 40;
return (_i: number) => size;
};
const virtualizer = createVirtualizer({
count: () => local.count,
getScrollElement: scrollEl,
estimateSize: (i) => estimateFn()(i),
overscan: local.overscan,
horizontal: local.horizontal,
});
const axis = () => (local.horizontal ? "width" : "height");
const posKey = () => (local.horizontal ? "left" : "top");
return (
<div
data-scope="virtual-list"
data-part="root"
ref={setScrollEl}
style={{ overflow: "auto", position: "relative", ...((local.style as object) ?? {}) }}
{...rest}
>
<div
data-part="inner"
style={{ [axis()]: `${virtualizer.totalSize()}px`, position: "relative" }}
>
<For each={virtualizer.virtualItems()}>
{(item) => (
<div
data-part="item"
data-index={item.index}
ref={(el) => virtualizer.measureElement(el)}
style={{ position: "absolute", [posKey()]: `${item.start}px`, width: local.horizontal ? undefined : "100%" }}
>
{local.children(item)}
</div>
)}
</For>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { z } from "zod/v4";
import type { JSX } from "solid-js";
import type { ComponentMeta } from "../../meta";
import type { VirtualItem } from "../../primitives/create-virtualizer";
export const VirtualListRootPropsSchema = z.object({ count: z.number(), estimateSize: z.number().optional(), overscan: z.number().optional(), horizontal: z.boolean().optional() });
export interface VirtualListRootProps extends Omit<z.infer<typeof VirtualListRootPropsSchema>, "count" | "estimateSize">, Omit<JSX.HTMLAttributes<HTMLDivElement>, "children"> {
count: number;
estimateSize?: number | ((index: number) => number);
children: (item: VirtualItem) => JSX.Element;
}
export const VirtualListMeta: ComponentMeta = {
name: "VirtualList",
description: "Virtualized scrollable list that only renders visible items, for large datasets",
parts: ["Root"] as const,
requiredParts: ["Root"] as const,
} as const;

View File

@ -0,0 +1,54 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { VirtualList } from "../../../src/components/virtual-list/index";
import { VirtualListRootPropsSchema, VirtualListMeta } from "../../../src/components/virtual-list/virtual-list.props";
describe("VirtualList", () => {
it("renders the inner spacer sized to totalSize via render prop", () => {
render(() => (
<VirtualList count={100} data-testid="vl" style={{ height: "200px" }}>
{(item) => <div data-testid={`item-${item.index}`}>Item {item.index}</div>}
</VirtualList>
));
const inner = screen.getByTestId("vl").querySelector("[data-part='inner']") as HTMLElement;
expect(inner).toBeTruthy();
expect(inner.style.height).toBe("4000px");
});
it("renders with data-scope and data-part attributes", () => {
render(() => (
<VirtualList count={10} data-testid="vlist" style={{ height: "300px" }}>
{(item) => <div>Row {item.index}</div>}
</VirtualList>
));
const el = screen.getByTestId("vlist");
expect(el.getAttribute("data-scope")).toBe("virtual-list");
expect(el.getAttribute("data-part")).toBe("root");
});
it("renders zero items when count is 0", () => {
render(() => (
<VirtualList count={0} data-testid="empty" style={{ height: "300px" }}>
{(item) => <div data-testid={`row-${item.index}`}>Row {item.index}</div>}
</VirtualList>
));
expect(screen.queryByTestId("row-0")).toBeNull();
});
it("schema validates required count field", () => {
expect(VirtualListRootPropsSchema.safeParse({ count: 50 }).success).toBe(true);
expect(VirtualListRootPropsSchema.safeParse({ count: "bad" }).success).toBe(false);
});
it("schema accepts optional fields", () => {
const result = VirtualListRootPropsSchema.safeParse({ count: 10, estimateSize: 50, overscan: 3, horizontal: true });
expect(result.success).toBe(true);
});
it("meta has required fields", () => {
expect(VirtualListMeta.name).toBe("VirtualList");
expect(VirtualListMeta.parts).toContain("Root");
expect(VirtualListMeta.requiredParts).toContain("Root");
expect(typeof VirtualListMeta.description).toBe("string");
});
});

View File

@ -1,5 +1,15 @@
import "@testing-library/jest-dom";
// jsdom does not implement ResizeObserver — polyfill it for tests that need it
if (typeof ResizeObserver === "undefined") {
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as unknown as Record<string, unknown>).ResizeObserver = ResizeObserver;
}
// jsdom does not implement PointerEvent — polyfill it for tests that need it
if (typeof PointerEvent === "undefined") {
class PointerEvent extends MouseEvent {