@rivetjs/cdk 0.1.0-alpha.0 → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_adapters/selection.d.ts +13 -2
- package/_adapters/selection.js +39 -15
- package/button/index.jsx +5 -3
- package/collections/components.d.ts +36 -1
- package/collections/components.jsx +224 -16
- package/collections/nodes.d.ts +9 -1
- package/collections/nodes.js +26 -0
- package/collections/row-stamps.d.ts +17 -0
- package/collections/row-stamps.js +108 -0
- package/collections/server-dev.d.ts +1 -0
- package/collections/server-dev.js +2 -0
- package/collections/server-off.d.ts +1 -0
- package/collections/server-off.js +2 -0
- package/collections/server-on.d.ts +1 -0
- package/collections/server-on.jsx +10 -0
- package/drag-drop/directives/drag.d.ts +16 -0
- package/drag-drop/directives/drag.js +28 -0
- package/drag-drop/drag.jsx +6 -4
- package/drag-drop/lazy-refs.js +15 -4
- package/grid-list/behaviour.d.ts +6 -0
- package/grid-list/behaviour.js +25 -7
- package/grid-list/index.d.ts +3 -1
- package/grid-list/index.jsx +60 -24
- package/interactions/press.jsx +2 -1
- package/interactions/utils.d.ts +2 -3
- package/interactions/utils.js +8 -7
- package/listbox/behaviour.jsx +37 -14
- package/listbox/index.jsx +65 -29
- package/menu/index.jsx +38 -7
- package/package.json +4 -4
- package/portal/index.d.ts +8 -0
- package/portal/index.jsx +8 -1
- package/radio-group/index.jsx +4 -1
- package/selection/keyed.js +6 -16
- package/selection/selectable-collection.js +42 -17
- package/sidebar/index.jsx +5 -4
- package/state/menu.d.ts +3 -0
- package/state/menu.js +1 -0
- package/table/index.jsx +512 -168
- package/tree/index.jsx +26 -4
- package/utils/data-attrs.d.ts +4 -2
- package/utils/data-attrs.jsx +27 -3
- package/utils/index.d.ts +1 -15
- package/utils/index.js +5 -102
- package/utils/link-state.d.ts +6 -0
- package/utils/link-state.js +6 -0
- package/utils/links.d.ts +1 -6
- package/utils/links.jsx +2 -4
- package/utils/transitions.d.ts +15 -0
- package/utils/transitions.js +104 -0
- package/virtualizer/behaviour.d.ts +35 -4
- package/virtualizer/behaviour.js +131 -9
- package/virtualizer/collection-renderer.d.ts +62 -0
- package/virtualizer/collection-renderer.jsx +190 -0
- package/virtualizer/engine.d.ts +1 -1
- package/virtualizer/index.d.ts +1 -0
- package/virtualizer/index.jsx +1 -0
- package/virtualizer/list-layout.js +4 -2
- package/virtualizer/state.js +12 -2
- package/virtualizer/virtualizer.d.ts +8 -6
- package/virtualizer/virtualizer.jsx +4 -1
package/virtualizer/behaviour.js
CHANGED
|
@@ -4,18 +4,26 @@
|
|
|
4
4
|
// Apache-2.0, © Adobe): layout + layoutOptions, persisted keys, item measurement, load more.
|
|
5
5
|
// Resume: the model is plain signals + JSON + resumable functions; engine instances live only in the browser
|
|
6
6
|
// (one driver per scroller element, in a lazily created WeakMap).
|
|
7
|
-
import { computed, createRoot, effect, untrack } from "@rivetjs/reactivity";
|
|
7
|
+
import { computed, createRoot, effect, runWithOwner, untrack } from "@rivetjs/reactivity";
|
|
8
8
|
import { direction as directionOf } from "../bidi/index.js";
|
|
9
9
|
import { createCollection, loaderNode } from "./collection.js";
|
|
10
10
|
import { createScrollView, getScrollLeft, layoutInfoToStyle, measureItem, setScrollLeft } from "./dom.js";
|
|
11
|
+
import { Layout } from "./layout.js";
|
|
11
12
|
import { Rect } from "./rect.js";
|
|
12
13
|
import { Size } from "./size.js";
|
|
13
14
|
import { createVirtualizerState } from "./state.js";
|
|
14
15
|
import { Virtualizer } from "./virtualizer-core.js";
|
|
15
16
|
/** Key of the loader sentinel node the component appends when `isLoading` / `onLoadMore` are given. */
|
|
16
17
|
export const LOADER_KEY = "rv-virtualizer-loader";
|
|
18
|
+
/** A layout instance from a class or a factory (RAC `Virtualizer` takes a class or an instance). */
|
|
19
|
+
export function createLayout(layout) {
|
|
20
|
+
const fn = layout;
|
|
21
|
+
return fn.prototype instanceof Layout ? new layout() : layout();
|
|
22
|
+
}
|
|
17
23
|
const read = (value) => (typeof value === "function" ? value() : value);
|
|
18
24
|
function collectionOf(model) {
|
|
25
|
+
if (model.source)
|
|
26
|
+
return (model.source() ?? createCollection([], {}));
|
|
19
27
|
const loading = model.isLoading === undefined && !model.onLoadMore ? null : !!read(model.isLoading ?? false);
|
|
20
28
|
return createCollection(read(model.items), model.collection, loading === null ? [] : [loaderNode(LOADER_KEY, loading)]);
|
|
21
29
|
}
|
|
@@ -24,8 +32,38 @@ const persistedOf = (model) => {
|
|
|
24
32
|
const focused = model.focusedKey();
|
|
25
33
|
if (focused != null)
|
|
26
34
|
keys.add(focused);
|
|
35
|
+
const hostFocused = model.focus?.();
|
|
36
|
+
if (hostFocused != null)
|
|
37
|
+
keys.add(hostFocused);
|
|
38
|
+
// Collection mode: the view holding DOM focus stays until focus moves on — the focused key changes before its
|
|
39
|
+
// item renders (and before the engine sees the scroll that brings it in), and removing the focused element
|
|
40
|
+
// would blur the collection (fast keyboard navigation across the window edge).
|
|
41
|
+
if (model.focus) {
|
|
42
|
+
const active = activeViewKey(model);
|
|
43
|
+
if (active != null)
|
|
44
|
+
keys.add(active);
|
|
45
|
+
}
|
|
27
46
|
return keys;
|
|
28
47
|
};
|
|
48
|
+
function activeViewKey(model) {
|
|
49
|
+
if (typeof document === "undefined")
|
|
50
|
+
return null;
|
|
51
|
+
const scroller = scrollerOf(model);
|
|
52
|
+
const active = document.activeElement;
|
|
53
|
+
const view = active && scroller?.contains(active) ? active.closest("[data-rv-view]") : null;
|
|
54
|
+
return view && scroller.contains(view) ? keyOfView(untrack(model.views), view.dataset.rvView) : null;
|
|
55
|
+
}
|
|
56
|
+
/** The view key whose `String(key)` is `text` (keys render as `data-rv-view` text), at any depth. */
|
|
57
|
+
function keyOfView(views, text) {
|
|
58
|
+
for (const view of views) {
|
|
59
|
+
if (String(view.key) === text)
|
|
60
|
+
return view.key;
|
|
61
|
+
const nested = view.children.length ? keyOfView(view.children, text) : null;
|
|
62
|
+
if (nested != null)
|
|
63
|
+
return nested;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
29
67
|
const sameStyle = (a, b) => {
|
|
30
68
|
const ka = Object.keys(a).filter((k) => a[k] != null), kb = Object.keys(b).filter((k) => b[k] != null);
|
|
31
69
|
return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
|
|
@@ -85,7 +123,7 @@ const sizeOfContent = (size) => ({ width: size.width, height: size.height });
|
|
|
85
123
|
export function initialWindow(model) {
|
|
86
124
|
return untrack(() => {
|
|
87
125
|
const collection = collectionOf(model);
|
|
88
|
-
const layout = model.layout
|
|
126
|
+
const layout = createLayout(model.layout);
|
|
89
127
|
const virtualizer = new Virtualizer({
|
|
90
128
|
collection,
|
|
91
129
|
layout,
|
|
@@ -95,7 +133,7 @@ export function initialWindow(model) {
|
|
|
95
133
|
const views = virtualizer.render({
|
|
96
134
|
layout,
|
|
97
135
|
collection,
|
|
98
|
-
persistedKeys:
|
|
136
|
+
persistedKeys: persistedOf(model),
|
|
99
137
|
visibleRect: new Rect(0, 0, width, height),
|
|
100
138
|
size: new Size(width, height),
|
|
101
139
|
invalidationContext: model.layoutOptions != null ? { layoutOptions: model.layoutOptions } : {},
|
|
@@ -106,6 +144,12 @@ export function initialWindow(model) {
|
|
|
106
144
|
});
|
|
107
145
|
}
|
|
108
146
|
let drivers;
|
|
147
|
+
/** The scroll container of `model`: the host's `scrollRef` (collection mode), else the element with `model.id`. */
|
|
148
|
+
function scrollerOf(model) {
|
|
149
|
+
if (typeof document === "undefined")
|
|
150
|
+
return null;
|
|
151
|
+
return (model.scrollRef ? model.scrollRef() : document.getElementById(model.id)) ?? null;
|
|
152
|
+
}
|
|
109
153
|
const viewElement = (root, key) => {
|
|
110
154
|
for (const el of root.querySelectorAll("[data-rv-view]"))
|
|
111
155
|
if (el.dataset.rvView === String(key))
|
|
@@ -114,16 +158,24 @@ const viewElement = (root, key) => {
|
|
|
114
158
|
};
|
|
115
159
|
/** The browser driver of `model` (created on first use: engine, scroll view, measurement). */
|
|
116
160
|
export function driverOf(model, element) {
|
|
117
|
-
const el = (element ?? (
|
|
118
|
-
|
|
161
|
+
const el = (element ?? scrollerOf(model));
|
|
162
|
+
// Not in the document yet (a client render's first pass): no size to start from.
|
|
163
|
+
if (!el || !el.isConnected)
|
|
119
164
|
return null;
|
|
120
165
|
drivers ??= new WeakMap();
|
|
121
166
|
const existing = drivers.get(el);
|
|
122
167
|
if (existing)
|
|
123
168
|
return existing;
|
|
169
|
+
// Detached from the caller's owner: `createRoot` attaches to the current owner, and the first caller may be the
|
|
170
|
+
// rows' `For` binding (`visibleViews` on a resumed page's first projection), which disposes its children on its
|
|
171
|
+
// next run — the cached driver then kept dead effects and an items change no longer reached the engine
|
|
172
|
+
// (cdk-docs `virtualizer-items`, cold scroll then filter, 2026-10-19). The driver lives with its element.
|
|
173
|
+
return runWithOwner(null, () => createDriver(model, el));
|
|
174
|
+
}
|
|
175
|
+
function createDriver(model, el) {
|
|
124
176
|
const dir = () => model.dir ?? directionOf(el);
|
|
125
177
|
const collection = computed(() => collectionOf(model));
|
|
126
|
-
const layout = untrack(() => model.layout
|
|
178
|
+
const layout = untrack(() => createLayout(model.layout));
|
|
127
179
|
let state;
|
|
128
180
|
const scrollView = createScrollView(el, {
|
|
129
181
|
contentSize: () => untrack(() => state?.contentSize() ?? new Size(model.contentSize().width, model.contentSize().height)),
|
|
@@ -216,23 +268,80 @@ export function driverOf(model, element) {
|
|
|
216
268
|
});
|
|
217
269
|
return driver;
|
|
218
270
|
}
|
|
219
|
-
// The driver's projection effect:
|
|
271
|
+
// The driver's projection effect: its own root (created under the detached driver, so it outlives region re-creation).
|
|
220
272
|
function computedEffect(fn) {
|
|
221
273
|
createRoot(() => effect(fn));
|
|
222
274
|
}
|
|
223
275
|
/** Rows of the model: the server window, or (browser) the driver's projection; reads `items` for reactivity. */
|
|
224
276
|
export function visibleViews(model) {
|
|
225
|
-
if (
|
|
277
|
+
if (model.source) {
|
|
278
|
+
// Collection mode: a rebuilt collection or a focus move (persisted key) starts the driver on a resumed page.
|
|
279
|
+
model.source();
|
|
280
|
+
model.focus?.();
|
|
281
|
+
}
|
|
282
|
+
else if (typeof model.items === "function")
|
|
226
283
|
model.items();
|
|
227
284
|
if (typeof model.isLoading === "function")
|
|
228
285
|
model.isLoading();
|
|
229
286
|
if (typeof document !== "undefined")
|
|
230
|
-
untrack(() => driverOf(model));
|
|
287
|
+
untrack(() => driverOf(model) ?? (model.source && startWhenMounted(model)));
|
|
231
288
|
return model.views();
|
|
232
289
|
}
|
|
290
|
+
let mounting;
|
|
291
|
+
/**
|
|
292
|
+
* Collection mode, client render: the host element exists but is not in the document during its first pass. Start
|
|
293
|
+
* the engine once it is (RAC measures the scroll view on mount); the server window stays until then.
|
|
294
|
+
*/
|
|
295
|
+
function startWhenMounted(model) {
|
|
296
|
+
if ((mounting ??= new WeakSet()).has(model) || !scrollerOf(model))
|
|
297
|
+
return null;
|
|
298
|
+
mounting.add(model);
|
|
299
|
+
queueMicrotask(() => {
|
|
300
|
+
mounting.delete(model);
|
|
301
|
+
untrack(() => driverOf(model));
|
|
302
|
+
});
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
233
305
|
/** Keys of `views`: the rows' `For` items. Keys keep their identity across windows, so resumed server rows are
|
|
234
306
|
* adopted and kept (a `For` re-creates an adopted row whose item changed; view descriptors change on every move). */
|
|
235
307
|
export const viewKeys = (views) => views.map((view) => view.key);
|
|
308
|
+
const byLayoutOrder = (a, b) => a.y - b.y || a.x - b.x;
|
|
309
|
+
/** Keys in layout order (top to bottom, then start to end); the engine's reusable views come in recycling order. */
|
|
310
|
+
export const orderedViewKeys = (views) => views
|
|
311
|
+
.slice()
|
|
312
|
+
.sort(byLayoutOrder)
|
|
313
|
+
.map((view) => view.key);
|
|
314
|
+
/** `rowOrder` parent of the root views. */
|
|
315
|
+
export const ROOT_VIEW = {};
|
|
316
|
+
let orders;
|
|
317
|
+
/**
|
|
318
|
+
* The keyed rows' order: layout order (top to bottom) — except while DOM focus is inside the collection, when kept
|
|
319
|
+
* keys keep their DOM order and new ones go last. A keyed list moves (detaches) a kept row to reorder it, and a
|
|
320
|
+
* detached focused option / cell loses focus (keyboard navigation across the window edge inserts rows before the
|
|
321
|
+
* focused one). Positions are absolute: DOM order is not visual order; options carry `aria-posinset`, rows
|
|
322
|
+
* `aria-rowindex` (RAC renders in the engine's recycling order).
|
|
323
|
+
*/
|
|
324
|
+
export function rowOrder(model, parent, views) {
|
|
325
|
+
const next = orderedViewKeys(views);
|
|
326
|
+
if (typeof document === "undefined")
|
|
327
|
+
return next;
|
|
328
|
+
const byParent = (orders ??= new WeakMap()).get(model) ?? new Map();
|
|
329
|
+
orders.set(model, byParent);
|
|
330
|
+
const prev = byParent.get(parent);
|
|
331
|
+
const scroller = model.scrollRef?.();
|
|
332
|
+
if (prev && scroller?.contains(document.activeElement)) {
|
|
333
|
+
const present = new Set(next);
|
|
334
|
+
const kept = prev.filter((key) => present.has(key));
|
|
335
|
+
const known = new Set(kept);
|
|
336
|
+
for (const key of next)
|
|
337
|
+
if (!known.has(key))
|
|
338
|
+
kept.push(key);
|
|
339
|
+
byParent.set(parent, kept);
|
|
340
|
+
return kept;
|
|
341
|
+
}
|
|
342
|
+
byParent.set(parent, next);
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
236
345
|
let indexes;
|
|
237
346
|
let last;
|
|
238
347
|
const seenOf = (model) => {
|
|
@@ -259,6 +368,19 @@ export function viewOf(model, views, key) {
|
|
|
259
368
|
seen.set(key, view);
|
|
260
369
|
return view;
|
|
261
370
|
}
|
|
371
|
+
let trees;
|
|
372
|
+
/**
|
|
373
|
+
* Collection mode: the child views of the view with key `parentKey` (a host part's `CollectionBranch`: section items,
|
|
374
|
+
* table header rows / columns, body rows, cells), from the whole projected tree; `[]` when it is not rendered.
|
|
375
|
+
*/
|
|
376
|
+
export function childViews(model, parentKey) {
|
|
377
|
+
const views = model.views();
|
|
378
|
+
let tree = (trees ??= new WeakMap()).get(views);
|
|
379
|
+
if (!tree)
|
|
380
|
+
trees.set(views, (tree = flatten(views)));
|
|
381
|
+
return (tree.get(parentKey)?.children ?? EMPTY);
|
|
382
|
+
}
|
|
383
|
+
const EMPTY = [];
|
|
262
384
|
/** Scroll handler of the scroller: drive the engine, load more near the end, then the caller's `onScroll`. */
|
|
263
385
|
export function virtualizerScroll(model, event) {
|
|
264
386
|
const el = event.currentTarget;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type CollectionBranchProps, type CollectionRenderer, type CollectionRootProps } from "../collections/components.jsx";
|
|
2
|
+
import type { Key } from "../collections/types.js";
|
|
3
|
+
import { type LayoutInput, type VirtualizerModel } from "./behaviour.js";
|
|
4
|
+
import { Rect } from "./rect.js";
|
|
5
|
+
import { Size } from "./size.js";
|
|
6
|
+
import type { Direction } from "./types.js";
|
|
7
|
+
/** What the renderer's functions capture (plain data + the root's model, set when the host renders its root). */
|
|
8
|
+
export interface VirtualizerLink<O = unknown> {
|
|
9
|
+
layout: LayoutInput<O>;
|
|
10
|
+
layoutOptions: O | undefined;
|
|
11
|
+
initialSize: {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
};
|
|
15
|
+
dir: Direction | undefined;
|
|
16
|
+
/** The model of the host's `CollectionRoot` (one collection per Virtualizer). */
|
|
17
|
+
model: VirtualizerModel<unknown, O> | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The virtualized `CollectionRenderer` of `link` (one per link). Not read from `CollectionRendererContext` below the
|
|
21
|
+
* host: a resumed page re-creates a part's branch (a binding re-run) without the server's providers, where the
|
|
22
|
+
* context is the default renderer; the renderer object resumes from `link` instead.
|
|
23
|
+
*/
|
|
24
|
+
export declare function rendererOf(link: VirtualizerLink<any>): CollectionRenderer;
|
|
25
|
+
/** The scroll handler hosts forward their scroll container's `scroll` events to (the engine starts on the first). */
|
|
26
|
+
export declare function virtualizerLinkScroll(link: VirtualizerLink<any>, event: Event): void;
|
|
27
|
+
/** RAC: the layout is the collection's layout delegate (keyboard Page Up / Down, range selection). */
|
|
28
|
+
export declare function layoutRect(link: VirtualizerLink<any>, key: Key): Rect | null;
|
|
29
|
+
export declare function layoutVisibleRect(link: VirtualizerLink<any>): Rect;
|
|
30
|
+
export declare function layoutContentSize(link: VirtualizerLink<any>): Size;
|
|
31
|
+
/** `CollectionRoot` of the virtualizer renderer: the sized content element and the root views. */
|
|
32
|
+
export declare function VirtualizerRoot(props: {
|
|
33
|
+
link: VirtualizerLink<any>;
|
|
34
|
+
root: CollectionRootProps;
|
|
35
|
+
}): import("@rivetjs/runtime/jsx-runtime").CompiledElement;
|
|
36
|
+
/** `CollectionBranch` of the virtualizer renderer: the rendered child views of `parentKey`'s view. */
|
|
37
|
+
export declare function VirtualizerBranch(props: {
|
|
38
|
+
link: VirtualizerLink<any>;
|
|
39
|
+
branch: CollectionBranchProps;
|
|
40
|
+
}): unknown;
|
|
41
|
+
/**
|
|
42
|
+
* RAC `Virtualizer` for a collection component: wraps one `ListBox` / `GridList` / `Menu` / `Tree` / `Table` and renders its collection with
|
|
43
|
+
* `layout` — only the views in the visible rect (plus overscan and the focused item) are in the DOM. The component's
|
|
44
|
+
* own element is the scroll container (give it a height and `overflow: auto`).
|
|
45
|
+
*/
|
|
46
|
+
export interface CollectionVirtualizerProps<O> {
|
|
47
|
+
/** The collection component (`<ListBox>`, `<GridList>`, `<Menu>`, `<Tree>`, `<Table>`). */
|
|
48
|
+
children: unknown;
|
|
49
|
+
/** Layout class (`ListLayout`, `TableLayout`, `GridLayout`, `WaterfallLayout`) or factory (`() => new ListLayout()`). */
|
|
50
|
+
layout: LayoutInput<O>;
|
|
51
|
+
/** Options for the layout (JSON: `{ rowHeight: 48 }`). */
|
|
52
|
+
layoutOptions?: O;
|
|
53
|
+
/** Rivet: viewport size of the server-rendered window: the scroll container's client size (inside the borders, padding included; a resumed page keeps it until its first interaction). Default 800 x 600. */
|
|
54
|
+
initialSize?: {
|
|
55
|
+
width: number;
|
|
56
|
+
height: number;
|
|
57
|
+
};
|
|
58
|
+
/** Rivet: text direction of view positions (`left` / `right`); default: logical `inset-inline-start`. */
|
|
59
|
+
dir?: Direction;
|
|
60
|
+
}
|
|
61
|
+
/** Provides the virtualized `CollectionRenderer` (layout = layout delegate) to its child collection. */
|
|
62
|
+
export declare function CollectionVirtualizer<O = unknown>(props: CollectionVirtualizerProps<O>): import("@rivetjs/runtime/jsx-runtime").CompiledElement;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `<CollectionVirtualizer>`: the react-aria-components `Virtualizer` (src/Virtualizer.tsx: the
|
|
2
|
+
// `CollectionRenderer` with `CollectionRoot` / `CollectionBranch` over useVirtualizerState + useScrollView, and
|
|
3
|
+
// VirtualizerItem / useVirtualizerItem; React Spectrum, Apache-2.0, © Adobe) for the cdk collection components:
|
|
4
|
+
// `<CollectionVirtualizer layout={ListLayout}><ListBox …/></CollectionVirtualizer>`, `<CollectionVirtualizer layout={TableLayout}><Table …/></CollectionVirtualizer>`.
|
|
5
|
+
// Hosts: ListBox (+ sections), GridList (ListLayout / GridLayout), Menu, Tree (flattened rows), Table.
|
|
6
|
+
// A separate component from the standalone `<Virtualizer items>` (`./virtualizer.tsx`) so items users (Masonry) ship none of this.
|
|
7
|
+
// Rivet:
|
|
8
|
+
// - The host (ListBox, GridList, Menu, Tree, Table) builds its collection and passes an accessor of it to `CollectionRoot` with its
|
|
9
|
+
// scroll container (`scrollRef`, the host element) and its focused key (`focusedKey`, persisted); the engine
|
|
10
|
+
// is the standalone component's driver (`./behaviour.ts`): the server renders the first window at `initialSize`,
|
|
11
|
+
// the browser starts the engine on the first scroll (the host forwards its `scroll` events to `onScroll`), focus
|
|
12
|
+
// move (a persisted key) or layout delegate call (keyboard Page Up / Down).
|
|
13
|
+
// - Every view renders in a wrapper `div[role=presentation]` (VirtualizerItem: `data-rv-view`, `data-rv-type`,
|
|
14
|
+
// absolute / sticky position from the layout); the node's part renders inside it (`node.render(node)`) with this
|
|
15
|
+
// renderer provided, so a part rendering its children (`CollectionBranch`: sections, table rows / cells) gets
|
|
16
|
+
// the child views of its own view.
|
|
17
|
+
// - Resume: the renderer object's functions capture only `link` (layout factory, options, the root's model);
|
|
18
|
+
// the model holds signals, the host's collection accessor, its focused key signal and its scroll ref.
|
|
19
|
+
// Deviations: no drop indicators (DnD); no window scrolling; rows are keyed by collection key (no DOM recycling
|
|
20
|
+
// across items, as the standalone component); a layout instance is not accepted (a class or a factory: the
|
|
21
|
+
// instance would be serialized).
|
|
22
|
+
import { computed, signal, untrack } from "@rivetjs/reactivity";
|
|
23
|
+
import { For, Provider } from "@rivetjs/runtime/dom";
|
|
24
|
+
import { CollectionRendererContext, DefaultCollectionRenderer, resumedKeys, sameRender, serverRows, } from "../collections/components.jsx";
|
|
25
|
+
import { rowKey } from "../collections/row-stamps.js";
|
|
26
|
+
import { childViews, contentStyle, driverOf, initialWindow, ROOT_VIEW, rowOrder, viewOf, visibleViews, } from "./behaviour.js";
|
|
27
|
+
import { Rect } from "./rect.js";
|
|
28
|
+
import { Size } from "./size.js";
|
|
29
|
+
const engineOf = (link) => (link.model ? (driverOf(link.model)?.state.virtualizer ?? null) : null);
|
|
30
|
+
let renderers;
|
|
31
|
+
/**
|
|
32
|
+
* The virtualized `CollectionRenderer` of `link` (one per link). Not read from `CollectionRendererContext` below the
|
|
33
|
+
* host: a resumed page re-creates a part's branch (a binding re-run) without the server's providers, where the
|
|
34
|
+
* context is the default renderer; the renderer object resumes from `link` instead.
|
|
35
|
+
*/
|
|
36
|
+
export function rendererOf(link) {
|
|
37
|
+
let renderer = (renderers ??= new WeakMap()).get(link);
|
|
38
|
+
if (!renderer)
|
|
39
|
+
renderers.set(link, (renderer = VirtualizerRenderer(link)));
|
|
40
|
+
return renderer;
|
|
41
|
+
}
|
|
42
|
+
/** A PascalCase factory: its object-literal arrows are resumable closures over `link` only. */
|
|
43
|
+
function VirtualizerRenderer(link) {
|
|
44
|
+
return {
|
|
45
|
+
isVirtualized: true,
|
|
46
|
+
// RAC: the layout is the collection's layout delegate (keyboard Page Up / Down, range selection).
|
|
47
|
+
layoutDelegate: {
|
|
48
|
+
getItemRect: (key) => layoutRect(link, key),
|
|
49
|
+
getVisibleRect: () => layoutVisibleRect(link),
|
|
50
|
+
getContentSize: () => layoutContentSize(link),
|
|
51
|
+
},
|
|
52
|
+
CollectionRoot: (root) => <VirtualizerRoot link={link} root={root}/>,
|
|
53
|
+
CollectionBranch: (branch) => <VirtualizerBranch link={link} branch={branch}/>,
|
|
54
|
+
onScroll: (event) => virtualizerLinkScroll(link, event),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The scroll handler hosts forward their scroll container's `scroll` events to (the engine starts on the first). */
|
|
58
|
+
export function virtualizerLinkScroll(link, event) {
|
|
59
|
+
const model = link.model;
|
|
60
|
+
if (!model)
|
|
61
|
+
return;
|
|
62
|
+
const driver = driverOf(model, event.currentTarget);
|
|
63
|
+
driver?.scrollView.onScroll(event);
|
|
64
|
+
}
|
|
65
|
+
/** RAC: the layout is the collection's layout delegate (keyboard Page Up / Down, range selection). */
|
|
66
|
+
export function layoutRect(link, key) {
|
|
67
|
+
return engineOf(link)?.layout.getLayoutInfo(key)?.rect ?? null;
|
|
68
|
+
}
|
|
69
|
+
export function layoutVisibleRect(link) {
|
|
70
|
+
return engineOf(link)?.visibleRect ?? new Rect();
|
|
71
|
+
}
|
|
72
|
+
export function layoutContentSize(link) {
|
|
73
|
+
return engineOf(link)?.layout.getContentSize() ?? new Size();
|
|
74
|
+
}
|
|
75
|
+
const isServer = typeof document === "undefined";
|
|
76
|
+
/**
|
|
77
|
+
* The node at `key` of the host's collection. As `renderNodeAt`: server once, untracked; browser a region that keeps
|
|
78
|
+
* the part while the node renders the same (`sameRender`).
|
|
79
|
+
*/
|
|
80
|
+
function renderNode(model, key) {
|
|
81
|
+
const at = () => model.source?.()?.getItem(key) ?? null;
|
|
82
|
+
if (isServer)
|
|
83
|
+
return untrack(() => {
|
|
84
|
+
const n = at();
|
|
85
|
+
return n?.render ? n.render(n) : null;
|
|
86
|
+
});
|
|
87
|
+
const node = computed(at, { equals: sameRender });
|
|
88
|
+
return () => {
|
|
89
|
+
node();
|
|
90
|
+
const n = untrack(at);
|
|
91
|
+
return n?.render ? untrack(() => n.render(n)) : null;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* VirtualizerItem: one view's wrapper (position + size of its layout info) and its node's part, rendered with this
|
|
96
|
+
* renderer provided (a `Provider` binding: a part's lazily evaluated content still sees it) — a part rendering its
|
|
97
|
+
* children (`CollectionBranch`: sections, table rows / cells) gets the child views of its own view.
|
|
98
|
+
*/
|
|
99
|
+
function VirtualizerView(props) {
|
|
100
|
+
const { model, renderer, viewKey, views } = props;
|
|
101
|
+
const view = () => viewOf(model, views(), viewKey);
|
|
102
|
+
return (<div role="presentation" class="rv-Virtualizer-item" data-rv-view={String(viewKey)} data-rv-type={view().type} style={view().style}>
|
|
103
|
+
<Provider token={CollectionRendererContext} value={renderer}>
|
|
104
|
+
{renderNode(model, viewKey)}
|
|
105
|
+
</Provider>
|
|
106
|
+
</div>);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Lazy collection rows (`resume.lazyRows`, LAZY-T6): a loop over container views — a section, a table's header / body
|
|
110
|
+
* (views whose children have children) — is not the lazy `For`; the collection's rows below it are (a lazy row is one
|
|
111
|
+
* option / table row, not the whole table). Read by the server's lazy rows only.
|
|
112
|
+
*/
|
|
113
|
+
const leafViews = (views) => !views.some((view) => view.type === "section" || view.children.some((child) => child.children.length > 0));
|
|
114
|
+
/**
|
|
115
|
+
* The keyed `For` of the rendered views below `parent` (the root / a section / a table body). Resumed page: an adopted view
|
|
116
|
+
* whose node changed since the server render is re-created (ROW-RERENDER, `resumedKeys`) — called in the region read that
|
|
117
|
+
* creates the For, so its `serverRows` baseline is recorded with (and adopted by) the For's region.
|
|
118
|
+
*/
|
|
119
|
+
function viewRows(model, parent, views, renderer, parentType) {
|
|
120
|
+
const order = () => rowOrder(model, parent, views());
|
|
121
|
+
const source = model.source, each = resumedKeys(source, serverRows(source, order, parentType), order);
|
|
122
|
+
return (<For each={each} by={(key) => key} lazy={() => leafViews(views())}>
|
|
123
|
+
{(key) => <VirtualizerView model={model} renderer={renderer} viewKey={rowKey(untrack(key))} views={views}/>}
|
|
124
|
+
</For>);
|
|
125
|
+
}
|
|
126
|
+
/** `CollectionRoot` of the virtualizer renderer: the sized content element and the root views. */
|
|
127
|
+
export function VirtualizerRoot(props) {
|
|
128
|
+
const { link, root } = props;
|
|
129
|
+
const renderer = rendererOf(link);
|
|
130
|
+
const collection = root.collection;
|
|
131
|
+
const scrollRef = root.scrollRef;
|
|
132
|
+
const model = {
|
|
133
|
+
id: "",
|
|
134
|
+
items: [],
|
|
135
|
+
source: typeof collection === "function" ? collection : undefined,
|
|
136
|
+
focus: root.focusedKey,
|
|
137
|
+
scrollRef: typeof scrollRef === "function" ? scrollRef : undefined,
|
|
138
|
+
// The class / factory itself (a module reference or a resumable function), never a closure over `link`.
|
|
139
|
+
layout: link.layout,
|
|
140
|
+
layoutOptions: link.layoutOptions,
|
|
141
|
+
collection: {},
|
|
142
|
+
initialSize: link.initialSize,
|
|
143
|
+
dir: link.dir,
|
|
144
|
+
loadMoreOffset: 1,
|
|
145
|
+
shouldObserveItemSize: false,
|
|
146
|
+
persistedKeys: [],
|
|
147
|
+
focusedKey: signal(null),
|
|
148
|
+
views: signal([]),
|
|
149
|
+
contentSize: signal({ width: 0, height: 0 }),
|
|
150
|
+
isScrolling: signal(false),
|
|
151
|
+
};
|
|
152
|
+
if (!model.source)
|
|
153
|
+
throw new Error("Virtualizer: the collection component must pass its built collection (an accessor) to CollectionRoot.");
|
|
154
|
+
link.model = model;
|
|
155
|
+
// Untracked: the body must not depend on the collection / focused key (a resumed page would re-create the
|
|
156
|
+
// root, its content element and the focused option on the first focus move).
|
|
157
|
+
const first = untrack(() => initialWindow(model));
|
|
158
|
+
model.views.set(first.views);
|
|
159
|
+
model.contentSize.set(first.contentSize);
|
|
160
|
+
const rootViews = () => visibleViews(model);
|
|
161
|
+
return (<div role="presentation" class="rv-Virtualizer-content" style={contentStyle(model)}>
|
|
162
|
+
{viewRows(model, ROOT_VIEW, rootViews, renderer)}
|
|
163
|
+
</div>);
|
|
164
|
+
}
|
|
165
|
+
/** `CollectionBranch` of the virtualizer renderer: the rendered child views of `parentKey`'s view. */
|
|
166
|
+
export function VirtualizerBranch(props) {
|
|
167
|
+
const link = props.link;
|
|
168
|
+
const model = link.model;
|
|
169
|
+
const renderer = rendererOf(link);
|
|
170
|
+
// A part rendered outside the window (no root yet): render its children as the default renderer would.
|
|
171
|
+
if (!model)
|
|
172
|
+
return DefaultCollectionRenderer.CollectionBranch(props.branch);
|
|
173
|
+
const parentKey = props.branch.parent.key;
|
|
174
|
+
const parentType = untrack(() => model.source?.()?.getItem(parentKey)?.type);
|
|
175
|
+
return viewRows(model, parentKey, () => childViews(model, parentKey), renderer, parentType);
|
|
176
|
+
}
|
|
177
|
+
/** Provides the virtualized `CollectionRenderer` (layout = layout delegate) to its child collection. */
|
|
178
|
+
export function CollectionVirtualizer(props) {
|
|
179
|
+
const link = {
|
|
180
|
+
layout: props.layout,
|
|
181
|
+
layoutOptions: props.layoutOptions,
|
|
182
|
+
initialSize: props.initialSize ?? { width: 800, height: 600 },
|
|
183
|
+
dir: props.dir,
|
|
184
|
+
model: null,
|
|
185
|
+
};
|
|
186
|
+
const renderer = rendererOf(link);
|
|
187
|
+
return (<Provider token={CollectionRendererContext} value={renderer}>
|
|
188
|
+
{props.children}
|
|
189
|
+
</Provider>);
|
|
190
|
+
}
|
package/virtualizer/engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { LOADER_KEY, initialWindow, projectViews, type VirtualView, type VirtualizerModel } from "./behaviour.js";
|
|
1
|
+
export { LOADER_KEY, initialWindow, projectViews, type LayoutInput, type VirtualView, type VirtualizerModel } from "./behaviour.js";
|
|
2
2
|
export { Virtualizer as VirtualizerEngine, type VirtualizerOptions } from "./virtualizer-core.js";
|
|
3
3
|
export { createVirtualizerState, type VirtualizerState, type VirtualizerStateOptions } from "./state.js";
|
|
4
4
|
export { Point } from "./point.js";
|
package/virtualizer/index.d.ts
CHANGED
package/virtualizer/index.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @rivetjs/cdk/virtualizer — React Spectrum's virtualizer (react-stately engine + layouts, react-aria
|
|
2
2
|
// Virtualizer / ScrollView / VirtualizerItem) for Rivet. Apache-2.0 portions © Adobe; see NOTICE.
|
|
3
3
|
export { Virtualizer } from "./virtualizer.jsx";
|
|
4
|
+
export { CollectionVirtualizer } from "./collection-renderer.jsx";
|
|
4
5
|
export * from "./engine.js";
|
|
@@ -305,8 +305,10 @@ export class ListLayout extends Layout {
|
|
|
305
305
|
if (child.type === "content")
|
|
306
306
|
continue;
|
|
307
307
|
const rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap;
|
|
308
|
-
// Skip rows before the valid rectangle unless they are already cached.
|
|
309
|
-
|
|
308
|
+
// Skip rows before the valid rectangle unless they are already cached. Rivet: never the section header —
|
|
309
|
+
// a cold-started engine (resumed page, first scroll) lays out from the scrolled rect, not from 0 as upstream
|
|
310
|
+
// does, and a skipped header was estimated at the row height and never rendered (story `#vsections`).
|
|
311
|
+
if (child.type !== "header" && offset + rowHeight < this.requestedRect[offsetProperty] && !this.isValid(node, offset)) {
|
|
310
312
|
offset += rowHeight;
|
|
311
313
|
skipped++;
|
|
312
314
|
continue;
|
package/virtualizer/state.js
CHANGED
|
@@ -43,8 +43,8 @@ export function createVirtualizerState(opts) {
|
|
|
43
43
|
lastOptions = layoutOptions;
|
|
44
44
|
merged = layoutOptions != null ? { ...context, layoutOptions } : context;
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
const
|
|
46
|
+
let rect = visibleRect();
|
|
47
|
+
const render = () => virtualizer.render({
|
|
48
48
|
layout: read(opts.layout),
|
|
49
49
|
collection: read(opts.collection),
|
|
50
50
|
persistedKeys: read(opts.persistedKeys) ?? null,
|
|
@@ -54,6 +54,16 @@ export function createVirtualizerState(opts) {
|
|
|
54
54
|
invalidationContext: merged,
|
|
55
55
|
isScrolling: isScrolling(),
|
|
56
56
|
});
|
|
57
|
+
let views = render();
|
|
58
|
+
// A render that moved the visible rect (the layout clamped the scroll offset, or a scroll anchor) laid out no
|
|
59
|
+
// subviews: render again at the new rect before publishing, as the original's `setVisibleRect` (a state update
|
|
60
|
+
// during render) re-renders before commit. Publishing the empty pass emptied the rows' `For` once — on a resumed
|
|
61
|
+
// page that dropped every adopted server row (a first click that scrolled the scroller 4px past its clamped
|
|
62
|
+
// offset re-created all rows and lost focus; resume fuzz `/list-data`, 2026-10-20).
|
|
63
|
+
for (let pass = 0; pass < 3 && visibleRectChanged && !untrack(visibleRect).equals(rect); pass++) {
|
|
64
|
+
rect = untrack(visibleRect);
|
|
65
|
+
views = render();
|
|
66
|
+
}
|
|
57
67
|
visibleViews.set(views);
|
|
58
68
|
contentSize.set(virtualizer.contentSize);
|
|
59
69
|
// onVisibleRectChange runs after the render (a layout effect in the original).
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Slot } from "@rivetjs/runtime/jsx-runtime";
|
|
2
2
|
import type { Key } from "../collections/index.js";
|
|
3
|
-
import { type MaybeAccessor, type VirtualView } from "./behaviour.js";
|
|
4
|
-
import type { CollectionOptions
|
|
5
|
-
import type { Layout } from "./layout.js";
|
|
3
|
+
import { type LayoutInput, type MaybeAccessor, type VirtualView } from "./behaviour.js";
|
|
4
|
+
import type { CollectionOptions } from "./collection.js";
|
|
6
5
|
import type { Direction } from "./types.js";
|
|
7
6
|
import type { StyleObject } from "@rivetjs/runtime/style";
|
|
8
7
|
import { type GlobalDOMAttributes } from "@rivetjs/cdk/dom-props";
|
|
@@ -10,8 +9,8 @@ import { type GlobalDOMAttributes } from "@rivetjs/cdk/dom-props";
|
|
|
10
9
|
export interface VirtualizerProps<T, O> extends Omit<GlobalDOMAttributes<HTMLDivElement>, "dir" | "onScroll"> {
|
|
11
10
|
/** The data: an array, or an accessor (`() => list()`) whose changes re-render the window. */
|
|
12
11
|
items: MaybeAccessor<readonly T[]>;
|
|
13
|
-
/** Layout factory
|
|
14
|
-
layout:
|
|
12
|
+
/** Layout class (`ListLayout`) or factory (`() => new ListLayout()`): resumable, instantiated on the server and on first scroll. */
|
|
13
|
+
layout: LayoutInput<O>;
|
|
15
14
|
/** Options for the layout (JSON: `{ rowHeight: 32 }`, `{ minItemSize: { width: 120, height: 120 } }`). */
|
|
16
15
|
layoutOptions?: O;
|
|
17
16
|
/** View content: `(item, view) => …`; `view().type` tells items from sections / headers / loaders. */
|
|
@@ -57,6 +56,9 @@ export interface VirtualizerProps<T, O> extends Omit<GlobalDOMAttributes<HTMLDiv
|
|
|
57
56
|
}
|
|
58
57
|
/**
|
|
59
58
|
* Renders a scrollable collection with a layout (ListLayout, GridLayout, TableLayout, WaterfallLayout): only
|
|
60
|
-
* views inside the visible rect (plus overscan and persisted keys) are in the DOM.
|
|
59
|
+
* views inside the visible rect (plus overscan and persisted keys) are in the DOM. Renders its own views
|
|
60
|
+
* (`children(item, view)`) from `items`; a cdk collection component (`ListBox`, `Table`) is virtualized by
|
|
61
|
+
* `CollectionVirtualizer` (`./collection-renderer.tsx`), kept apart so this component and its users (Masonry)
|
|
62
|
+
* carry none of the collection renderer.
|
|
61
63
|
*/
|
|
62
64
|
export declare function Virtualizer<T, O = unknown>(props: VirtualizerProps<T, O>): import("@rivetjs/runtime/jsx-runtime").CompiledElement;
|
|
@@ -11,7 +11,10 @@ import { globalDOMProps } from "@rivetjs/cdk/dom-props";
|
|
|
11
11
|
const OWNED = ["onScroll", "dir"];
|
|
12
12
|
/**
|
|
13
13
|
* Renders a scrollable collection with a layout (ListLayout, GridLayout, TableLayout, WaterfallLayout): only
|
|
14
|
-
* views inside the visible rect (plus overscan and persisted keys) are in the DOM.
|
|
14
|
+
* views inside the visible rect (plus overscan and persisted keys) are in the DOM. Renders its own views
|
|
15
|
+
* (`children(item, view)`) from `items`; a cdk collection component (`ListBox`, `Table`) is virtualized by
|
|
16
|
+
* `CollectionVirtualizer` (`./collection-renderer.tsx`), kept apart so this component and its users (Masonry)
|
|
17
|
+
* carry none of the collection renderer.
|
|
15
18
|
*/
|
|
16
19
|
export function Virtualizer(props) {
|
|
17
20
|
const collection = {};
|