Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.

Commit 3a95d10

Browse files
authored
Virtual components (#65)
* wip adding virtual grid and row * tweaks and docus for virtualgrid * Release 2.9.6 * address comments
1 parent 3aa95b7 commit 3a95d10

8 files changed

Lines changed: 414 additions & 1 deletion

File tree

docs/_sidebar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@
4242
- [Row and Column](/primitives/row_column.md)
4343
- [useHold](/primitives/useHold.md)
4444
- [Visible](/primitives/visible.md)
45+
- [VirtualGrid](/primitives/virtualgrid.md)
4546
- Tooling
4647
- [Solid Devtools](/tooling/solid_devtools.md)

docs/primitives/virtualgrid.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# VirtualGrid Primitive
2+
3+
`VirtualGrid` renders a dynamic slice of items based on the currently selected index and supports smooth vertical scrolling between rows.
4+
5+
### Behavior
6+
7+
- Renders a 2D grid of items using `itemsPerRow` and `numberOfRows`.
8+
- Uses `rowsBuffer` to pre-render additional rows above and below the visible window.
9+
- Only a subset of the total items is rendered, improving performance.
10+
- Handles directional navigation (`onUp`, `onDown`, `onLeft`, `onRight`) using built-in key handlers.
11+
- Automatically scrolls vertically when the user navigates to a new row.
12+
- Triggers `onEndReached` when the user approaches the end of the list. Allowing fetching additional items
13+
- Focus is updated and maintained internally, with optional control via `scrollToIndex`.
14+
- `selected` can be set to the index in the total array of items (which can be gotten from the cursor property)
15+
16+
### Example Usage
17+
18+
```tsx
19+
import { VirtualGrid } from './components/VirtualGrid';
20+
21+
<VirtualGrid
22+
x={160}
23+
y={24}
24+
width={1620}
25+
scroll="always"
26+
autofocus
27+
rows={7}
28+
columns={2}
29+
buffer={2}
30+
each={provider().pages()}
31+
onEnter={onEnter}
32+
onEndReached={onEndReached}
33+
onSelectedChanged={updateContentBlock}
34+
announce={`All Trending ${props.params.filter}`}
35+
>
36+
{(item) => <Thumbnail item={item()} />}
37+
</VirtualGrid>;
38+
```
39+
40+
### Props
41+
42+
- **each** (`readonly T[] | undefined | null | false`): The full list of items to be rendered.
43+
- **itemsPerRow** (`number`): Number of items per row (required).
44+
- **numberOfRows** (`number`): Number of visible rows (default: `1`).
45+
- **rowsBuffer** (`number`): Number of rows to pre-render above and below the visible area (default: `2`).
46+
- **onEndReached** (`() => void`): Callback triggered when selection moves near the end of the list.
47+
- **children** (`(item: Accessor<T>, index: Accessor<number>) => JSX.Element`): Function that renders each item.
48+
- **selected** (`number`): Initial selected index.
49+
- **autofocus** (`boolean`): If `true`, the component will auto-focus the first item on mount.
50+
- **onSelectedChanged** (`OnSelectedChanged`): Optional callback triggered when selection changes.
51+
- **onLeft / onRight / onUp / onDown** (`KeyHandler`): Optional directional key handlers.
52+
- **ref** (`RefSetter`): Ref to access the underlying view element.
53+
- **style** (`NodeStyles`): Custom style object; merged with internal layout styles.
54+
55+
### Performance Optimization
56+
57+
- Renders only a subset of the full list (`slice`) for improved memory and render-time performance.
58+
- Automatically re-calculates the slice on selection or data change.
59+
- Reuses Children components
60+
- Merges styles with internal layout defaults:
61+
62+
- `display: flex`, `flexWrap: wrap`, `gap: 30`
63+
- `transition: { y: 250ms ease-out }`
64+
65+
### Focus & Navigation
66+
67+
- Focus is managed via `selected` and handled automatically when navigating.
68+
- Navigation jumps vertically by row (`onUp`, `onDown`) and horizontally by item (`onLeft`, `onRight`).
69+
- When the selected index crosses a row boundary, the slice is updated and the grid scrolls accordingly.
70+
- Internal `onSelectedChanged` adjusts for slice-relative index offset to maintain correct selection.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lightningtv/solid",
3-
"version": "2.9.5",
3+
"version": "2.9.6",
44
"description": "Lightning Renderer for Solid Universal",
55
"type": "module",
66
"exports": {
@@ -56,6 +56,8 @@
5656
"dependencies": {
5757
"@lightningtv/core": "^2.9.3",
5858
"@solid-primitives/event-listener": "^2.3.3",
59+
"@solid-primitives/keyed": "^1.5.2",
60+
"@solid-primitives/list": "^0.1.2",
5961
"@solid-primitives/mouse": "^2.0.20",
6062
"@solid-primitives/scheduled": "^1.4.4"
6163
},

pnpm-lock.yaml

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/primitives/VirtualGrid.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import * as s from 'solid-js';
2+
import * as lng from '@lightningtv/solid';
3+
import * as lngp from '@lightningtv/solid/primitives';
4+
import { List } from '@solid-primitives/list';
5+
import * as utils from '../utils.js';
6+
7+
const columnScroll = lngp.withScrolling(false);
8+
9+
const rowStyles: lng.NodeStyles = {
10+
display: 'flex',
11+
flexWrap: 'wrap',
12+
transition: {
13+
y: true,
14+
},
15+
};
16+
17+
function scrollToIndex(this: lng.ElementNode, index: number) {
18+
this.selected = index;
19+
columnScroll(index, this);
20+
this.setFocus();
21+
}
22+
23+
export type VirtualGridProps<T> = lng.NewOmit<lngp.RowProps, 'children'> & {
24+
each: readonly T[] | undefined | null | false;
25+
columns: number; // items per row
26+
rows?: number; // number of visible rows (default: 1)
27+
buffer?: number;
28+
onEndReached?: () => void;
29+
children: (item: s.Accessor<T>, index: s.Accessor<number>) => s.JSX.Element;
30+
};
31+
32+
export function VirtualGrid<T>(props: VirtualGridProps<T>): s.JSX.Element {
33+
const bufferSize = () => props.buffer ?? 2;
34+
const [ cursor, setCursor ] = s.createSignal(props.selected ?? 0);
35+
const items = s.createMemo(() => props.each || []);
36+
const itemsPerRow = () => props.columns;
37+
const numberOfRows = () => props.rows ?? 1;
38+
const totalVisibleItems = () => itemsPerRow() * numberOfRows();
39+
40+
const start = s.createMemo(() => {
41+
const perRow = itemsPerRow();
42+
const newRowIndex = Math.floor(cursor() / perRow);
43+
44+
return utils.clamp(
45+
newRowIndex * perRow - bufferSize() * perRow,
46+
0,
47+
Math.max(0, items().length - totalVisibleItems())
48+
);
49+
})
50+
51+
const end = s.createMemo(() => {
52+
const perRow = itemsPerRow();
53+
const newRowIndex = Math.floor(cursor() / perRow);
54+
55+
return Math.min(
56+
items().length,
57+
(newRowIndex + bufferSize()) * perRow + totalVisibleItems()
58+
);
59+
})
60+
61+
const [slice, setSlice] = s.createSignal(items().slice(start(), end()));
62+
63+
let viewRef!: lngp.NavigableElement;
64+
65+
const onLeft = lngp.handleNavigation('left');
66+
const onRight = lngp.handleNavigation('right');
67+
68+
const onUpDown: lngp.KeyHandler = function () {
69+
const perRow = itemsPerRow();
70+
const selected = this.selected || 0;
71+
if (selected < perRow) return false;
72+
73+
const newIndex = utils.clamp(selected - perRow, 0, items().length - 1);
74+
const lastIdx = selected;
75+
this.selected = newIndex;
76+
const active = this.children[this.selected];
77+
if (active instanceof lng.ElementNode) {
78+
active.setFocus();
79+
chainedOnSelectedChanged.call(this as lngp.NavigableElement, this.selected, this as lngp.NavigableElement, active, lastIdx);
80+
}
81+
};
82+
83+
const onSelectedChanged: lngp.OnSelectedChanged = function (_idx, elm, active, _lastIdx,) {
84+
let idx = _idx;
85+
let lastIdx = _lastIdx;
86+
const perRow = itemsPerRow();
87+
const newRowIndex = Math.floor(idx / perRow);
88+
const prevRowIndex = Math.floor((lastIdx || 0) / perRow);
89+
const prevStart = start();
90+
91+
if (newRowIndex === prevRowIndex) return;
92+
93+
setCursor(prevStart + idx);
94+
setSlice(items().slice(start(), end()));
95+
96+
// this.selected is relative to the slice
97+
// and it doesn't get corrected automatically after children change
98+
const idxCorrection = prevStart - start();
99+
if (lastIdx) lastIdx += idxCorrection;
100+
idx += idxCorrection;
101+
this.selected += idxCorrection;
102+
103+
if (cursor() >= items().length - perRow * bufferSize()) {
104+
props.onEndReached?.();
105+
}
106+
107+
queueMicrotask(() => {
108+
const prevRowY = this.y + active.y;
109+
this.updateLayout();
110+
// if (prevRowY > active.y) {
111+
// }
112+
this.lng.y = prevRowY - active.y;
113+
// this.y = prevRowY - active.y;
114+
columnScroll(idx, elm, active, lastIdx);
115+
});
116+
};
117+
118+
const chainedOnSelectedChanged = lngp.chainFunctions(props.onSelectedChanged, onSelectedChanged)!;
119+
120+
s.createEffect(
121+
s.on([() => props.selected, items], ([selected]) => {
122+
if (!viewRef || selected == null) return;
123+
124+
const item = items()[selected];
125+
let active = viewRef.children.find(x => x.item === item);
126+
const lastSelected = viewRef.selected;
127+
128+
if (active instanceof lng.ElementNode) {
129+
viewRef.selected = viewRef.children.indexOf(active);
130+
chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
131+
} else {
132+
setSlice(items().slice(start(), end()));
133+
134+
queueMicrotask(() => {
135+
viewRef.updateLayout();
136+
active = viewRef.children.find(x => x.item === item);
137+
if (active instanceof lng.ElementNode) {
138+
viewRef.selected = viewRef.children.indexOf(active);
139+
chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected);
140+
}
141+
});
142+
}
143+
})
144+
);
145+
146+
s.createEffect(
147+
s.on(items, () => {
148+
if (!viewRef) return;
149+
setSlice(items().slice(start(), end()));
150+
}, { defer: true })
151+
);
152+
153+
return (
154+
<view
155+
{...props}
156+
ref={lngp.chainRefs(el => { viewRef = el as lngp.NavigableElement; }, props.ref)}
157+
selected={props.selected || 0}
158+
cursor={cursor()}
159+
onLeft={/* @once */ lngp.chainFunctions(props.onLeft, onLeft)}
160+
onRight={/* @once */ lngp.chainFunctions(props.onRight, onRight)}
161+
onUp={/* @once */ lngp.chainFunctions(props.onUp, onUpDown)}
162+
onDown={/* @once */ lngp.chainFunctions(props.onDown, onUpDown)}
163+
forwardFocus={/* @once */ lngp.onGridFocus(chainedOnSelectedChanged)}
164+
onCreate={/* @once */ props.selected ? lngp.chainFunctions(props.onCreate, columnScroll) : props.onCreate}
165+
scrollToIndex={/* @once */ scrollToIndex}
166+
onSelectedChanged={/* @once */ chainedOnSelectedChanged}
167+
style={/* @once */ lng.combineStyles(props.style, rowStyles)}
168+
>
169+
<List each={slice()}>{props.children}</List>
170+
</view>
171+
);
172+
}

0 commit comments

Comments
 (0)