diff --git a/packages/core/src/components/virtual-list/index.ts b/packages/core/src/components/virtual-list/index.ts new file mode 100644 index 0000000..9e9425b --- /dev/null +++ b/packages/core/src/components/virtual-list/index.ts @@ -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"; diff --git a/packages/core/src/components/virtual-list/virtual-list-root.tsx b/packages/core/src/components/virtual-list/virtual-list-root.tsx new file mode 100644 index 0000000..006d01f --- /dev/null +++ b/packages/core/src/components/virtual-list/virtual-list-root.tsx @@ -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(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 ( +
+
+ + {(item) => ( +
virtualizer.measureElement(el)} + style={{ position: "absolute", [posKey()]: `${item.start}px`, width: local.horizontal ? undefined : "100%" }} + > + {local.children(item)} +
+ )} +
+
+
+ ); +} diff --git a/packages/core/src/components/virtual-list/virtual-list.props.ts b/packages/core/src/components/virtual-list/virtual-list.props.ts new file mode 100644 index 0000000..8f1b832 --- /dev/null +++ b/packages/core/src/components/virtual-list/virtual-list.props.ts @@ -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, "count" | "estimateSize">, Omit, "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; diff --git a/packages/core/tests/components/virtual-list/virtual-list.test.tsx b/packages/core/tests/components/virtual-list/virtual-list.test.tsx new file mode 100644 index 0000000..540ff98 --- /dev/null +++ b/packages/core/tests/components/virtual-list/virtual-list.test.tsx @@ -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(() => ( + + {(item) =>
Item {item.index}
} +
+ )); + 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(() => ( + + {(item) =>
Row {item.index}
} +
+ )); + 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(() => ( + + {(item) =>
Row {item.index}
} +
+ )); + 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"); + }); +}); diff --git a/packages/core/tests/setup.ts b/packages/core/tests/setup.ts index ea5c7ed..19ce5c2 100644 --- a/packages/core/tests/setup.ts +++ b/packages/core/tests/setup.ts @@ -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).ResizeObserver = ResizeObserver; +} + // jsdom does not implement PointerEvent — polyfill it for tests that need it if (typeof PointerEvent === "undefined") { class PointerEvent extends MouseEvent {