Skip to content

Commit

Permalink
Headless virtual list (#702)
Browse files Browse the repository at this point in the history
  • Loading branch information
thetarnav authored Oct 29, 2024
2 parents f6d165b + 0a6d281 commit d4763fe
Show file tree
Hide file tree
Showing 8 changed files with 416 additions and 180 deletions.
5 changes: 5 additions & 0 deletions .changeset/unlucky-cars-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/virtual": minor
---

Add a headless virtual list primitive: `createVirtualList` in #702
73 changes: 68 additions & 5 deletions packages/virtual/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- [`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

Expand All @@ -23,18 +24,80 @@ 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 items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const rootHeight = 20;
const rowHeight = 10;
const overscanCount = 5;

const [{ containerHeight, viewerTop, visibleItems }, onScroll] = createVirtualList({
// the list of items - can be a signal
items,
// the height of the root element of the virtualizedList - can be a signal
rootHeight,
// 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 - can be a signal
overscanCount,
});

return (
<div
style={{
overflow: "auto",
// root element's height must be rootHeight
height: `${rootHeight}px`,
}}
// outermost container must use onScroll
onScroll={onScroll}
>
<div
style={{
position: "relative",
width: "100%",
// list container element's height must be set to containerHeight()
height: `${containerHeight()}px`,
}}
>
<div
style={{
position: "absolute",
// viewer element's top must be set to viewerTop()
top: `${viewerTop()}px`,
}}
>
{/* only visibleItems() are ultimately rendered */}
<For fallback={"no items"} each={visibleItems()}>
{item => <div>{item}</div>}
</For>
</div>
</div>
</div>
);
}
```

### `<VirtualList />`

`<VirtualList />` is a basic, unstyled virtual list component you can drop into projects without modification.

```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 optional fallback to display if the list of items is empty
fallback={<div>No items</div>}
// 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
Expand All @@ -43,12 +106,12 @@ pnpm add @solid-primitives/virtual
</VirtualList>
```

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

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

Expand Down
22 changes: 12 additions & 10 deletions packages/virtual/dev/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const App: Component = () => {
<DemoControl
label="Number of rows"
max={100_000}
min={1}
min={0}
name="rowCount"
setValue={setListLength}
value={listLength()}
Expand Down Expand Up @@ -64,15 +64,17 @@ const App: Component = () => {
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 class="bg-white text-gray-800">
<VirtualList
each={items.slice(0, listLength())}
fallback={<div>no items</div>}
overscanCount={overscanCount()}
rootHeight={rootHeight()}
rowHeight={rowHeight()}
>
{item => <VirtualListItem item={item} height={rowHeight()} />}
</VirtualList>
</div>
</div>
);
};
Expand Down
4 changes: 4 additions & 0 deletions packages/virtual/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"name": "virtual",
"stage": 0,
"list": [
"createVirutalList",
"VirtualList"
],
"category": "UI Patterns"
Expand Down Expand Up @@ -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"
}
Expand Down
118 changes: 83 additions & 35 deletions packages/virtual/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,119 @@
import { For, createSignal } from "solid-js";
import type { Accessor, JSX } from "solid-js";
import { access } from "@solid-primitives/utils";
import type { MaybeAccessor } from "@solid-primitives/utils";

type VirtualListConfig<T extends readonly any[]> = {
items: MaybeAccessor<T | undefined | null | false>;
rootHeight: MaybeAccessor<number>;
rowHeight: MaybeAccessor<number>;
overscanCount?: MaybeAccessor<number>;
};

type VirtualListReturn<T extends readonly any[]> = [
Accessor<{
containerHeight: number;
viewerTop: number;
visibleItems: T;
}>,
onScroll: (e: Event) => void,
];

/**
* 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 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 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
* @return virtualized list component
* @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 {VirtualListReturn} to use in the list's jsx
*/
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;
export function createVirtualList<T extends readonly any[]>({
items,
rootHeight,
rowHeight,
overscanCount,
}: VirtualListConfig<T>): VirtualListReturn<T> {
items = access(items) || ([] as any as T);
rootHeight = access(rootHeight);
rowHeight = access(rowHeight);
overscanCount = access(overscanCount) || 1;

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 getFirstIdx = () => Math.max(0, Math.floor(offset() / rowHeight) - overscanCount);

const getLastIdx = () =>
Math.min(
items().length,
Math.floor(offset() / props.rowHeight) +
Math.ceil(props.rootHeight / props.rowHeight) +
(props.overscanCount || 1),
items.length,
Math.floor(offset() / rowHeight) + Math.ceil(rootHeight / rowHeight) + overscanCount,
);

return [
() => ({
containerHeight: items.length * rowHeight,
viewerTop: getFirstIdx() * rowHeight,
visibleItems: items.slice(getFirstIdx(), getLastIdx()) as unknown as T,
}),
e => {
// @ts-expect-error
if (e.target?.scrollTop !== undefined) setOffset(e.target.scrollTop);
},
];
}

type VirtualListProps<T extends readonly any[], U extends JSX.Element> = {
children: (item: T[number], index: Accessor<number>) => 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
*
* @param children the flowComponent that will be used to transform the items into rows in the list
* @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
* @returns virtualized list component
*/
export function VirtualList<T extends readonly any[], U extends JSX.Element>(
props: VirtualListProps<T, U>,
): JSX.Element {
const [virtual, onScroll] = createVirtualList({
items: () => props.each,
rootHeight: () => props.rootHeight,
rowHeight: () => props.rowHeight,
overscanCount: () => props.overscanCount || 1,
});

return (
<div
ref={rootElement}
style={{
overflow: "auto",
height: `${props.rootHeight}px`,
}}
class={props.class}
onScroll={() => {
setOffset(rootElement.scrollTop);
}}
onScroll={onScroll}
>
<div
style={{
position: "relative",
width: "100%",
height: `${items().length * props.rowHeight}px`,
height: `${virtual().containerHeight}px`,
}}
>
<div
style={{
position: "absolute",
top: `${getFirstIdx() * props.rowHeight}px`,
top: `${virtual().viewerTop}px`,
}}
>
<For
fallback={props.fallback}
each={items().slice(getFirstIdx(), getLastIdx()) as any as T}
>
<For fallback={props.fallback} each={virtual().visibleItems}>
{props.children}
</For>
</div>
Expand Down
Loading

0 comments on commit d4763fe

Please sign in to comment.