VirtualList component
This commit is contained in:
parent
896819526e
commit
f270ef64af
4
packages/core/src/components/virtual-list/index.ts
Normal file
4
packages/core/src/components/virtual-list/index.ts
Normal 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";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user