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";