From 4e3925f1e509b29ee3c661af74b6ace8087603c3 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Sat, 28 Sep 2024 14:32:14 -0400 Subject: [PATCH 01/11] make row heights in tests more realistic --- packages/virtual/test/index.test.tsx | 12 ++++++------ packages/virtual/test/server.test.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index 67f0aa4c0..88dd54528 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -24,7 +24,7 @@ describe("VirtualList", () => { const dispose = render( () => ( - {item =>
} + {item =>
} ), root, @@ -42,7 +42,7 @@ describe("VirtualList", () => { const dispose = render( () => ( - {item =>
} + {item =>
} ), root, @@ -99,7 +99,7 @@ describe("VirtualList", () => { const dispose = render( () => ( - {item =>
} + {item =>
} ), root, @@ -128,7 +128,7 @@ describe("VirtualList", () => { overscanCount={2} class={SELECTOR_CLASS_NAME} > - {item =>
} + {item =>
} ), root, @@ -154,7 +154,7 @@ describe("VirtualList", () => { const dispose = render( () => ( - {item =>
} + {item =>
} ), root, @@ -184,7 +184,7 @@ describe("VirtualList", () => { overscanCount={0} class={SELECTOR_CLASS_NAME} > - {item =>
} + {item =>
} ), root, diff --git a/packages/virtual/test/server.test.tsx b/packages/virtual/test/server.test.tsx index 903be5a97..7ce893811 100644 --- a/packages/virtual/test/server.test.tsx +++ b/packages/virtual/test/server.test.tsx @@ -14,7 +14,7 @@ describe("VirtualList", () => { rowHeight={10} class="classString" > - {item =>
{item}
} + {item =>
{item}
} )); @@ -23,9 +23,9 @@ describe("VirtualList", () => { '
', '
', '
', - '
0
', - '
1
', - '
2
', + '
0
', + '
1
', + '
2
', "
", "
", "
", From 32de1399d45c4cadc4954c67a8b5c6bd6b8ef358 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Sat, 28 Sep 2024 15:00:04 -0400 Subject: [PATCH 02/11] fix snake vs camel casing in tests --- packages/virtual/test/index.test.tsx | 62 +++++++++++++++------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index 88dd54528..190efbfb0 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -10,17 +10,16 @@ const SCROLL_EVENT = new Event("scroll"); let root = document.createElement("div"); -function get_scroll_continer() { - const scroll_container = root.querySelector("." + SELECTOR_CLASS_NAME); - if (scroll_container == null) { +function getScrollContainer() { + const scrollContainer = root.querySelector("." + SELECTOR_CLASS_NAME); + if (scrollContainer === null) { throw "." + SELECTOR_CLASS_NAME + " was not found"; } - return scroll_container; + return scrollContainer; } describe("VirtualList", () => { test("renders a subset of the items", () => { - let root = document.createElement("div"); const dispose = render( () => ( @@ -38,7 +37,7 @@ describe("VirtualList", () => { dispose(); }); - test("scrolling renders the correct subset of the items", () => { + test("renders the correct subset of the items based on scrolling", () => { const dispose = render( () => ( @@ -47,17 +46,18 @@ describe("VirtualList", () => { ), root, ); - const scroll_container = get_scroll_continer(); - scroll_container.dispatchEvent(SCROLL_EVENT); + const scrollContainer = getScrollContainer(); + + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-0")).not.toBeNull(); expect(root.querySelector("#item-1")).not.toBeNull(); expect(root.querySelector("#item-2")).not.toBeNull(); expect(root.querySelector("#item-3")).toBeNull(); - scroll_container.scrollTop += 10; - scroll_container.dispatchEvent(SCROLL_EVENT); + scrollContainer.scrollTop += 10; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-0")).not.toBeNull(); expect(root.querySelector("#item-1")).not.toBeNull(); @@ -65,8 +65,8 @@ describe("VirtualList", () => { expect(root.querySelector("#item-3")).not.toBeNull(); expect(root.querySelector("#item-4")).toBeNull(); - scroll_container.scrollTop += 10; - scroll_container.dispatchEvent(SCROLL_EVENT); + scrollContainer.scrollTop += 10; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-0")).toBeNull(); expect(root.querySelector("#item-1")).not.toBeNull(); @@ -75,8 +75,8 @@ describe("VirtualList", () => { expect(root.querySelector("#item-4")).not.toBeNull(); expect(root.querySelector("#item-5")).toBeNull(); - scroll_container.scrollTop -= 10; - scroll_container.dispatchEvent(SCROLL_EVENT); + scrollContainer.scrollTop -= 10; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-0")).not.toBeNull(); expect(root.querySelector("#item-1")).not.toBeNull(); @@ -84,8 +84,8 @@ describe("VirtualList", () => { expect(root.querySelector("#item-3")).not.toBeNull(); expect(root.querySelector("#item-4")).toBeNull(); - scroll_container.scrollTop -= 10; - scroll_container.dispatchEvent(SCROLL_EVENT); + scrollContainer.scrollTop -= 10; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-0")).not.toBeNull(); expect(root.querySelector("#item-1")).not.toBeNull(); @@ -104,10 +104,11 @@ describe("VirtualList", () => { ), root, ); - const scroll_container = get_scroll_continer(); - scroll_container.scrollTop += 9_980; - scroll_container.dispatchEvent(SCROLL_EVENT); + const scrollContainer = getScrollContainer(); + + scrollContainer.scrollTop += 9_980; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-996")).toBeNull(); expect(root.querySelector("#item-997")).not.toBeNull(); @@ -118,7 +119,7 @@ describe("VirtualList", () => { dispose(); }); - test("renders `overScan` rows above and below the visible rendered items", () => { + test("renders `overscanCount` rows above and below the visible rendered items", () => { const dispose = render( () => ( { ), root, ); - const scroll_container = get_scroll_continer(); - scroll_container.scrollTop += 100; - scroll_container.dispatchEvent(SCROLL_EVENT); + const scrollContainer = getScrollContainer(); + + scrollContainer.scrollTop += 100; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-7")).toBeNull(); expect(root.querySelector("#item-8")).not.toBeNull(); @@ -159,10 +161,11 @@ describe("VirtualList", () => { ), root, ); - const scroll_container = get_scroll_continer(); - scroll_container.scrollTop += 100; - scroll_container.dispatchEvent(SCROLL_EVENT); + const scrollContainer = getScrollContainer(); + + scrollContainer.scrollTop += 100; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-8")).toBeNull(); expect(root.querySelector("#item-9")).not.toBeNull(); @@ -189,10 +192,11 @@ describe("VirtualList", () => { ), root, ); - const scroll_container = get_scroll_continer(); - scroll_container.scrollTop += 100; - scroll_container.dispatchEvent(SCROLL_EVENT); + const scrollContainer = getScrollContainer(); + + scrollContainer.scrollTop += 100; + scrollContainer.dispatchEvent(SCROLL_EVENT); expect(root.querySelector("#item-8")).toBeNull(); expect(root.querySelector("#item-9")).not.toBeNull(); From 0575a8fc7e03a2ea40beb0171a4b9c51803eab96 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Sat, 28 Sep 2024 15:02:03 -0400 Subject: [PATCH 03/11] tests and docs for fallback behavior when list is empty --- packages/virtual/README.md | 4 ++- packages/virtual/dev/index.tsx | 3 ++- packages/virtual/src/index.tsx | 3 ++- packages/virtual/test/index.test.tsx | 38 ++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/virtual/README.md b/packages/virtual/README.md index 23af11d6c..ca084f858 100644 --- a/packages/virtual/README.md +++ b/packages/virtual/README.md @@ -27,6 +27,8 @@ pnpm add @solid-primitives/virtual No items
} // the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling overscanCount={5} // the height of the root element of the virtualizedList itself @@ -48,7 +50,7 @@ Note that the component only handles vertical lists where the number of items is ## Demo -You can see the VirtualizedList in action in the following sandbox: https://primitives.solidjs.community/playground/virtual +You can see the VirtualList in action in the following sandbox: https://primitives.solidjs.community/playground/virtual ## Changelog diff --git a/packages/virtual/dev/index.tsx b/packages/virtual/dev/index.tsx index 1f4bab098..ee1e9b291 100644 --- a/packages/virtual/dev/index.tsx +++ b/packages/virtual/dev/index.tsx @@ -22,7 +22,7 @@ const App: Component = () => { { no items
} overscanCount={overscanCount()} rootHeight={rootHeight()} rowHeight={rowHeight()} diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index e81e14728..9e8184571 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -7,6 +7,7 @@ import type { Accessor, JSX } from "solid-js"; * @param children the flowComponent that will be used to transform the items into rows in the list * @param class the class applied to the root element of the virtualizedList * @param each the list of items + * @param fallback the optional fallback to display if the list of items to display is empty * @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling * @param rootHeight the height of the root element of the virtualizedList itself * @param rowHeight the height of individual rows in the virtualizedList @@ -14,9 +15,9 @@ import type { Accessor, JSX } from "solid-js"; */ export function VirtualList(props: { children: (item: T[number], index: Accessor) => U; - fallback?: JSX.Element; class?: string; each: T | undefined | null | false; + fallback?: JSX.Element; overscanCount?: number; rootHeight: number; rowHeight: number; diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index 190efbfb0..9c247c1a4 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -207,4 +207,42 @@ describe("VirtualList", () => { dispose(); }); + + test("renders when list is empty", () => { + const dispose = render( + () => ( + + {item =>
} + + ), + root, + ); + + expect(getScrollContainer()).not.toBeNull(); + + dispose(); + }); + + test("renders when list is empty with optional fallback", () => { + const dispose = render( + () => ( + } + rootHeight={20} + rowHeight={10} + class={SELECTOR_CLASS_NAME} + > + {item =>
} + + ), + root, + ); + + expect(getScrollContainer()).not.toBeNull(); + + expect(root.querySelector("#fallback")).not.toBeNull(); + + dispose(); + }); }); From 9c9bc4264ad71ad53ccd6c08c26acf1da7b168a1 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Mon, 30 Sep 2024 00:28:19 -0400 Subject: [PATCH 04/11] headless createVirtualList utility function --- packages/virtual/README.md | 71 +++++- packages/virtual/dev/index.tsx | 21 +- packages/virtual/src/index.tsx | 97 +++++--- packages/virtual/test/index.test.tsx | 341 ++++++++++++++++---------- packages/virtual/test/server.test.tsx | 10 +- 5 files changed, 361 insertions(+), 179 deletions(-) diff --git a/packages/virtual/README.md b/packages/virtual/README.md index ca084f858..cb30158b1 100644 --- a/packages/virtual/README.md +++ b/packages/virtual/README.md @@ -9,7 +9,8 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/virtual?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/virtual) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -A basic [virtualized list](https://www.patterns.dev/vanilla/virtual-lists/) component for improving performance when rendering very large lists +A headless `createVirtualList` utility function for [virtualized lists](https://www.patterns.dev/vanilla/virtual-lists/) and a basic, unstyled `VirtualList` component (which uses the utility). +Virtual lists are useful for improving performance when rendering very large lists. ## Installation @@ -23,6 +24,70 @@ pnpm add @solid-primitives/virtual ## How to use it +`createVirtualList` is a headless utility for constructing your own virtualized list components with maximum flexibility. + +```tsx +function MyComp(): JSX.Element { + const [rootElement, setRootElement] = createSignal() as Signal; + + const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + const rootHeight = 20; + const rowHeight = 10; + const overscanCount = 5; + + const { onScroll, containerHeight, viewerTop, visibleItems } = createVirtualList({ + // accessor for the element to use as the root of the virtualized list + rootElement, + // the list of items + items, + // the height of the root element of the virtualizedList + rootHeight, + // the height of individual rows in the virtualizedList + rowHeight, + // the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling + overscanCount, + }); + + return ( +
+
+
+ {/* only visibleItems() are ultimately rendered */} + + {item =>
{item}
} +
+
+
+
+ ); +} +``` + +`` is a basic, unstyled virtual list component you can drop into projects without modification. + ```tsx { // the flowComponent that will be used to transform the items into rows in the list @@ -45,7 +108,7 @@ pnpm add @solid-primitives/virtual ``` -The tests describe the component's exact behavior and how overscanCount handles the start/end of the list in more detail. +The tests describe the exact behavior and how overscanCount handles the start/end of the list in more detail. Note that the component only handles vertical lists where the number of items is known and the height of an individual item is fixed. ## Demo diff --git a/packages/virtual/dev/index.tsx b/packages/virtual/dev/index.tsx index ee1e9b291..36f865147 100644 --- a/packages/virtual/dev/index.tsx +++ b/packages/virtual/dev/index.tsx @@ -64,16 +64,17 @@ const App: Component = () => { View the devtools console for log of items being added and removed from the visible list
- no items
} - overscanCount={overscanCount()} - rootHeight={rootHeight()} - rowHeight={rowHeight()} - class="bg-white text-gray-800" - > - {item => } -
+
+ no items
} + overscanCount={overscanCount()} + rootHeight={rootHeight()} + rowHeight={rowHeight()} + > + {item => } + +
); }; diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 9e8184571..750230691 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -1,72 +1,109 @@ import { For, createSignal } from "solid-js"; -import type { Accessor, JSX } from "solid-js"; +import type { Accessor, JSX, Signal } from "solid-js"; /** - * A basic virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) component for improving performance when rendering very large lists + * A headless virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) utility for constructing your own virtualized list components with maximum flexibility. + * + * @param rootElement accessor for the element to use as the root of the virtualized list + * @param items the list of items + * @param rootHeight the height of the root element of the virtualizedList + * @param rowHeight the height of individual rows in the virtualizedList + * @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling + * @returns an object whose properties are used by the list's components + */ +export function createVirtualList({ + rootElement, + items, + rootHeight, + rowHeight, + overscanCount, +}: { + rootElement: Accessor; + items: T | undefined | null | false; + rootHeight: number; + rowHeight: number; + overscanCount?: number; +}): { + onScroll: VoidFunction; + containerHeight: () => number; + viewerTop: () => number; + visibleItems: () => readonly T[]; +} { + items = items || ([] as any as T); + overscanCount = overscanCount || 1; + + const [offset, setOffset] = createSignal(0); + + const getFirstIdx = () => Math.max(0, Math.floor(offset() / rowHeight) - overscanCount); + + const getLastIdx = () => + Math.min( + items.length, + Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount, + ); + + return { + onScroll: () => { + setOffset(rootElement().scrollTop); + }, + containerHeight: () => items.length * rowHeight, + viewerTop: () => getFirstIdx() * rowHeight, + visibleItems: () => items.slice(getFirstIdx(), getLastIdx()), + }; +} + +/** + * A basic, unstyled virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) component you can drop into projects without modification * * @param children the flowComponent that will be used to transform the items into rows in the list - * @param class the class applied to the root element of the virtualizedList * @param each the list of items * @param fallback the optional fallback to display if the list of items to display is empty * @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling * @param rootHeight the height of the root element of the virtualizedList itself * @param rowHeight the height of individual rows in the virtualizedList - * @return virtualized list component + * @returns virtualized list component */ export function VirtualList(props: { children: (item: T[number], index: Accessor) => U; - class?: string; each: T | undefined | null | false; fallback?: JSX.Element; overscanCount?: number; rootHeight: number; rowHeight: number; }): JSX.Element { - let rootElement!: HTMLDivElement; - - const [offset, setOffset] = createSignal(0); - const items = () => props.each || ([] as any as T); - - const getFirstIdx = () => - Math.max(0, Math.floor(offset() / props.rowHeight) - (props.overscanCount || 1)); + const [rootElement, setRootElement] = createSignal() as Signal; - const getLastIdx = () => - Math.min( - items().length, - Math.floor(offset() / props.rowHeight) + - Math.ceil(props.rootHeight / props.rowHeight) + - (props.overscanCount || 1), - ); + const { onScroll, containerHeight, viewerTop, visibleItems } = createVirtualList({ + rootElement, + items: props.each, + rootHeight: props.rootHeight, + rowHeight: props.rowHeight, + overscanCount: props.overscanCount, + }); return (
{ - setOffset(rootElement.scrollTop); - }} + onScroll={onScroll} >
- + {props.children}
diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index 9c247c1a4..c4da83be2 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -1,23 +1,178 @@ import { describe, test, expect } from "vitest"; -import { VirtualList } from "../src/index.jsx"; import { render } from "solid-js/web"; +import { createSignal } from "solid-js"; -const TEST_LIST = Array.from({ length: 1000 }, (_, i) => i); +import { createVirtualList, VirtualList } from "../src/index.jsx"; -const SELECTOR_CLASS_NAME = "scroll-container-selector"; +const TEST_LIST = Array.from({ length: 1000 }, (_, i) => i); const SCROLL_EVENT = new Event("scroll"); -let root = document.createElement("div"); +const ROOT = document.createElement("div"); function getScrollContainer() { - const scrollContainer = root.querySelector("." + SELECTOR_CLASS_NAME); + const scrollContainer = ROOT.querySelector("div"); if (scrollContainer === null) { - throw "." + SELECTOR_CLASS_NAME + " was not found"; + throw "scrollContainer not found"; } return scrollContainer; } +describe("createVirtualList", () => { + const [rootElement] = createSignal(ROOT); + + test("returns containerHeight representing the size of the list container element within the root", () => { + const { containerHeight } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + }); + + expect(containerHeight()).toEqual(10_000); + }); + + test("returns viewerTop representing the location of the list viewer element within the list container", () => { + const { viewerTop } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + }); + + expect(viewerTop()).toEqual(0); + }); + + test("returns visibleList representing the subset of items to render", () => { + const { visibleItems } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + }); + + expect(visibleItems()).toEqual([0, 1, 2]); + }); + + test("returns onScroll which sets viewerTop and visibleItems based on rootElement's scrolltop", () => { + const el = document.createElement("div"); + const [rootElement] = createSignal(el); + + const { onScroll, viewerTop, visibleItems } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + }); + + expect(visibleItems()).toEqual([0, 1, 2]); + expect(viewerTop()).toEqual(0); + + el.scrollTop += 10; + + // no change until onScroll is called + expect(visibleItems()).toEqual([0, 1, 2]); + expect(viewerTop()).toEqual(0); + + onScroll(); + + expect(visibleItems()).toEqual([0, 1, 2, 3]); + expect(viewerTop()).toEqual(0); + + el.scrollTop += 10; + onScroll(); + + expect(visibleItems()).toEqual([1, 2, 3, 4]); + expect(viewerTop()).toEqual(10); + + el.scrollTop -= 10; + onScroll(); + + expect(visibleItems()).toEqual([0, 1, 2, 3]); + expect(viewerTop()).toEqual(0); + + el.scrollTop -= 10; + onScroll(); + + expect(visibleItems()).toEqual([0, 1, 2]); + expect(viewerTop()).toEqual(0); + }); + + test("onScroll handles reaching the bottom of the list", () => { + const el = document.createElement("div"); + const [rootElement] = createSignal(el); + + const { onScroll, viewerTop, visibleItems } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + }); + + expect(visibleItems()).toEqual([0, 1, 2]); + expect(viewerTop()).toEqual(0); + + el.scrollTop += 9_980; + onScroll(); + + expect(visibleItems()).toEqual([997, 998, 999]); + expect(viewerTop()).toEqual(9_970); + }); + + test("visibleList takes `overscanCount` into account", () => { + const el = document.createElement("div"); + const [rootElement] = createSignal(el); + + const { visibleItems, onScroll } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + overscanCount: 2, + }); + + el.scrollTop += 100; + onScroll(); + + expect(visibleItems()).toEqual([8, 9, 10, 11, 12, 13]); + }); + + test("overscanCount defaults to 1 if undefined or zero", () => { + const { visibleItems: visibleItemsUndefined } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + }); + + expect(visibleItemsUndefined()).toEqual([0, 1, 2]); + + const { visibleItems: visibleItemsZero } = createVirtualList({ + rootElement, + items: TEST_LIST, + rootHeight: 20, + rowHeight: 10, + overscanCount: 0, + }); + + expect(visibleItemsZero()).toEqual([0, 1, 2]); + }); + + test("handles empty list", () => { + const { containerHeight, viewerTop, visibleItems } = createVirtualList({ + rootElement, + items: [], + rootHeight: 20, + rowHeight: 10, + overscanCount: 0, + }); + + expect(containerHeight()).toEqual(0); + expect(viewerTop()).toEqual(0); + expect(visibleItems()).toEqual([]); + }); +}); + describe("VirtualList", () => { test("renders a subset of the items", () => { const dispose = render( @@ -26,13 +181,13 @@ describe("VirtualList", () => { {item =>
} ), - root, + ROOT, ); - expect(root.querySelector("#item-0")).not.toBeNull(); - expect(root.querySelector("#item-1")).not.toBeNull(); - expect(root.querySelector("#item-2")).not.toBeNull(); - expect(root.querySelector("#item-3")).toBeNull(); + expect(ROOT.querySelector("#item-0")).not.toBeNull(); + expect(ROOT.querySelector("#item-1")).not.toBeNull(); + expect(ROOT.querySelector("#item-2")).not.toBeNull(); + expect(ROOT.querySelector("#item-3")).toBeNull(); dispose(); }); @@ -40,57 +195,57 @@ describe("VirtualList", () => { test("renders the correct subset of the items based on scrolling", () => { const dispose = render( () => ( - + {item =>
} ), - root, + ROOT, ); const scrollContainer = getScrollContainer(); scrollContainer.dispatchEvent(SCROLL_EVENT); - expect(root.querySelector("#item-0")).not.toBeNull(); - expect(root.querySelector("#item-1")).not.toBeNull(); - expect(root.querySelector("#item-2")).not.toBeNull(); - expect(root.querySelector("#item-3")).toBeNull(); + expect(ROOT.querySelector("#item-0")).not.toBeNull(); + expect(ROOT.querySelector("#item-1")).not.toBeNull(); + expect(ROOT.querySelector("#item-2")).not.toBeNull(); + expect(ROOT.querySelector("#item-3")).toBeNull(); scrollContainer.scrollTop += 10; scrollContainer.dispatchEvent(SCROLL_EVENT); - expect(root.querySelector("#item-0")).not.toBeNull(); - expect(root.querySelector("#item-1")).not.toBeNull(); - expect(root.querySelector("#item-2")).not.toBeNull(); - expect(root.querySelector("#item-3")).not.toBeNull(); - expect(root.querySelector("#item-4")).toBeNull(); + expect(ROOT.querySelector("#item-0")).not.toBeNull(); + expect(ROOT.querySelector("#item-1")).not.toBeNull(); + expect(ROOT.querySelector("#item-2")).not.toBeNull(); + expect(ROOT.querySelector("#item-3")).not.toBeNull(); + expect(ROOT.querySelector("#item-4")).toBeNull(); scrollContainer.scrollTop += 10; scrollContainer.dispatchEvent(SCROLL_EVENT); - expect(root.querySelector("#item-0")).toBeNull(); - expect(root.querySelector("#item-1")).not.toBeNull(); - expect(root.querySelector("#item-2")).not.toBeNull(); - expect(root.querySelector("#item-3")).not.toBeNull(); - expect(root.querySelector("#item-4")).not.toBeNull(); - expect(root.querySelector("#item-5")).toBeNull(); + expect(ROOT.querySelector("#item-0")).toBeNull(); + expect(ROOT.querySelector("#item-1")).not.toBeNull(); + expect(ROOT.querySelector("#item-2")).not.toBeNull(); + expect(ROOT.querySelector("#item-3")).not.toBeNull(); + expect(ROOT.querySelector("#item-4")).not.toBeNull(); + expect(ROOT.querySelector("#item-5")).toBeNull(); scrollContainer.scrollTop -= 10; scrollContainer.dispatchEvent(SCROLL_EVENT); - expect(root.querySelector("#item-0")).not.toBeNull(); - expect(root.querySelector("#item-1")).not.toBeNull(); - expect(root.querySelector("#item-2")).not.toBeNull(); - expect(root.querySelector("#item-3")).not.toBeNull(); - expect(root.querySelector("#item-4")).toBeNull(); + expect(ROOT.querySelector("#item-0")).not.toBeNull(); + expect(ROOT.querySelector("#item-1")).not.toBeNull(); + expect(ROOT.querySelector("#item-2")).not.toBeNull(); + expect(ROOT.querySelector("#item-3")).not.toBeNull(); + expect(ROOT.querySelector("#item-4")).toBeNull(); scrollContainer.scrollTop -= 10; scrollContainer.dispatchEvent(SCROLL_EVENT); - expect(root.querySelector("#item-0")).not.toBeNull(); - expect(root.querySelector("#item-1")).not.toBeNull(); - expect(root.querySelector("#item-2")).not.toBeNull(); - expect(root.querySelector("#item-3")).toBeNull(); + expect(ROOT.querySelector("#item-0")).not.toBeNull(); + expect(ROOT.querySelector("#item-1")).not.toBeNull(); + expect(ROOT.querySelector("#item-2")).not.toBeNull(); + expect(ROOT.querySelector("#item-3")).toBeNull(); dispose(); }); @@ -98,11 +253,11 @@ describe("VirtualList", () => { test("renders the correct subset of the items for the end of the list", () => { const dispose = render( () => ( - + {item =>
} ), - root, + ROOT, ); const scrollContainer = getScrollContainer(); @@ -110,11 +265,11 @@ describe("VirtualList", () => { scrollContainer.scrollTop += 9_980; scrollContainer.dispatchEvent(SCROLL_EVENT); - expect(root.querySelector("#item-996")).toBeNull(); - expect(root.querySelector("#item-997")).not.toBeNull(); - expect(root.querySelector("#item-998")).not.toBeNull(); - expect(root.querySelector("#item-999")).not.toBeNull(); - expect(root.querySelector("#item-1000")).toBeNull(); + expect(ROOT.querySelector("#item-996")).toBeNull(); + expect(ROOT.querySelector("#item-997")).not.toBeNull(); + expect(ROOT.querySelector("#item-998")).not.toBeNull(); + expect(ROOT.querySelector("#item-999")).not.toBeNull(); + expect(ROOT.querySelector("#item-1000")).toBeNull(); dispose(); }); @@ -122,75 +277,11 @@ describe("VirtualList", () => { test("renders `overscanCount` rows above and below the visible rendered items", () => { const dispose = render( () => ( - - {item =>
} - - ), - root, - ); - - const scrollContainer = getScrollContainer(); - - scrollContainer.scrollTop += 100; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(root.querySelector("#item-7")).toBeNull(); - expect(root.querySelector("#item-8")).not.toBeNull(); - expect(root.querySelector("#item-9")).not.toBeNull(); - expect(root.querySelector("#item-10")).not.toBeNull(); - expect(root.querySelector("#item-11")).not.toBeNull(); - expect(root.querySelector("#item-12")).not.toBeNull(); - expect(root.querySelector("#item-13")).not.toBeNull(); - expect(root.querySelector("#item-14")).toBeNull(); - - dispose(); - }); - - test("overscanCount defaults to 1 if undefined", () => { - const dispose = render( - () => ( - - {item =>
} - - ), - root, - ); - - const scrollContainer = getScrollContainer(); - - scrollContainer.scrollTop += 100; - scrollContainer.dispatchEvent(SCROLL_EVENT); - - expect(root.querySelector("#item-8")).toBeNull(); - expect(root.querySelector("#item-9")).not.toBeNull(); - expect(root.querySelector("#item-10")).not.toBeNull(); - expect(root.querySelector("#item-11")).not.toBeNull(); - expect(root.querySelector("#item-12")).not.toBeNull(); - expect(root.querySelector("#item-13")).toBeNull(); - - dispose(); - }); - - test("overscanCount defaults to 1 if set to zero", () => { - const dispose = render( - () => ( - + {item =>
} ), - root, + ROOT, ); const scrollContainer = getScrollContainer(); @@ -198,12 +289,14 @@ describe("VirtualList", () => { scrollContainer.scrollTop += 100; scrollContainer.dispatchEvent(SCROLL_EVENT); - expect(root.querySelector("#item-8")).toBeNull(); - expect(root.querySelector("#item-9")).not.toBeNull(); - expect(root.querySelector("#item-10")).not.toBeNull(); - expect(root.querySelector("#item-11")).not.toBeNull(); - expect(root.querySelector("#item-12")).not.toBeNull(); - expect(root.querySelector("#item-13")).toBeNull(); + expect(ROOT.querySelector("#item-7")).toBeNull(); + expect(ROOT.querySelector("#item-8")).not.toBeNull(); + expect(ROOT.querySelector("#item-9")).not.toBeNull(); + expect(ROOT.querySelector("#item-10")).not.toBeNull(); + expect(ROOT.querySelector("#item-11")).not.toBeNull(); + expect(ROOT.querySelector("#item-12")).not.toBeNull(); + expect(ROOT.querySelector("#item-13")).not.toBeNull(); + expect(ROOT.querySelector("#item-14")).toBeNull(); dispose(); }); @@ -211,11 +304,11 @@ describe("VirtualList", () => { test("renders when list is empty", () => { const dispose = render( () => ( - + {item =>
} ), - root, + ROOT, ); expect(getScrollContainer()).not.toBeNull(); @@ -226,22 +319,16 @@ describe("VirtualList", () => { test("renders when list is empty with optional fallback", () => { const dispose = render( () => ( - } - rootHeight={20} - rowHeight={10} - class={SELECTOR_CLASS_NAME} - > + } rootHeight={20} rowHeight={10}> {item =>
} ), - root, + ROOT, ); expect(getScrollContainer()).not.toBeNull(); - expect(root.querySelector("#fallback")).not.toBeNull(); + expect(ROOT.querySelector("#fallback")).not.toBeNull(); dispose(); }); diff --git a/packages/virtual/test/server.test.tsx b/packages/virtual/test/server.test.tsx index 7ce893811..b70da7ff0 100644 --- a/packages/virtual/test/server.test.tsx +++ b/packages/virtual/test/server.test.tsx @@ -7,20 +7,14 @@ const TEST_LIST = Array.from({ length: 1000 }, (_, i) => i); describe("VirtualList", () => { test("doesn't break in SSR", () => { const virtualListStr = renderToString(() => ( - + {item =>
{item}
}
)); expect(virtualListStr).toEqual( [ - '
', + '
', '
', '
', '
0
', From e1ebe61f68f322b560bdd119110ac73d6ae3500f Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Sun, 6 Oct 2024 22:11:48 -0400 Subject: [PATCH 05/11] Add changeset --- .changeset/unlucky-cars-yell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/unlucky-cars-yell.md diff --git a/.changeset/unlucky-cars-yell.md b/.changeset/unlucky-cars-yell.md new file mode 100644 index 000000000..6616d74b1 --- /dev/null +++ b/.changeset/unlucky-cars-yell.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/virtual": minor +--- + +Add a headless virtual list primitive: `createVirtualList` in #702 From 83bfbeed82b33cfe05811915725039c53e8d7982 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Sun, 6 Oct 2024 22:10:11 -0400 Subject: [PATCH 06/11] address PR feedback --- packages/virtual/README.md | 20 +++---- packages/virtual/package.json | 4 ++ packages/virtual/src/index.tsx | 90 ++++++++++++++++------------ packages/virtual/test/index.test.tsx | 50 ++++++---------- pnpm-lock.yaml | 3 + 5 files changed, 87 insertions(+), 80 deletions(-) diff --git a/packages/virtual/README.md b/packages/virtual/README.md index cb30158b1..c65b494cb 100644 --- a/packages/virtual/README.md +++ b/packages/virtual/README.md @@ -24,34 +24,30 @@ pnpm add @solid-primitives/virtual ## How to use it +### `createVirtualList` + `createVirtualList` is a headless utility for constructing your own virtualized list components with maximum flexibility. ```tsx function MyComp(): JSX.Element { - const [rootElement, setRootElement] = createSignal() as Signal; - const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const rootHeight = 20; const rowHeight = 10; const overscanCount = 5; - const { onScroll, containerHeight, viewerTop, visibleItems } = createVirtualList({ - // accessor for the element to use as the root of the virtualized list - rootElement, - // the list of items + const [{ containerHeight, viewerTop, visibleItems }, onScroll] = createVirtualList({ + // the list of items - can be a signal items, - // the height of the root element of the virtualizedList + // the height of the root element of the virtualizedList - can be a signal rootHeight, - // the height of individual rows in the virtualizedList + // the height of individual rows in the virtualizedList - can be a signal rowHeight, - // the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling + // the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling - can be a signal overscanCount, }); return (
` + `` is a basic, unstyled virtual list component you can drop into projects without modification. ```tsx diff --git a/packages/virtual/package.json b/packages/virtual/package.json index bca03da44..a2bbe4842 100644 --- a/packages/virtual/package.json +++ b/packages/virtual/package.json @@ -17,6 +17,7 @@ "name": "virtual", "stage": 0, "list": [ + "createVirutalList", "VirtualList" ], "category": "UI Patterns" @@ -54,6 +55,9 @@ "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, + "dependencies": { + "@solid-primitives/utils": "workspace:^" + }, "peerDependencies": { "solid-js": "^1.6.12" } diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 750230691..18922e46c 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -1,36 +1,48 @@ import { For, createSignal } from "solid-js"; -import type { Accessor, JSX, Signal } from "solid-js"; +import type { Accessor, JSX } from "solid-js"; +import type { DOMElement } from "solid-js/jsx-runtime"; +import { access } from "@solid-primitives/utils"; +import type { MaybeAccessor } from "@solid-primitives/utils"; + +type VirtualListConfig = { + items: MaybeAccessor; + rootHeight: MaybeAccessor; + rowHeight: MaybeAccessor; + overscanCount?: MaybeAccessor; +}; + +type VirtualListReturn = [ + { + containerHeight: Accessor; + viewerTop: Accessor; + visibleItems: Accessor; + }, + onScroll: ( + e: Event & { + target: DOMElement; + }, + ) => void, +]; /** * A headless virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) utility for constructing your own virtualized list components with maximum flexibility. * - * @param rootElement accessor for the element to use as the root of the virtualized list * @param items the list of items * @param rootHeight the height of the root element of the virtualizedList * @param rowHeight the height of individual rows in the virtualizedList * @param overscanCount the number of elements to render both before and after the visible section of the list, so passing 5 will render 5 items before the list, and 5 items after. Defaults to 1, cannot be set to zero. This is necessary to hide the blank space around list items when scrolling - * @returns an object whose properties are used by the list's components + * @returns {VirtualListReturn} to use in the list's jsx */ export function createVirtualList({ - rootElement, items, rootHeight, rowHeight, overscanCount, -}: { - rootElement: Accessor; - items: T | undefined | null | false; - rootHeight: number; - rowHeight: number; - overscanCount?: number; -}): { - onScroll: VoidFunction; - containerHeight: () => number; - viewerTop: () => number; - visibleItems: () => readonly T[]; -} { - items = items || ([] as any as T); - overscanCount = overscanCount || 1; +}: VirtualListConfig): VirtualListReturn { + items = access(items) || ([] as any as T); + rootHeight = access(rootHeight); + rowHeight = access(rowHeight); + overscanCount = access(overscanCount) || 1; const [offset, setOffset] = createSignal(0); @@ -42,16 +54,27 @@ export function createVirtualList({ Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount, ); - return { - onScroll: () => { - setOffset(rootElement().scrollTop); + return [ + { + containerHeight: () => items.length * rowHeight, + viewerTop: () => getFirstIdx() * rowHeight, + visibleItems: () => items.slice(getFirstIdx(), getLastIdx()), }, - containerHeight: () => items.length * rowHeight, - viewerTop: () => getFirstIdx() * rowHeight, - visibleItems: () => items.slice(getFirstIdx(), getLastIdx()), - }; + e => { + setOffset(e.target.scrollTop); + }, + ]; } +type VirtualListProps = { + children: (item: T[number], index: Accessor) => U; + each: T | undefined | null | false; + fallback?: JSX.Element; + overscanCount?: number; + rootHeight: number; + rowHeight: number; +}; + /** * A basic, unstyled virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) component you can drop into projects without modification * @@ -63,18 +86,10 @@ export function createVirtualList({ * @param rowHeight the height of individual rows in the virtualizedList * @returns virtualized list component */ -export function VirtualList(props: { - children: (item: T[number], index: Accessor) => U; - each: T | undefined | null | false; - fallback?: JSX.Element; - overscanCount?: number; - rootHeight: number; - rowHeight: number; -}): JSX.Element { - const [rootElement, setRootElement] = createSignal() as Signal; - - const { onScroll, containerHeight, viewerTop, visibleItems } = createVirtualList({ - rootElement, +export function VirtualList( + props: VirtualListProps, +): JSX.Element { + const [{ containerHeight, viewerTop, visibleItems }, onScroll] = createVirtualList({ items: props.each, rootHeight: props.rootHeight, rowHeight: props.rowHeight, @@ -83,7 +98,6 @@ export function VirtualList(pro return (
i); +const ROOT = document.createElement("div"); + const SCROLL_EVENT = new Event("scroll"); -const ROOT = document.createElement("div"); +const TARGETED_SCROLL_EVENT = (el: DOMElement) => ({ ...SCROLL_EVENT, target: el }); function getScrollContainer() { const scrollContainer = ROOT.querySelector("div"); @@ -19,11 +21,8 @@ function getScrollContainer() { } describe("createVirtualList", () => { - const [rootElement] = createSignal(ROOT); - test("returns containerHeight representing the size of the list container element within the root", () => { - const { containerHeight } = createVirtualList({ - rootElement, + const [{ containerHeight }] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -33,8 +32,7 @@ describe("createVirtualList", () => { }); test("returns viewerTop representing the location of the list viewer element within the list container", () => { - const { viewerTop } = createVirtualList({ - rootElement, + const [{ viewerTop }] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -44,8 +42,7 @@ describe("createVirtualList", () => { }); test("returns visibleList representing the subset of items to render", () => { - const { visibleItems } = createVirtualList({ - rootElement, + const [{ visibleItems }] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -56,10 +53,8 @@ describe("createVirtualList", () => { test("returns onScroll which sets viewerTop and visibleItems based on rootElement's scrolltop", () => { const el = document.createElement("div"); - const [rootElement] = createSignal(el); - const { onScroll, viewerTop, visibleItems } = createVirtualList({ - rootElement, + const [{ viewerTop, visibleItems }, onScroll] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -74,25 +69,25 @@ describe("createVirtualList", () => { expect(visibleItems()).toEqual([0, 1, 2]); expect(viewerTop()).toEqual(0); - onScroll(); + onScroll(TARGETED_SCROLL_EVENT(el)); expect(visibleItems()).toEqual([0, 1, 2, 3]); expect(viewerTop()).toEqual(0); el.scrollTop += 10; - onScroll(); + onScroll(TARGETED_SCROLL_EVENT(el)); expect(visibleItems()).toEqual([1, 2, 3, 4]); expect(viewerTop()).toEqual(10); el.scrollTop -= 10; - onScroll(); + onScroll(TARGETED_SCROLL_EVENT(el)); expect(visibleItems()).toEqual([0, 1, 2, 3]); expect(viewerTop()).toEqual(0); el.scrollTop -= 10; - onScroll(); + onScroll(TARGETED_SCROLL_EVENT(el)); expect(visibleItems()).toEqual([0, 1, 2]); expect(viewerTop()).toEqual(0); @@ -100,10 +95,8 @@ describe("createVirtualList", () => { test("onScroll handles reaching the bottom of the list", () => { const el = document.createElement("div"); - const [rootElement] = createSignal(el); - const { onScroll, viewerTop, visibleItems } = createVirtualList({ - rootElement, + const [{ viewerTop, visibleItems }, onScroll] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -113,7 +106,7 @@ describe("createVirtualList", () => { expect(viewerTop()).toEqual(0); el.scrollTop += 9_980; - onScroll(); + onScroll(TARGETED_SCROLL_EVENT(el)); expect(visibleItems()).toEqual([997, 998, 999]); expect(viewerTop()).toEqual(9_970); @@ -121,10 +114,8 @@ describe("createVirtualList", () => { test("visibleList takes `overscanCount` into account", () => { const el = document.createElement("div"); - const [rootElement] = createSignal(el); - const { visibleItems, onScroll } = createVirtualList({ - rootElement, + const [{ visibleItems }, onScroll] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -132,14 +123,13 @@ describe("createVirtualList", () => { }); el.scrollTop += 100; - onScroll(); + onScroll(TARGETED_SCROLL_EVENT(el)); expect(visibleItems()).toEqual([8, 9, 10, 11, 12, 13]); }); test("overscanCount defaults to 1 if undefined or zero", () => { - const { visibleItems: visibleItemsUndefined } = createVirtualList({ - rootElement, + const [{ visibleItems: visibleItemsUndefined }] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -147,8 +137,7 @@ describe("createVirtualList", () => { expect(visibleItemsUndefined()).toEqual([0, 1, 2]); - const { visibleItems: visibleItemsZero } = createVirtualList({ - rootElement, + const [{ visibleItems: visibleItemsZero }] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -159,8 +148,7 @@ describe("createVirtualList", () => { }); test("handles empty list", () => { - const { containerHeight, viewerTop, visibleItems } = createVirtualList({ - rootElement, + const [{ containerHeight, viewerTop, visibleItems }] = createVirtualList({ items: [], rootHeight: 20, rowHeight: 10, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1994beda5..8c5165ac0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,6 +956,9 @@ importers: packages/virtual: dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils solid-js: specifier: ^1.6.12 version: 1.8.20 From 7b5a3b52496578ad4bd10e6da232eb23d38f4226 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Tue, 8 Oct 2024 21:24:41 -0400 Subject: [PATCH 07/11] fix types --- packages/virtual/src/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 18922e46c..aac9bdcc4 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -15,7 +15,7 @@ type VirtualListReturn = [ { containerHeight: Accessor; viewerTop: Accessor; - visibleItems: Accessor; + visibleItems: Accessor; }, onScroll: ( e: Event & { @@ -58,7 +58,7 @@ export function createVirtualList({ { containerHeight: () => items.length * rowHeight, viewerTop: () => getFirstIdx() * rowHeight, - visibleItems: () => items.slice(getFirstIdx(), getLastIdx()), + visibleItems: () => items.slice(getFirstIdx(), getLastIdx()) as unknown as T, }, e => { setOffset(e.target.scrollTop); @@ -117,7 +117,7 @@ export function VirtualList( top: `${viewerTop()}px`, }} > - + {props.children}
From f63753adb883f7082ae7a163f6ad8b922285b6d8 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Wed, 16 Oct 2024 23:10:55 -0400 Subject: [PATCH 08/11] add links in readme --- packages/virtual/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/virtual/README.md b/packages/virtual/README.md index c65b494cb..80f3a4c40 100644 --- a/packages/virtual/README.md +++ b/packages/virtual/README.md @@ -9,8 +9,8 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/virtual?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/virtual) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -A headless `createVirtualList` utility function for [virtualized lists](https://www.patterns.dev/vanilla/virtual-lists/) and a basic, unstyled `VirtualList` component (which uses the utility). -Virtual lists are useful for improving performance when rendering very large lists. +- [`createVirtualList`](#createvirtuallist) - A headless utility function for [virtualized lists](https://www.patterns.dev/vanilla/virtual-lists/) +- [`VirtualList`](#virtuallist) - a basic, unstyled component based on `createVirtualList` ## Installation From ee1d42a3eb3593145e38e6cff11531e79387f406 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Sun, 27 Oct 2024 21:20:25 -0400 Subject: [PATCH 09/11] address PR feedback --- packages/virtual/src/index.tsx | 28 ++++++------ packages/virtual/test/index.test.tsx | 68 ++++++++++++++-------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index aac9bdcc4..a5d8886ac 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -12,11 +12,11 @@ type VirtualListConfig = { }; type VirtualListReturn = [ - { - containerHeight: Accessor; - viewerTop: Accessor; - visibleItems: Accessor; - }, + Accessor<{ + containerHeight: number; + viewerTop: number; + visibleItems: T; + }>, onScroll: ( e: Event & { target: DOMElement; @@ -55,11 +55,11 @@ export function createVirtualList({ ); return [ - { - containerHeight: () => items.length * rowHeight, - viewerTop: () => getFirstIdx() * rowHeight, - visibleItems: () => items.slice(getFirstIdx(), getLastIdx()) as unknown as T, - }, + () => ({ + containerHeight: items.length * rowHeight, + viewerTop: getFirstIdx() * rowHeight, + visibleItems: items.slice(getFirstIdx(), getLastIdx()) as unknown as T, + }), e => { setOffset(e.target.scrollTop); }, @@ -89,7 +89,7 @@ type VirtualListProps = { export function VirtualList( props: VirtualListProps, ): JSX.Element { - const [{ containerHeight, viewerTop, visibleItems }, onScroll] = createVirtualList({ + const [virtual, onScroll] = createVirtualList({ items: props.each, rootHeight: props.rootHeight, rowHeight: props.rowHeight, @@ -108,16 +108,16 @@ export function VirtualList( style={{ position: "relative", width: "100%", - height: `${containerHeight()}px`, + height: `${virtual().containerHeight}px`, }} >
- + {props.children}
diff --git a/packages/virtual/test/index.test.tsx b/packages/virtual/test/index.test.tsx index 943210dc7..f30abccb0 100644 --- a/packages/virtual/test/index.test.tsx +++ b/packages/virtual/test/index.test.tsx @@ -22,100 +22,100 @@ function getScrollContainer() { describe("createVirtualList", () => { test("returns containerHeight representing the size of the list container element within the root", () => { - const [{ containerHeight }] = createVirtualList({ + const [virtual] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, }); - expect(containerHeight()).toEqual(10_000); + expect(virtual().containerHeight).toEqual(10_000); }); test("returns viewerTop representing the location of the list viewer element within the list container", () => { - const [{ viewerTop }] = createVirtualList({ + const [virtual] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, }); - expect(viewerTop()).toEqual(0); + expect(virtual().viewerTop).toEqual(0); }); test("returns visibleList representing the subset of items to render", () => { - const [{ visibleItems }] = createVirtualList({ + const [virtual] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, }); - expect(visibleItems()).toEqual([0, 1, 2]); + expect(virtual().visibleItems).toEqual([0, 1, 2]); }); test("returns onScroll which sets viewerTop and visibleItems based on rootElement's scrolltop", () => { const el = document.createElement("div"); - const [{ viewerTop, visibleItems }, onScroll] = createVirtualList({ + const [virtual, onScroll] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, }); - expect(visibleItems()).toEqual([0, 1, 2]); - expect(viewerTop()).toEqual(0); + expect(virtual().visibleItems).toEqual([0, 1, 2]); + expect(virtual().viewerTop).toEqual(0); el.scrollTop += 10; // no change until onScroll is called - expect(visibleItems()).toEqual([0, 1, 2]); - expect(viewerTop()).toEqual(0); + expect(virtual().visibleItems).toEqual([0, 1, 2]); + expect(virtual().viewerTop).toEqual(0); onScroll(TARGETED_SCROLL_EVENT(el)); - expect(visibleItems()).toEqual([0, 1, 2, 3]); - expect(viewerTop()).toEqual(0); + expect(virtual().visibleItems).toEqual([0, 1, 2, 3]); + expect(virtual().viewerTop).toEqual(0); el.scrollTop += 10; onScroll(TARGETED_SCROLL_EVENT(el)); - expect(visibleItems()).toEqual([1, 2, 3, 4]); - expect(viewerTop()).toEqual(10); + expect(virtual().visibleItems).toEqual([1, 2, 3, 4]); + expect(virtual().viewerTop).toEqual(10); el.scrollTop -= 10; onScroll(TARGETED_SCROLL_EVENT(el)); - expect(visibleItems()).toEqual([0, 1, 2, 3]); - expect(viewerTop()).toEqual(0); + expect(virtual().visibleItems).toEqual([0, 1, 2, 3]); + expect(virtual().viewerTop).toEqual(0); el.scrollTop -= 10; onScroll(TARGETED_SCROLL_EVENT(el)); - expect(visibleItems()).toEqual([0, 1, 2]); - expect(viewerTop()).toEqual(0); + expect(virtual().visibleItems).toEqual([0, 1, 2]); + expect(virtual().viewerTop).toEqual(0); }); test("onScroll handles reaching the bottom of the list", () => { const el = document.createElement("div"); - const [{ viewerTop, visibleItems }, onScroll] = createVirtualList({ + const [virtual, onScroll] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, }); - expect(visibleItems()).toEqual([0, 1, 2]); - expect(viewerTop()).toEqual(0); + expect(virtual().visibleItems).toEqual([0, 1, 2]); + expect(virtual().viewerTop).toEqual(0); el.scrollTop += 9_980; onScroll(TARGETED_SCROLL_EVENT(el)); - expect(visibleItems()).toEqual([997, 998, 999]); - expect(viewerTop()).toEqual(9_970); + expect(virtual().visibleItems).toEqual([997, 998, 999]); + expect(virtual().viewerTop).toEqual(9_970); }); test("visibleList takes `overscanCount` into account", () => { const el = document.createElement("div"); - const [{ visibleItems }, onScroll] = createVirtualList({ + const [virtual, onScroll] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, @@ -125,39 +125,39 @@ describe("createVirtualList", () => { el.scrollTop += 100; onScroll(TARGETED_SCROLL_EVENT(el)); - expect(visibleItems()).toEqual([8, 9, 10, 11, 12, 13]); + expect(virtual().visibleItems).toEqual([8, 9, 10, 11, 12, 13]); }); test("overscanCount defaults to 1 if undefined or zero", () => { - const [{ visibleItems: visibleItemsUndefined }] = createVirtualList({ + const [virtualUndefined] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, }); - expect(visibleItemsUndefined()).toEqual([0, 1, 2]); + expect(virtualUndefined().visibleItems).toEqual([0, 1, 2]); - const [{ visibleItems: visibleItemsZero }] = createVirtualList({ + const [virtualZero] = createVirtualList({ items: TEST_LIST, rootHeight: 20, rowHeight: 10, overscanCount: 0, }); - expect(visibleItemsZero()).toEqual([0, 1, 2]); + expect(virtualZero().visibleItems).toEqual([0, 1, 2]); }); test("handles empty list", () => { - const [{ containerHeight, viewerTop, visibleItems }] = createVirtualList({ + const [virtual] = createVirtualList({ items: [], rootHeight: 20, rowHeight: 10, overscanCount: 0, }); - expect(containerHeight()).toEqual(0); - expect(viewerTop()).toEqual(0); - expect(visibleItems()).toEqual([]); + expect(virtual().containerHeight).toEqual(0); + expect(virtual().viewerTop).toEqual(0); + expect(virtual().visibleItems).toEqual([]); }); }); From 2911fcd7968d895ba4625f94967b0763a7961987 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Mon, 28 Oct 2024 23:47:08 -0400 Subject: [PATCH 10/11] address PR feedback --- packages/virtual/src/index.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index a5d8886ac..27735db89 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -17,11 +17,7 @@ type VirtualListReturn = [ viewerTop: number; visibleItems: T; }>, - onScroll: ( - e: Event & { - target: DOMElement; - }, - ) => void, + onScroll: (e: Event) => void, ]; /** @@ -61,7 +57,8 @@ export function createVirtualList({ visibleItems: items.slice(getFirstIdx(), getLastIdx()) as unknown as T, }), e => { - setOffset(e.target.scrollTop); + // @ts-expect-error + setOffset(e.target?.scrollTop); }, ]; } @@ -90,10 +87,10 @@ export function VirtualList( props: VirtualListProps, ): JSX.Element { const [virtual, onScroll] = createVirtualList({ - items: props.each, - rootHeight: props.rootHeight, - rowHeight: props.rowHeight, - overscanCount: props.overscanCount, + items: () => props.each, + rootHeight: () => props.rootHeight, + rowHeight: () => props.rowHeight, + overscanCount: () => props.overscanCount || 1, }); return ( From 0a6d281b50d264ed4914561bf6f210ce9aca5390 Mon Sep 17 00:00:00 2001 From: Spencer Whitehead Date: Tue, 29 Oct 2024 09:39:05 -0400 Subject: [PATCH 11/11] fix: no-op if onScroll applied to incorrect element --- packages/virtual/src/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/virtual/src/index.tsx b/packages/virtual/src/index.tsx index 27735db89..dbebd2b4c 100644 --- a/packages/virtual/src/index.tsx +++ b/packages/virtual/src/index.tsx @@ -1,6 +1,5 @@ import { For, createSignal } from "solid-js"; import type { Accessor, JSX } from "solid-js"; -import type { DOMElement } from "solid-js/jsx-runtime"; import { access } from "@solid-primitives/utils"; import type { MaybeAccessor } from "@solid-primitives/utils"; @@ -58,7 +57,7 @@ export function createVirtualList({ }), e => { // @ts-expect-error - setOffset(e.target?.scrollTop); + if (e.target?.scrollTop !== undefined) setOffset(e.target.scrollTop); }, ]; }