Skip to content

Commit

Permalink
Virtualized list (#667)
Browse files Browse the repository at this point in the history
  • Loading branch information
thetarnav authored Sep 8, 2024
2 parents 1edb03f + e1c127d commit afd5260
Show file tree
Hide file tree
Showing 11 changed files with 743 additions and 356 deletions.
21 changes: 21 additions & 0 deletions packages/virtual/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 Solid Primitives Working Group

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
55 changes: 55 additions & 0 deletions packages/virtual/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<p>
<img width="100%" src="https://assets.solidjs.com/banner?type=Primitives&background=tiles&project=virtual" alt="Solid Primitives virtual">
</p>

# @solid-primitives/virtual

[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/)
[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/virtual?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/virtual)
[![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

## Installation

```bash
npm install @solid-primitives/virtual
# or
yarn add @solid-primitives/virtual
# or
pnpm add @solid-primitives/virtual
```

## How to use it

```tsx
<VirtualList
// the list of items (of course, to for this component to be useful, the list would need to be much bigger than shown here)
each={[0, 1, 2, 3, 4, 5, 6, 7]}
// 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
rootHeight={20}
// the height of individual rows in the virtualizedList
rowHeight={10}
// the class applied to the root element of the virtualizedList
class={"my-class-name"}
>
{
// the flowComponent that will be used to transform the items into rows in the list
item => <div>{item}</div>
}
</VirtualList>
```

The tests describe the component's 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

You can see the VirtualizedList in action in the following sandbox: https://primitives.solidjs.community/playground/virtual

## Changelog

See [CHANGELOG.md](./CHANGELOG.md)
130 changes: 130 additions & 0 deletions packages/virtual/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { createSignal, onMount, onCleanup } from "solid-js";
import type { Component } from "solid-js";
import { isServer } from "solid-js/web";
import { VirtualList } from "../src/index.jsx";

const intl = new Intl.NumberFormat();

const items = new Array(100_000).fill(0).map((_, i) => i);

const clampRange = (min: number, max: number, v: number) => (v < min ? min : v > max ? max : v);

const App: Component = () => {
const [listLength, setListLength] = createSignal(100_000);
const [overscanCount, setOverscanCount] = createSignal(5);
const [rootHeight, setRootHeight] = createSignal(240);
const [rowHeight, setRowHeight] = createSignal(24);

return (
<div class="box-border flex min-h-screen w-full flex-col space-y-4 bg-gray-800 p-24 text-white">
<div class="grid w-full grid-cols-4 bg-white p-4 text-gray-800 shadow-md">
<div>
<DemoControl
label="Number of rows"
max={100_000}
min={1}
name="rowCount"
setValue={setListLength}
value={listLength()}
/>
</div>
<div>
<DemoControl
label="overscanCount"
max={100}
min={1}
name="overscanCount"
setValue={setOverscanCount}
value={overscanCount()}
/>
</div>
<div>
<DemoControl
label="rootHeight"
max={1_000}
min={100}
name="rootHeight"
setValue={setRootHeight}
value={rootHeight()}
/>
</div>
<div>
<DemoControl
label="rowHeight"
max={100}
min={10}
name="rowHeight"
setValue={setRowHeight}
value={rowHeight()}
/>
</div>
</div>

<div class="w-full space-y-2 bg-white p-4 text-gray-800 shadow-md">
View the devtools console for log of items being added and removed from the visible list
</div>

<VirtualList
each={items.slice(0, listLength())}
overscanCount={overscanCount()}
rootHeight={rootHeight()}
rowHeight={rowHeight()}
class="bg-white text-gray-800"
>
{item => <VirtualListItem item={item} height={rowHeight()} />}
</VirtualList>
</div>
);
};

type DemoControlProps = {
label: string;
max: number;
min: number;
name: string;
setValue: (v: number) => void;
value: number;
};

const DemoControl: Component<DemoControlProps> = props => (
<label>
<div class="text-s">{props.label}:</div>
<input
type="number"
name={props.name}
value={props.value}
min={props.min}
max={props.max}
class="mx-2 w-32"
onInput={e => {
props.setValue(clampRange(props.min, props.max, Number(e.target.value)));
}}
/>
<div class="text-xs">
({intl.format(props.min)} min, {intl.format(props.max)} max)
</div>
</label>
);

type VirtualListItemProps = {
item: number;
height: number;
};

const VirtualListItem: Component<VirtualListItemProps> = props => {
onMount(() => {
if (!isServer) console.log("item added:", props.item);
});

onCleanup(() => {
if (!isServer) console.log("item removed::", props.item);
});

return (
<div style={{ height: `${props.height}px` }} class="align-center flex">
{intl.format(props.item)}
</div>
);
};

export default App;
60 changes: 60 additions & 0 deletions packages/virtual/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"name": "@solid-primitives/virtual",
"version": "0.0.1",
"description": "A virtualized list component for performantly rendering lists with many elements",
"author": "Spencer Whitehead <[email protected]>",
"contributors": [],
"license": "MIT",
"homepage": "https://primitives.solidjs.community/package/virtual",
"repository": {
"type": "git",
"url": "git+https://github.com/solidjs-community/solid-primitives.git"
},
"bugs": {
"url": "https://github.com/solidjs-community/solid-primitives/issues"
},
"primitive": {
"name": "virtual",
"stage": 0,
"list": [
"VirtualList"
],
"category": "Display & Media"
},
"keywords": [
"solid",
"primitives",
"virtualized"
],
"private": false,
"sideEffects": false,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"browser": {},
"exports": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"typesVersions": {},
"scripts": {
"dev": "tsx ../../scripts/dev.ts",
"build": "tsx ../../scripts/build.ts",
"vitest": "vitest -c ../../configs/vitest.config.ts",
"test": "pnpm run vitest",
"test:ssr": "pnpm run vitest --mode ssr"
},
"peerDependencies": {
"solid-js": "^1.6.12"
}
}
70 changes: 70 additions & 0 deletions packages/virtual/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { For, createSignal } from "solid-js";
import type { Accessor, JSX } from "solid-js";

/**
* A basic virtualized list (see https://www.patterns.dev/vanilla/virtual-lists/) component for improving performance when rendering very large lists
*
* @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 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
*/
export function VirtualList<T extends readonly any[], U extends JSX.Element>(props: {
children: (item: T[number], index: Accessor<number>) => U;
fallback?: JSX.Element;
class?: string;
each: T | undefined | null | false;
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 getLastIdx = () =>
Math.min(
items().length,
Math.floor(offset() / props.rowHeight) +
Math.ceil(props.rootHeight / props.rowHeight) +
(props.overscanCount || 1),
);

return (
<div
ref={rootElement}
style={{
overflow: "auto",
height: `${props.rootHeight}px`,
}}
class={props.class}
onScroll={() => {
setOffset(rootElement.scrollTop);
}}
>
<div
style={{
position: "relative",
width: "100%",
height: `${items().length * props.rowHeight}px`,
}}
>
<div
style={{
position: "absolute",
top: `${getFirstIdx() * props.rowHeight}px`,
}}
>
<For fallback={props.fallback} each={items().slice(getFirstIdx(), getLastIdx()) as any as T}>{props.children}</For>
</div>
</div>
</div>
);
};
11 changes: 11 additions & 0 deletions packages/virtual/test/helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Component } from "solid-js";

export const TEST_LIST = new Array(1_000).fill(undefined).map((_, i) => i);

type VirtualListItemProps = {
item: number;
};

export const VirtualListItem: Component<VirtualListItemProps> = props => (
<div style={{ height: "100px" }}>{props.item}</div>
);
Loading

0 comments on commit afd5260

Please sign in to comment.