diff --git a/apps/demo/src/demos/virtualList/index.scss b/apps/demo/src/demos/virtualList/index.scss
new file mode 100644
index 0000000..5df16ef
--- /dev/null
+++ b/apps/demo/src/demos/virtualList/index.scss
@@ -0,0 +1,11 @@
+.container {
+ border: 1px solid black;
+ width: 502px;
+}
+.item {
+ width: 100px;
+ height: 100px;
+ background-color: white;
+ border: 1px solid black;
+ background-color: aliceblue;
+}
diff --git a/apps/demo/src/demos/virtualList/virtual-list.tsx b/apps/demo/src/demos/virtualList/virtual-list.tsx
new file mode 100644
index 0000000..50e59da
--- /dev/null
+++ b/apps/demo/src/demos/virtualList/virtual-list.tsx
@@ -0,0 +1,45 @@
+import { VirtualList } from "@solid-gadgets/components";
+import { RendererProps } from "@solid-gadgets/components/src/VirtualList/type";
+import { For } from "solid-js";
+
+import "./index.scss";
+
+const List = ({ list }: RendererProps<{ name: string; id: number }>) => {
+ return (
+
+ {item => (
+
+
+
{item.name}
+
{item.id}
+
+ )}
+
+ );
+};
+
+export default () => {
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ const data = new Array(1000).fill(0).map((_, idx) => ({
+ name: `leo${idx}`,
+ id: idx,
+ }));
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/apps/demo/src/routes.ts b/apps/demo/src/routes.ts
index c742475..b166a2a 100644
--- a/apps/demo/src/routes.ts
+++ b/apps/demo/src/routes.ts
@@ -8,4 +8,9 @@ export const routes: RouteConfig[] = [
path: "/resizable-layout",
component: lazy(async () => import("./demos/resizableLayout/resizable-layout")),
},
+ {
+ name: "Virtual List",
+ path: "/virtual-list",
+ component: lazy(async () => import("./demos/virtualList/virtual-list")),
+ },
];
diff --git a/apps/demo/src/web-demos/resizableLayout/index.ts b/apps/demo/src/web-demos/resizableLayout/index.ts
index 837f1cc..81130f3 100644
--- a/apps/demo/src/web-demos/resizableLayout/index.ts
+++ b/apps/demo/src/web-demos/resizableLayout/index.ts
@@ -6,4 +6,5 @@ const splitterDoms = document.querySelectorAll("so-splitter");
splitterDoms.forEach(splitter => {
splitter.setAttribute("style-code", styleCode);
});
+
registerSplitter();
diff --git a/apps/demo/src/web-demos/virtualList/index.html b/apps/demo/src/web-demos/virtualList/index.html
new file mode 100644
index 0000000..def5300
--- /dev/null
+++ b/apps/demo/src/web-demos/virtualList/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ Document
+
+
+
+
+
+
+
+
+
diff --git a/apps/demo/src/web-demos/virtualList/index.ts b/apps/demo/src/web-demos/virtualList/index.ts
new file mode 100644
index 0000000..f6786f4
--- /dev/null
+++ b/apps/demo/src/web-demos/virtualList/index.ts
@@ -0,0 +1,35 @@
+/* eslint-disable @typescript-eslint/no-magic-numbers */
+import { VirtualListElement, registerVirtualList } from "@solid-gadgets/web-components";
+
+registerVirtualList();
+
+const data = new Array(1000).fill(0).map((_, idx) => ({
+ name: `leo${idx}`,
+ id: idx,
+}));
+
+const virtualListDom =
+ document.querySelector>("so-virtual-list");
+
+virtualListDom?.setAttribute(
+ "item-style-code",
+ `.item {
+ width: 100px;
+ height: 100px;
+ background-color: white;
+ border: 1px solid black;
+ background-color: aliceblue;
+ }`
+);
+virtualListDom?.setData(data);
+virtualListDom?.setRenderer(item => {
+ const itemDom = document.createElement("div");
+ itemDom.innerHTML = `
+
+
+
${item.name}
+
${item.id}
+
`;
+
+ return itemDom;
+});
diff --git a/packages/components/src/VirtualList/index.scss b/packages/components/src/VirtualList/index.scss
new file mode 100644
index 0000000..52eb5a5
--- /dev/null
+++ b/packages/components/src/VirtualList/index.scss
@@ -0,0 +1,13 @@
+.virtual-list__wrapper {
+ overflow: auto;
+ position: relative;
+ .virtual-list__phantom {
+ position: relative;
+ }
+ .virtual-list__content {
+ position: absolute;
+ width: fit-content;
+ height: fit-content;
+ top: 0;
+ }
+}
diff --git a/packages/components/src/VirtualList/index.tsx b/packages/components/src/VirtualList/index.tsx
new file mode 100644
index 0000000..1d05c06
--- /dev/null
+++ b/packages/components/src/VirtualList/index.tsx
@@ -0,0 +1,72 @@
+/* eslint-disable @typescript-eslint/no-magic-numbers */
+import { createEffect, createMemo, createSignal } from "solid-js";
+
+import style from "./index.scss";
+import { VirtualListProps } from "./type";
+
+export const VirtualList = (props: VirtualListProps) => {
+ const { itemSize, width = "100%", height = "100%", buffer = 2, horizontal = false } = props;
+
+ const dataSource = createMemo(() => props.dataSource);
+ const Renderer = createMemo(() => props.renderer);
+
+ // eslint-disable-next-line @typescript-eslint/init-declarations
+ let containerRef: HTMLElement | undefined;
+
+ const phantomSize = createMemo(() => dataSource().length * itemSize);
+
+ const [shiftSize, setShiftSize] = createSignal(0);
+ const [startIdx, setStartIdx] = createSignal(0);
+ const [endIdx, setEndIdx] = createSignal(0);
+
+ const displayData = createMemo(() => dataSource().slice(startIdx(), endIdx()));
+
+ createEffect(() => {
+ if (!containerRef) return;
+ const containerSize = Number(getComputedStyle(containerRef).height.replace("px", ""));
+ const visibleNum = Math.ceil(containerSize / itemSize);
+
+ // init the displayed data
+ setEndIdx(visibleNum + buffer);
+
+ containerRef?.addEventListener("scroll", () => {
+ const scrollSize = horizontal ? containerRef?.scrollLeft : containerRef?.scrollTop;
+ if (scrollSize == null) return;
+
+ const start = Math.floor(scrollSize / itemSize);
+ const bufferedStart = start - buffer < 0 ? start : start - buffer;
+ const bufferedEnd =
+ start + visibleNum + buffer >= dataSource().length
+ ? dataSource().length
+ : start + visibleNum + buffer;
+
+ setStartIdx(bufferedStart);
+ setEndIdx(bufferedEnd);
+
+ setShiftSize(scrollSize - (scrollSize % itemSize) - (start - bufferedStart) * itemSize);
+ });
+ });
+
+ const wrapperStyle = createMemo(() => ({ width, height }));
+ const phantomStyle = createMemo(() =>
+ horizontal ? { width: `${phantomSize()}px` } : { height: `${phantomSize()}px` }
+ );
+ const contentStyle = createMemo(() =>
+ horizontal
+ ? { transform: `translateX(${shiftSize()}px)`, display: "flex" }
+ : { transform: `translateY(${shiftSize()}px)` }
+ );
+
+ return (
+
+
+
+ {/* make it compatible with web component */}
+ {Renderer()({ list: displayData })}
+
+
+ );
+};
+
+export * from "./type";
+export const virtualListStyle = style;
diff --git a/packages/components/src/VirtualList/type.ts b/packages/components/src/VirtualList/type.ts
new file mode 100644
index 0000000..97de750
--- /dev/null
+++ b/packages/components/src/VirtualList/type.ts
@@ -0,0 +1,23 @@
+import { Accessor } from "solid-js";
+import { JSX } from "solid-js/jsx-runtime";
+
+export interface RendererProps {
+ /** pass as a signal function to be reactive */
+ list: Accessor;
+}
+export interface VirtualListProps {
+ /** determine how to render each item of the visible items */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ renderer: (props: RendererProps) => JSX.Element;
+ dataSource: readonly unknown[];
+ /** scrolling direction, default as vertical */
+ horizontal?: boolean;
+ /** px of item height/width */
+ itemSize: number;
+ /** width of the virtual list container */
+ width?: string;
+ /** height of the virtual list container */
+ height?: string;
+ /** the extra buffered item at the top and bottom */
+ buffer?: number;
+}
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index 4121835..f27f5d5 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -1 +1,2 @@
export * from "./ResizableLayout";
+export * from "./VirtualList";
diff --git a/packages/web-components/src/VirtualList/index.tsx b/packages/web-components/src/VirtualList/index.tsx
new file mode 100644
index 0000000..a692689
--- /dev/null
+++ b/packages/web-components/src/VirtualList/index.tsx
@@ -0,0 +1,86 @@
+import {
+ RendererProps,
+ VirtualList,
+ VirtualListProps,
+ virtualListStyle,
+} from "@solid-gadgets/components";
+import { customElement } from "solid-element";
+import { For, createMemo } from "solid-js";
+import { createStore } from "solid-js/store";
+
+type Stringify = { [key in keyof T]: string };
+export type WebVirtualListProps = Stringify> & {
+ itemStyleCode?: string;
+ itemStyleLink?: string;
+};
+
+export type Renderer = (item: I) => HTMLElement;
+
+export interface VirtualListStore {
+ dataSource: unknown[];
+ renderer?: Renderer;
+}
+
+export type VirtualListElement = Element & {
+ setData: (data: I[]) => void;
+ setRenderer: (itemRenderer: Renderer) => void;
+};
+
+const defaultProps: WebVirtualListProps = {
+ itemSize: "50",
+ width: "100%",
+ height: "100%",
+ buffer: "2",
+ horizontal: "false",
+ itemStyleCode: "",
+ itemStyleLink: "",
+};
+
+const createRenderList =
+ (itemRenderer?: Renderer) =>
+ ({ list }: RendererProps) =>
+ {item => itemRenderer?.(item)};
+
+export const registerVirtualList = () => {
+ customElement("so-virtual-list", defaultProps, (props: WebVirtualListProps, { element }) => {
+ const { itemSize, width = "100%", height = "100%", buffer = "2", horizontal = "false" } = props;
+
+ const [store, setStore] = createStore({
+ dataSource: [],
+ });
+ element.setData = (data: unknown[]) => {
+ setStore("dataSource", data);
+ };
+ element.setRenderer = (itemRenderer: Renderer) => {
+ // avoid solid resolving the itemRenderer since it is a function
+ setStore("renderer", () => itemRenderer);
+ };
+
+ const processedProps = createMemo(() => ({
+ itemSize: Number(itemSize),
+ width,
+ height,
+ buffer: Number(buffer),
+ horizontal: horizontal === "true" ? true : false,
+ }));
+ const dataSource = createMemo(() => store.dataSource);
+ const List = createMemo(() => createRenderList(store.renderer));
+
+ return (
+ <>
+
+ {props.itemStyleLink?.trim() !== "" && }
+
+ >
+ );
+ });
+};
diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts
index 4121835..f27f5d5 100644
--- a/packages/web-components/src/index.ts
+++ b/packages/web-components/src/index.ts
@@ -1 +1,2 @@
export * from "./ResizableLayout";
+export * from "./VirtualList";