Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/virtual-list-class-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/virtual": minor
---

Added `class` prop to `<VirtualList>` for styling the element that wraps the rendered rows (e.g. `display: flex; flex-flow: column; width: 100%;`). Resolves #698.
25 changes: 24 additions & 1 deletion packages/virtual/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pnpm add @solid-primitives/virtual

`createVirtualList` is a headless utility for constructing your own virtualized list components with maximum flexibility.

`virtual` is an accessor returning `{ containerHeight, viewerTop, visibleItems }` — call it to read the current values. When rendering `visibleItems` with `<For>`, pass `keyed={false}` so each item is provided as an `Accessor` (`<For>` defaults to `keyed={true}`, which passes raw values instead).

```tsx
function MyComp(): JSX.Element {
const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
Expand Down Expand Up @@ -72,7 +74,8 @@ function MyComp(): JSX.Element {
}}
>
{/* only virtual().visibleItems are ultimately rendered */}
<For fallback={"no items"} each={virtual().visibleItems}>
{/* keyed={false} is required for item to be passed as an Accessor — <For> defaults to keyed={true}, passing raw values instead */}
<For fallback={"no items"} each={virtual().visibleItems} keyed={false}>
{item => <div>{item()}</div>}
</For>
</div>
Expand All @@ -92,6 +95,8 @@ function MyComp(): JSX.Element {
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>}
// an optional class applied to the element wrapping the rendered rows, useful for styling row layout (e.g. flex/grid)
class="my-list"
// 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
Expand All @@ -110,6 +115,24 @@ function MyComp(): JSX.Element {
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.

#### Styling the rows with `class`

The rendered rows are wrapped in an absolutely positioned element that `<VirtualList>` manages internally (for offsetting the visible window as you scroll). Pass `class` to have it applied to that wrapper, which is useful for laying out rows with flexbox or grid instead of relying on each row's own styles:

```tsx
<VirtualList each={items} rootHeight={400} rowHeight={40} class="row-list">
{item => <div>{item()}</div>}
</VirtualList>
```

```css
.row-list {
display: flex;
flex-flow: column;
width: 100%;
}
```

## Changelog

See [CHANGELOG.md](./CHANGELOG.md)
131 changes: 0 additions & 131 deletions packages/virtual/dev/index.tsx

This file was deleted.

3 changes: 3 additions & 0 deletions packages/virtual/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type VirtualListProps<T extends readonly any[], U extends JSX.Element> = {
children: (item: Accessor<T[number]>, index: number) => U;
each: T | undefined | null | false;
fallback?: JSX.Element;
class?: string;
overscanCount?: number;
rootHeight: number;
rowHeight: number;
Expand All @@ -81,6 +82,7 @@ type VirtualListProps<T extends readonly any[], U extends JSX.Element> = {
* @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 class an optional class applied to the element wrapping the rendered rows, useful for styling row layout (e.g. flex/grid)
* @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
Expand Down Expand Up @@ -112,6 +114,7 @@ export function VirtualList<T extends readonly any[], U extends JSX.Element>(
}}
>
<div
class={props.class}
style={{
position: "absolute",
top: `${virtual().viewerTop}px`,
Expand Down
68 changes: 67 additions & 1 deletion packages/virtual/stories/virtual.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,72 @@ export const VirtualListComponent = meta.story({
},
});

export const RowLayoutClass = meta.story({
name: "Styling rows with `class`",
parameters: {
docs: {
description: {
story:
"The `class` prop is applied to the element wrapping the rendered rows, so you can lay them out with flexbox or grid instead of styling each row individually. Here it adds `display: flex; flex-flow: column; gap: 0.4rem;` to space out rows uniformly.",
},
},
},
render: () => {
const items = ALL_ITEMS.slice(0, 30);

return (
<Container minWidth={380}>
<h3 style={{ margin: 0 }}>{'<VirtualList class="...">'}</h3>

<style>{`
.virtual-story-row-list {
display: flex;
flex-flow: column;
gap: 0.4rem;
width: 100%;
padding: 0.4rem;
box-sizing: border-box;
}
`}</style>

<VirtualList each={items} rootHeight={280} rowHeight={44} class="virtual-story-row-list">
{(item: Accessor<(typeof ALL_ITEMS)[number]>) => (
<div
style={{
height: "44px",
display: "flex",
"align-items": "center",
"padding-left": "0.75rem",
gap: "0.6rem",
"border-radius": "6px",
background: "#f8fafc",
"box-sizing": "border-box",
width: "320px",
}}
>
<div
style={{
width: "10px",
height: "10px",
"border-radius": "50%",
background: item().color,
"flex-shrink": 0,
}}
/>
<span style={{ "font-size": "0.85rem", color: "#334155" }}>{item().label}</span>
</div>
)}
</VirtualList>

<p style={{ margin: 0, "font-size": "0.8rem", color: "#64748b" }}>
Without <code>class</code>, rows would need their own margin to create gaps. With it, the
row wrapper itself becomes a flex column with a uniform <code>gap</code>.
</p>
</Container>
);
},
});

export const HeadlessVirtualList = meta.story({
name: "createVirtualList (headless)",
parameters: {
Expand Down Expand Up @@ -233,7 +299,7 @@ export const HeadlessVirtualList = meta.story({
width: "100%",
}}
>
<For each={virtual().visibleItems}>
<For each={virtual().visibleItems} keyed={false}>
{(item: Accessor<string>) => (
<div
style={{
Expand Down
17 changes: 17 additions & 0 deletions packages/virtual/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,23 @@ describe("VirtualList", () => {
dispose();
});

test("applies class to the element wrapping the rendered rows", () => {
const dispose = render(
() => (
<VirtualList each={TEST_LIST} rootHeight={20} rowHeight={10} class="my-list">
{item => <div id={"item-" + item()} style={{ height: "10px" }} />}
</VirtualList>
),
ROOT,
);

const listEl = ROOT.querySelector(".my-list");
expect(listEl).not.toBeNull();
expect(listEl?.querySelector("#item-0")).not.toBeNull();

dispose();
});

test("renders when list is empty with optional fallback", () => {
const dispose = render(
() => (
Expand Down
2 changes: 1 addition & 1 deletion packages/virtual/test/server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("VirtualList", () => {
[
'<div style="overflow:auto;height:20px">',
' <div style="position:relative;width:100%;height:10000px">',
' <div style="position:absolute;top:0px">',
' <div class="" style="position:absolute;top:0px">',
' <div style="height:10px">0</div>',
' <div style="height:10px">1</div>',
' <div style="height:10px">2</div>',
Expand Down
Loading