@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.
Files changed (61) hide show
  1. package/_adapters/selection.d.ts +13 -2
  2. package/_adapters/selection.js +39 -15
  3. package/button/index.jsx +5 -3
  4. package/collections/components.d.ts +36 -1
  5. package/collections/components.jsx +224 -16
  6. package/collections/nodes.d.ts +9 -1
  7. package/collections/nodes.js +26 -0
  8. package/collections/row-stamps.d.ts +17 -0
  9. package/collections/row-stamps.js +108 -0
  10. package/collections/server-dev.d.ts +1 -0
  11. package/collections/server-dev.js +2 -0
  12. package/collections/server-off.d.ts +1 -0
  13. package/collections/server-off.js +2 -0
  14. package/collections/server-on.d.ts +1 -0
  15. package/collections/server-on.jsx +10 -0
  16. package/drag-drop/directives/drag.d.ts +16 -0
  17. package/drag-drop/directives/drag.js +28 -0
  18. package/drag-drop/drag.jsx +6 -4
  19. package/drag-drop/lazy-refs.js +15 -4
  20. package/grid-list/behaviour.d.ts +6 -0
  21. package/grid-list/behaviour.js +25 -7
  22. package/grid-list/index.d.ts +3 -1
  23. package/grid-list/index.jsx +60 -24
  24. package/interactions/press.jsx +2 -1
  25. package/interactions/utils.d.ts +2 -3
  26. package/interactions/utils.js +8 -7
  27. package/listbox/behaviour.jsx +37 -14
  28. package/listbox/index.jsx +65 -29
  29. package/menu/index.jsx +38 -7
  30. package/package.json +4 -4
  31. package/portal/index.d.ts +8 -0
  32. package/portal/index.jsx +8 -1
  33. package/radio-group/index.jsx +4 -1
  34. package/selection/keyed.js +6 -16
  35. package/selection/selectable-collection.js +42 -17
  36. package/sidebar/index.jsx +5 -4
  37. package/state/menu.d.ts +3 -0
  38. package/state/menu.js +1 -0
  39. package/table/index.jsx +512 -168
  40. package/tree/index.jsx +26 -4
  41. package/utils/data-attrs.d.ts +4 -2
  42. package/utils/data-attrs.jsx +27 -3
  43. package/utils/index.d.ts +1 -15
  44. package/utils/index.js +5 -102
  45. package/utils/link-state.d.ts +6 -0
  46. package/utils/link-state.js +6 -0
  47. package/utils/links.d.ts +1 -6
  48. package/utils/links.jsx +2 -4
  49. package/utils/transitions.d.ts +15 -0
  50. package/utils/transitions.js +104 -0
  51. package/virtualizer/behaviour.d.ts +35 -4
  52. package/virtualizer/behaviour.js +131 -9
  53. package/virtualizer/collection-renderer.d.ts +62 -0
  54. package/virtualizer/collection-renderer.jsx +190 -0
  55. package/virtualizer/engine.d.ts +1 -1
  56. package/virtualizer/index.d.ts +1 -0
  57. package/virtualizer/index.jsx +1 -0
  58. package/virtualizer/list-layout.js +4 -2
  59. package/virtualizer/state.js +12 -2
  60. package/virtualizer/virtualizer.d.ts +8 -6
  61. package/virtualizer/virtualizer.jsx +4 -1
@@ -0,0 +1,108 @@
1
+ // 64 JSON / HTML-safe characters (no quote, backslash, `<`, `&`).
2
+ const DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
3
+ /** Characters per row stamp (24 bits). */
4
+ export const STAMP = 4;
5
+ let h = 0, budget = 0;
6
+ const ch = (c) => void (h = Math.imul(h ^ c, 16777619));
7
+ const str = (s) => {
8
+ for (let i = 0; i < s.length; i++)
9
+ h = Math.imul(h ^ s.charCodeAt(i), 16777619);
10
+ ch(0);
11
+ };
12
+ const isPlain = (v) => {
13
+ const proto = Object.getPrototypeOf(v);
14
+ return proto === Object.prototype || proto === null;
15
+ };
16
+ // Data the server and the browser see alike (resume-decoded values): primitives, arrays, plain objects, dates. Functions
17
+ // (slots, render functions, handlers: the server and browser compile them differently) and class instances count by kind only;
18
+ // nesting below `depth` by kind and length. `budget` bounds the work per node (a tree node's value holds its subtree).
19
+ function feed(v, depth) {
20
+ if (--budget < 0)
21
+ return;
22
+ switch (typeof v) {
23
+ case "string":
24
+ (ch(1), str(v));
25
+ return;
26
+ case "number":
27
+ case "bigint":
28
+ (ch(2), str(String(v)));
29
+ return;
30
+ case "boolean":
31
+ ch(v ? 3 : 4);
32
+ return;
33
+ case "undefined":
34
+ ch(5);
35
+ return;
36
+ case "function":
37
+ ch(6);
38
+ return;
39
+ case "object":
40
+ break;
41
+ default:
42
+ ch(7);
43
+ return;
44
+ }
45
+ if (v === null)
46
+ return ch(8);
47
+ if (Array.isArray(v)) {
48
+ (ch(9), str(String(v.length)));
49
+ if (depth > 0)
50
+ for (let i = 0; i < v.length; i++)
51
+ feed(v[i], depth - 1);
52
+ return;
53
+ }
54
+ if (v instanceof Date)
55
+ return (ch(10), str(String(v.getTime())));
56
+ if (!isPlain(v))
57
+ return ch(11);
58
+ ch(12);
59
+ if (depth > 0)
60
+ for (const k in v)
61
+ (str(k), feed(v[k], depth - 1));
62
+ }
63
+ function node(n) {
64
+ const live = n.constructor.liveProps;
65
+ (str(n.type), feed(n.key, 0), feed(n.textValue, 0), feed(n["aria-label"], 0), feed(n.rendered, 0));
66
+ const props = n.props;
67
+ if (props)
68
+ for (const k in props)
69
+ if (!live?.includes(k))
70
+ (str(k), feed(props[k], 2));
71
+ const value = n.value;
72
+ if (live && value !== null && typeof value === "object" && isPlain(value)) {
73
+ ch(12);
74
+ for (const k in value)
75
+ if (!live.includes(k))
76
+ (str(k), feed(value[k], 2));
77
+ }
78
+ else
79
+ feed(value, 3);
80
+ }
81
+ /**
82
+ * The row stamp of a built node: a 24-bit hash of what `sameRender` compares (type, key, text, `rendered`, props, value), as
83
+ * 4 characters. A section's stamp covers its child nodes too (`children`): its rows are a For the section part creates in
84
+ * its body (ListBoxSection), which a resumed page never re-runs, so a changed child re-creates the section row.
85
+ */
86
+ export function stamp(n, children) {
87
+ h = 0x811c9dc5;
88
+ if (n) {
89
+ budget = 256;
90
+ node(n);
91
+ if (children)
92
+ for (const child of children)
93
+ ((budget = 256), node(child));
94
+ }
95
+ h ^= h >>> 15;
96
+ h = Math.imul(h, 0x2c1b3c6d);
97
+ h ^= h >>> 12;
98
+ return DIGITS[h & 63] + DIGITS[(h >>> 6) & 63] + DIGITS[(h >>> 12) & 63] + DIGITS[(h >>> 18) & 63];
99
+ }
100
+ /** The set of stamps in a baseline string. */
101
+ export function stampSet(base) {
102
+ const set = new Set();
103
+ for (let i = 0; i + STAMP <= base.length; i += STAMP)
104
+ set.add(base.slice(i, i + STAMP));
105
+ return set;
106
+ }
107
+ /** The collection key of a `For` row key (`resumedKeys` boxes the keys of rows it re-creates). */
108
+ export const rowKey = (key) => (typeof key === "object" ? key.k : key);
@@ -3,3 +3,4 @@ export declare const slotProbing = false;
3
3
  export declare function probeSlots(_values: readonly (readonly [Context<any>, unknown])[], _content: unknown): void;
4
4
  export declare const idScoping = false;
5
5
  export declare function scopedIdUpdaters(): Map<string, unknown> | undefined;
6
+ export declare function domRowItem(_item: unknown): void;
@@ -14,3 +14,5 @@ export const idScoping = false;
14
14
  export function scopedIdUpdaters() {
15
15
  return undefined;
16
16
  }
17
+ // DOM-backed loader data (DOM-DATA): browsers register no rows (folds away).
18
+ export function domRowItem(_item) { }
@@ -3,3 +3,4 @@ export declare const slotProbing = false;
3
3
  export declare function probeSlots(_values: readonly (readonly [Context<any>, unknown])[], _content: unknown): void;
4
4
  export declare const idScoping = false;
5
5
  export declare function scopedIdUpdaters(): Map<string, unknown> | undefined;
6
+ export declare function domRowItem(_item: unknown): void;
@@ -5,3 +5,5 @@ export const idScoping = false;
5
5
  export function scopedIdUpdaters() {
6
6
  return undefined;
7
7
  }
8
+ // DOM-backed loader data (DOM-DATA): browsers register no rows (folds away).
9
+ export function domRowItem(_item) { }
@@ -26,3 +26,4 @@ export declare const idScoping = true;
26
26
  * A's ids. Held weakly by the scope object: dropped with the render.
27
27
  */
28
28
  export declare function scopedIdUpdaters(): Map<string, unknown> | undefined;
29
+ export declare function domRowItem(item: unknown): void;
@@ -74,3 +74,13 @@ export function scopedIdUpdaters() {
74
74
  scopedIds.set(scope, (map = new Map()));
75
75
  return map;
76
76
  }
77
+ /**
78
+ * DOM-backed loader data (DOM-DATA, `resume: { domData: true }` servers): a built collection row renders `item` (the
79
+ * node's `value`, the user's data object) although its `For` iterates keys. `@rivetjs/runtime/server-resume` installs the
80
+ * hook only in domData builds (`Symbol.for("rivet.domRowItem")`): the row then registers that object, so the item's
81
+ * strings the row shows as an element's whole text / a plain attribute travel as DOM paths. Elsewhere: nothing.
82
+ */
83
+ const DOM_ROW_ITEM = Symbol.for("rivet.domRowItem");
84
+ export function domRowItem(item) {
85
+ globalThis[DOM_ROW_ITEM]?.(item);
86
+ }
@@ -91,6 +91,12 @@ export interface DragSetup<T = any> {
91
91
  config?: DragDropConfig | null;
92
92
  /** `DirectionalityContext` value where the `<Drag>` rendered (else the document's). */
93
93
  dir?: Directionality | null;
94
+ /**
95
+ * Rivet: the `<Drag>` element's `ref()`. The instance is destroyed once it lets go of the element: a client unmount
96
+ * (`bindRef`'s cleanup) or, on a resumed page, the end of the server element's region (a row a `For` drops, a
97
+ * re-created region: the revived ref resets).
98
+ */
99
+ element?: () => HTMLElement | undefined;
94
100
  }
95
101
  /** Element that can be moved inside a `DropList` container (Angular `CdkDrag`). */
96
102
  export declare class DragInstance<T = any> {
@@ -112,6 +118,7 @@ export declare class DragInstance<T = any> {
112
118
  private _dragDropRegistry;
113
119
  private _destroyed;
114
120
  private _disposeSync;
121
+ private _disposeRelease;
115
122
  private _containerBeforeStarted;
116
123
  private _movedSubscription;
117
124
  private _handles;
@@ -163,6 +170,15 @@ export declare class DragInstance<T = any> {
163
170
  setFreeDragPosition(value: Point): void;
164
171
  /** Rivet: the props of a lazily created (bare) instance arrived with its own handler. */
165
172
  _setInputs(setup: DragSetup<T>): void;
173
+ /**
174
+ * Rivet: destroy the instance once `element` (the `<Drag>`'s `ref()`, `DragSetup.element`) stops holding this
175
+ * element (Angular `ngOnDestroy`). A server-rendered `<Drag>` runs no component code on a resumed page, so its lazily
176
+ * created instance had no destroy hook: a row a resumed `For` dropped stayed in the registry (`_dragInstances`, the
177
+ * document `touchmove` listener), its list's items and its sync effects. The resume runtime resets a revived ref when
178
+ * the region of its server element dies; client renders reset it on unmount (`bindRef`), like `drag.tsx`'s cleanup.
179
+ * False: destroyed (the element already left).
180
+ */
181
+ _releaseWith(element: (() => HTMLElement | undefined) | undefined): boolean;
166
182
  /** Rivet: sync what the DOM holds (handles, container state) before the ref sees a pointer down. */
167
183
  _beforePointerDown(): void;
168
184
  /** Angular `ngOnDestroy`. */
@@ -56,6 +56,7 @@ export class DragInstance {
56
56
  _dragDropRegistry;
57
57
  _destroyed = false;
58
58
  _disposeSync = null;
59
+ _disposeRelease = null;
59
60
  _containerBeforeStarted = null;
60
61
  _movedSubscription = null;
61
62
  _handles = [];
@@ -224,6 +225,32 @@ export class DragInstance {
224
225
  this._syncReactiveInputs();
225
226
  this._syncHostClasses();
226
227
  }
228
+ /**
229
+ * Rivet: destroy the instance once `element` (the `<Drag>`'s `ref()`, `DragSetup.element`) stops holding this
230
+ * element (Angular `ngOnDestroy`). A server-rendered `<Drag>` runs no component code on a resumed page, so its lazily
231
+ * created instance had no destroy hook: a row a resumed `For` dropped stayed in the registry (`_dragInstances`, the
232
+ * document `touchmove` listener), its list's items and its sync effects. The resume runtime resets a revived ref when
233
+ * the region of its server element dies; client renders reset it on unmount (`bindRef`), like `drag.tsx`'s cleanup.
234
+ * False: destroyed (the element already left).
235
+ */
236
+ _releaseWith(element) {
237
+ if (element && !this._disposeRelease && !this._destroyed) {
238
+ const release = (this._disposeRelease = createRoot((dispose) => {
239
+ let held = false;
240
+ effect(() => {
241
+ if (element() === this.element)
242
+ held = true;
243
+ // Never held: a ref that already let go (its region died before this handler ran, a replayed press).
244
+ else if (held || !this.element.isConnected)
245
+ untrack(() => this.destroy());
246
+ });
247
+ return dispose;
248
+ }).value);
249
+ if (this._destroyed)
250
+ release();
251
+ }
252
+ return !this._destroyed;
253
+ }
227
254
  /** Rivet: sync what the DOM holds (handles, container state) before the ref sees a pointer down. */
228
255
  _beforePointerDown() {
229
256
  this._syncHandles();
@@ -241,6 +268,7 @@ export class DragInstance {
241
268
  }
242
269
  this._dragDropRegistry.removeDirectiveNode(this.element);
243
270
  this._disposeSync?.();
271
+ this._disposeRelease?.();
244
272
  this._containerBeforeStarted?.unsubscribe();
245
273
  this._movedSubscription?.unsubscribe();
246
274
  this._dragRef.dispose();
@@ -47,7 +47,10 @@ export function Drag(props) {
47
47
  onDropped: props.onDropped,
48
48
  onMoved: props.onMoved,
49
49
  };
50
- const setup = { inputs, config, dir };
50
+ // Client render: set once rendered. Resumed page: revived holding the server element, reset when its region dies;
51
+ // the instance (created by the first press) is destroyed with it (`DragInstance._releaseWith`).
52
+ const element = ref();
53
+ const setup = { inputs, config, dir, element };
51
54
  // Read every prop up front: an element with a spread resumes as one binding over all its
52
55
  // attributes, and reading `props` there would capture the whole props object (children included).
53
56
  const dom = globalDOMProps(props, OWNED);
@@ -75,8 +78,6 @@ export function Drag(props) {
75
78
  dragHostEvent(event, setup);
76
79
  onTouchStart?.(event);
77
80
  };
78
- // Client render only: on the server the ref stays empty.
79
- const element = ref();
80
81
  const node = elementType === "li" ? (<li {...dom} ref={element} id={id} class={className} class:cdk-drag-disabled={isDisabled()} data-disabled={isDisabled() ? "true" : undefined} data-rv-drag="" data-rv-drag-self-handle={selfHandle} data-rv-free-position={transform() ? "" : undefined} style={style} style:transform={transform()} onMouseDown={mouseDown} onTouchStart={touchStart} onDragStart={host}>
81
82
  {children}
82
83
  </li>) : elementType === "tr" ? (<tr {...dom} ref={element} id={id} class={className} class:cdk-drag-disabled={isDisabled()} data-disabled={isDisabled() ? "true" : undefined} data-rv-drag="" data-rv-drag-self-handle={selfHandle} data-rv-free-position={transform() ? "" : undefined} style={style} style:transform={transform()} onMouseDown={mouseDown} onTouchStart={touchStart} onDragStart={host}>
@@ -91,7 +92,8 @@ export function Drag(props) {
91
92
  {children}
92
93
  </div>);
93
94
  // Client-rendered: join a list that is being dragged over (Angular `addItem` on construction) and
94
- // destroy the instance with the element (`ngOnDestroy`). Server-rendered drags live with the page.
95
+ // destroy the instance with the element (`ngOnDestroy`). Server-rendered drags (resumed page, no component code):
96
+ // the instance goes when the revived element ref resets (its region died), `DragInstance._releaseWith`.
95
97
  // (`getOwner()` is set on the server too: the element ref is what tells a client render apart.)
96
98
  queueMicrotask(() => {
97
99
  const el = element();
@@ -6,14 +6,17 @@
6
6
  // Cold presses: the loader replays a `mousedown` / `touchstart` once the component chunks are in
7
7
  // (async). The replayed event is forwarded with its age: the ref counts the drag-start delay from the
8
8
  // original press and cancels the pending sequence when the press was released meanwhile
9
- // (`DragRef._pointerDownFromHost`). An item whose `<DropList>` chunk is still loading waits for the
10
- // list to register before it handles the press (so it never starts in standalone mode).
9
+ // (`DragRef._pointerDownFromHost`). A replay is a press handled after its dispatch ended
10
+ // (`eventPhase` NONE), however soon: preloaded / merged chunks replay it within a few ms, and its
11
+ // `mouseup` / `touchend` (no resumed handler, so never held by the loader) reached no listener.
12
+ // An item whose `<DropList>` chunk is still loading waits for the list to register before it
13
+ // handles the press (so it never starts in standalone mode).
11
14
  import { DragInstance } from "./directives/drag.js";
12
15
  import { DropListInstance } from "./directives/drop-list.js";
13
16
  import { DRAG_ATTR, closestDropListElement, dragOf, dropListOf, drags, dropLists, isWake, wake, whenRegistered } from "./directives/registry.js";
14
17
  import { handleElements } from "./directives/drag-handle.js";
15
18
  import { toggleNativeDragInteractions } from "./dom/styling.js";
16
- /** A press handled this much later than it happened (ms) was replayed after its code loaded. */
19
+ /** A press handled this much later than it happened (ms) is treated as replayed even during its dispatch. */
17
20
  const REPLAYED_AFTER = 50;
18
21
  /** Handler of a `<Drag>` element (`mousedown`, `touchstart`, `dragstart` + the wake event). */
19
22
  export function dragHostEvent(event, setup) {
@@ -32,6 +35,9 @@ export function dragHostEvent(event, setup) {
32
35
  if (instance)
33
36
  instance._setInputs(setup);
34
37
  const drag = instance ?? new DragInstance(element, setup);
38
+ // Dies with its element's `ref()` (a resumed row dropped, a client unmount): see `DragInstance._releaseWith`.
39
+ if (!drag._releaseWith(setup.element))
40
+ return;
35
41
  // The wake event, or a native `dragstart`: the ref's own listener prevents it when needed, except
36
42
  // for the `dragstart` that created the ref (its listener was added during the dispatch).
37
43
  if (event.type === "dragstart") {
@@ -41,7 +47,12 @@ export function dragHostEvent(event, setup) {
41
47
  }
42
48
  drag._beforePointerDown();
43
49
  const age = performance.now() - event.timeStamp;
44
- drag._dragRef._pointerDownFromHost(event, age > REPLAYED_AFTER ? age : 0);
50
+ // Replayed: its dispatch is over (the loader's replay, `whenRegistered` above), or handled late. Age alone missed a
51
+ // replay faster than REPLAYED_AFTER: the press released before the code loaded then kept its sequence, and the next
52
+ // move started a drag of an element the replayed click may already have removed (KANBAN-BUILD 2026-10-19: ui-docs
53
+ // `showcase-kanban`, "Cannot read properties of null (reading 'insertBefore')" in `_startDragSequence`).
54
+ const replayed = event.eventPhase === Event.NONE || age > REPLAYED_AFTER;
55
+ drag._dragRef._pointerDownFromHost(event, replayed ? Math.max(age, Number.MIN_VALUE) : 0);
45
56
  }
46
57
  /** Handler of a `<DropList>` element (`mousedown`, `touchstart` and the wake event): creates its instance. */
47
58
  export function dropListHostEvent(event, setup) {
@@ -160,6 +160,12 @@ export interface GridListItemAria extends SelectableItemStates {
160
160
  }
161
161
  /** Focus the row (focusMode "child": its first tabbable child) — react-aria's item `focus()`. */
162
162
  export declare function focusRow(props: AriaGridListItemOptions, row: HTMLElement | null): void;
163
+ /**
164
+ * useGridListItem (virtualized) `aria-rowindex` of `key`: `node.index + 1`, or with sections its 1-based position
165
+ * among the collection's non-section keys. Rivet: the section scan is cached per committed collection (a virtualized
166
+ * GridList's rows each ask; upstream rescans per row).
167
+ */
168
+ export declare function gridListRowIndex(collection: Collection<Node<unknown>>, key: Key, index?: number): number;
163
169
  /**
164
170
  * keydown (capture) on the row: Left / Right move between focusable children (arrow
165
171
  * keyboardNavigationBehavior, RTL aware) and back to the row; tree expand / collapse; Up / Down
@@ -336,15 +336,33 @@ function gridListItemAttrs(props, state) {
336
336
  };
337
337
  if (props.focusMode === "child" && props.allowsArrowNavigation && d.shared.keyboardNavigationBehavior === "tab")
338
338
  attrs.tabIndex = -1;
339
- if (props.isVirtualized) {
340
- const { collection } = state;
341
- const nodes = [...collection];
342
- attrs["aria-rowindex"] = nodes.find((n) => n.type === "section")
343
- ? [...collection.getKeys()].filter((key) => collection.getItem(key)?.type !== "section").findIndex((key) => key === node.key) + 1
344
- : node.index + 1;
345
- }
339
+ if (props.isVirtualized)
340
+ attrs["aria-rowindex"] = gridListRowIndex(state.collection, node.key, node.index);
346
341
  return { ...attrs, ...d.treeAttrs };
347
342
  }
343
+ const rowIndexes = new WeakMap();
344
+ /**
345
+ * useGridListItem (virtualized) `aria-rowindex` of `key`: `node.index + 1`, or with sections its 1-based position
346
+ * among the collection's non-section keys. Rivet: the section scan is cached per committed collection (a virtualized
347
+ * GridList's rows each ask; upstream rescans per row).
348
+ */
349
+ export function gridListRowIndex(collection, key, index = collection.getItem(key)?.index ?? 0) {
350
+ let keys = rowIndexes.get(collection);
351
+ if (keys === undefined) {
352
+ keys = null;
353
+ for (const n of collection)
354
+ if (n.type === "section") {
355
+ keys = new Map();
356
+ for (const k of collection.getKeys())
357
+ if (collection.getItem(k)?.type !== "section")
358
+ keys.set(k, keys.size + 1);
359
+ break;
360
+ }
361
+ if (collection.isFrozen !== false)
362
+ rowIndexes.set(collection, keys);
363
+ }
364
+ return keys ? (keys.get(key) ?? 0) : index + 1;
365
+ }
348
366
  /** Attributes of the grid cell inside the row. */
349
367
  const gridListCellAttrs = () => ({ role: "gridcell", "aria-colindex": 1 });
350
368
  function handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, row, allowsArrowNavigation) {
@@ -1,7 +1,7 @@
1
1
  import { type Signal } from "@rivetjs/reactivity";
2
2
  import { type CollectionProps } from "../collections/components.jsx";
3
3
  import { type ListState } from "../collections/list-state.js";
4
- import type { DisabledBehavior, Key, KeyboardDelegate, Node, Orientation, Selection, SelectionBehavior, SelectionMode } from "../collections/types.js";
4
+ import type { Collection as ICollection, DisabledBehavior, Key, KeyboardDelegate, Node, Orientation, Selection, SelectionBehavior, SelectionMode } from "../collections/types.js";
5
5
  import { type DOMRenderProps } from "../utils/render.js";
6
6
  import { type GlobalDOMAttributes } from "../utils/dom-props.js";
7
7
  import { type LinkDOMProps } from "../utils/links.jsx";
@@ -100,6 +100,8 @@ export interface GridListInternal {
100
100
  version: () => unknown;
101
101
  /** Autocomplete filter (SelectableCollectionContext): rows it returns false for do not render. */
102
102
  filter?: ((textValue: string, node: Node<any>) => boolean) | undefined;
103
+ /** Rivet: the built collection of a virtualized GridList (rows read their `aria-rowindex` from it). */
104
+ collection?: (() => ICollection<Node<any>>) | undefined;
103
105
  }
104
106
  /** The GridList a part renders in (null outside a GridList, and inside a row's content). */
105
107
  export declare const GridListInternalContext: import("@rivetjs/reactivity").Context<GridListInternal | null>;
@@ -6,10 +6,10 @@
6
6
  // @react-aria/gridlist port (`./behaviour.ts`).
7
7
  //
8
8
  // Rivet:
9
- // - B5 LIVE collection mode: the rows render in place (`data-key` on the row); the handlers read the
10
- // collection back from the grid's DOM per event (`listStateView(state, shared)` →
11
- // `listCollection(state)` → `domCollection`). Resumed pages need no item model; the grid and its
12
- // rows resume from serializable state only (`createListState` signals + keys + plain item props).
9
+ // - Rows render from the built collection (below; `data-key` on the row); the handlers read it per event
10
+ // (`listStateView(state, shared)` → `listCollection(state)`). Resumed pages ship no item model: the collection is
11
+ // rebuilt from the content, the grid and its rows resume from serializable state only (`createListState` signals +
12
+ // keys + plain item props).
13
13
  // - Row attributes are computed per row from a one-node collection of the row's own props
14
14
  // (`rowCollection(spec)`) inside a `computed` (selection / focus / disabled reads are reactive);
15
15
  // handlers rebuild the row node from the DOM collection per event (`eventNode`).
@@ -19,8 +19,13 @@
19
19
  // `class` / `style` / `children` function asks for render props.
20
20
  // - `className` → `class` (default `rv-GridList`, `rv-GridListItem`, `rv-GridListSection`,
21
21
  // `rv-GridListHeader`, `rv-GridListLoadingIndicator`).
22
+ // - The GridList builds its collection (`computed(buildCollectionFrom(content))` in the body; LAZY-ROWS T3, 2026-10-19:
23
+ // always, virtualized only before): rows render from it through the renderer's keyed `For` (`renderNodeAt`, lazy-row
24
+ // eligible), handlers navigate it; a resumed page rebuilds it from the content on first need. Inside a
25
+ // `<CollectionVirtualizer layout={ListLayout | GridLayout}>` (RAC Virtualizer) rows render as views, the grid element
26
+ // is the scroll container (scroll forwarded to the renderer), the focused row is persisted, rows get `aria-rowindex`.
22
27
  // Deviation: `isEmpty` is known for `items` collections and, when `renderEmptyState` is set, for static
23
- // children (built with `collectionOf`); static children without `renderEmptyState` count as non-empty.
28
+ // children (the built collection's size); static children without `renderEmptyState` count as non-empty.
24
29
  // Deviation: react-aria `useHasTabbableChild` (empty state with a tabbable child) is not tracked: an
25
30
  // empty grid is always tabbable.
26
31
  // Deviation: the description / section heading ids (react-aria `useSlotId`) are always set on the
@@ -32,7 +37,7 @@
32
37
  import { computed, createContext, onCleanup, signal, untrack, useContext } from "@rivetjs/reactivity";
33
38
  import { Show, clientEffect, ref as createRef } from "@rivetjs/runtime/dom";
34
39
  import { buildCollection, HeaderNode, ItemNode, LoaderNode, SectionNode } from "../collections/base.js";
35
- import { Collection, CollectionRendererContext, DefaultCollectionRenderer, collectionOf, createBranchComponent, createLeafComponent, } from "../collections/components.jsx";
40
+ import { Collection, CollectionRendererContext, DefaultCollectionRenderer, buildCollectionFrom, createBranchComponent, createLeafComponent, } from "../collections/components.jsx";
36
41
  import { createListState, listSelectionManager } from "../collections/list-state.js";
37
42
  import { ListKeyboardDelegate, searchCollator } from "../selection/list-keyboard-delegate.js";
38
43
  import {} from "../utils/render.js";
@@ -52,7 +57,7 @@ import { SharedElementContext } from "../shared-element-transition/transition.js
52
57
  import { loadMoreSentinel } from "../utils/load-more-sentinel.jsx";
53
58
  import { SelectableCollectionContext } from "../autocomplete/contexts.js";
54
59
  import { eventNode, isFn, ringOf, rowCollection, rowSpec, slotRef, withDisabled } from "./row.js";
55
- import { gridListBlur, gridListAutoFocus, gridListFocus, gridListHighlightDescription, gridListItemClick, gridListItemDoubleClick, gridListItemDragStart, gridListItemFocus, gridListItemKeyDown, gridListItemKeyDownCapture, gridListItemKeyUp, gridListItemPointerCancel, gridListItemPointerDown, gridListItemPointerUp, gridListItemProps, gridListKeyDown, gridListKeyDownCapture, gridListMouseDown, gridListProps, gridListSectionProps, gridListSelectionCheckboxChange, gridListSelectionCheckboxProps, listStateView, } from "./behaviour.js";
60
+ import { gridListBlur, gridListAutoFocus, gridListFocus, gridListHighlightDescription, gridListItemClick, gridListItemDoubleClick, gridListItemDragStart, gridListItemFocus, gridListItemKeyDown, gridListItemKeyDownCapture, gridListItemKeyUp, gridListItemPointerCancel, gridListItemPointerDown, gridListItemPointerUp, gridListItemProps, gridListRowIndex, gridListKeyDown, gridListKeyDownCapture, gridListMouseDown, gridListProps, gridListSectionProps, gridListSelectionCheckboxChange, gridListSelectionCheckboxProps, listStateView, } from "./behaviour.js";
56
61
  import { hasLive, isLive, liveKeys, liveView } from "../utils/live.jsx";
57
62
  import { liveRenderProps } from "../slots/index.jsx";
58
63
  /** RAC `GridListContext`. */
@@ -79,8 +84,17 @@ export function GridList(inProps) {
79
84
  const sharedElements = {};
80
85
  const isVirtualized = !!renderer.isVirtualized, layoutDelegate = renderer.layoutDelegate;
81
86
  const { locale, direction } = useLocale();
87
+ const content = () => (<Collection items={items} idScope={idScope}>
88
+ {children}
89
+ </Collection>);
90
+ // The list renders from its built collection (RAC: inside a Virtualizer only the visible window has rows; the
91
+ // handlers navigate the built collection, not the rendered DOM; LAZY-ROWS T3: every GridList, so its rows are the rows
92
+ // of the renderer's keyed `For`). A computed in the body: the compiler records it as a resumable computed (with the
93
+ // host's provider frame), so a resumed page rebuilds it from `content` on its first read.
94
+ const collection = computed(() => buildCollectionFrom(content));
82
95
  const state = createListState({
83
96
  id: props.id,
97
+ collection,
84
98
  selectionMode: props.selectionMode,
85
99
  selectionBehavior: props.selectionBehavior,
86
100
  // Live props: a controlled `selectedKeys={keys()}` stays live (accessor; never written).
@@ -142,9 +156,6 @@ export function GridList(inProps) {
142
156
  direction,
143
157
  }),
144
158
  });
145
- const content = () => (<Collection items={items} idScope={idScope}>
146
- {children}
147
- </Collection>);
148
159
  const itemsOf = items;
149
160
  const isEmpty = itemsOf != null
150
161
  ? () => {
@@ -154,10 +165,7 @@ export function GridList(inProps) {
154
165
  return true;
155
166
  }
156
167
  : renderEmptyState
157
- ? (() => {
158
- const built = collectionOf(content);
159
- return () => built().size === 0;
160
- })()
168
+ ? () => collection().size === 0
161
169
  : () => false;
162
170
  const version = itemsOf != null ? (typeof itemsOf === "function" ? itemsOf : () => itemsOf) : () => null;
163
171
  const wantsStates = isFn(props.class) || isFn(props.style) || !!renderEmptyState;
@@ -177,10 +185,11 @@ export function GridList(inProps) {
177
185
  // Grid attributes (reactive): the render view only needs the size and the focused key.
178
186
  const gridAttrs = computed(() => {
179
187
  const empty = isEmpty();
180
- const collection = buildCollection([]);
188
+ const none = buildCollection([]);
181
189
  const v = {
182
- collection: { ...collection, size: empty ? 0 : 1 },
183
- selectionManager: listSelectionManager(state, collection),
190
+ // Virtualized: the built collection (useGridList `aria-rowcount` = its size).
191
+ collection: isVirtualized ? collection() : { ...none, size: empty ? 0 : 1 },
192
+ selectionManager: listSelectionManager(state, none),
184
193
  disabledKeys: state.disabledKeys,
185
194
  list: shared,
186
195
  };
@@ -195,11 +204,20 @@ export function GridList(inProps) {
195
204
  list: shared,
196
205
  });
197
206
  };
198
- const internal = { state, shared, locale, direction, keyboardNavigationBehavior, isVirtualized, version, filter };
207
+ const internal = { state, shared, locale, direction, keyboardNavigationBehavior, isVirtualized, version, filter, collection };
199
208
  const dom = globalDOMProps(props, GRID_OWNED);
200
209
  const slot = props.slot || undefined;
201
210
  const gridEl = createRef();
202
211
  const rootRef = mergeRefValues(ref, gridEl);
212
+ // Virtualized: the grid element is the scroll container (RAC scrollRef); its focused row stays rendered and its
213
+ // scroll events reach the renderer (the engine follows the scroll position).
214
+ const rootCollection = collection;
215
+ const focusedKey = isVirtualized ? state.selectionState.focusedKey : undefined;
216
+ const virtualScroll = isVirtualized ? customRenderer?.onScroll : undefined;
217
+ const scrolled = (e) => {
218
+ virtualScroll?.(e);
219
+ onScroll?.(e);
220
+ };
203
221
  // react-aria useSelectableCollection autoFocus mount effect (retried until the grid has items).
204
222
  if (autoFocus !== undefined)
205
223
  clientEffect(() => {
@@ -261,13 +279,19 @@ export function GridList(inProps) {
261
279
  },
262
280
  onFocus: (e) => focusStateFocus(ring, e),
263
281
  onBlur: (e) => focusStateBlur(ring, e),
264
- onScroll: (e) => onScroll?.(e),
282
+ onScroll: (e) => scrolled(e),
265
283
  children: () => [
266
284
  <Provider values={[
267
285
  [GridListInternalContext, internal],
268
286
  [SharedElementContext, sharedElements],
269
287
  ]}>
270
- {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({ collection: null, content, idScope })}
288
+ {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({
289
+ collection: rootCollection,
290
+ content,
291
+ idScope,
292
+ scrollRef: gridEl,
293
+ focusedKey,
294
+ })}
271
295
  </Provider>,
272
296
  <Show when={isEmpty}>
273
297
  {() => renderEmptyState ? (<div role="row" aria-rowindex={1} style={{ display: "contents" }}>
@@ -301,13 +325,19 @@ export function GridList(inProps) {
301
325
  const v = view();
302
326
  gridListMouseDown(e, eventOptions(v, e.currentTarget), v);
303
327
  onMouseDown?.(e);
304
- }} onFocus={(e) => focusStateFocus(ring, e)} onBlur={(e) => focusStateBlur(ring, e)} onScroll={onScroll ? (e) => onScroll(e) : undefined}>
328
+ }} onFocus={(e) => focusStateFocus(ring, e)} onBlur={(e) => focusStateBlur(ring, e)} onScroll={virtualScroll || onScroll ? scrolled : undefined}>
305
329
  <Provider values={[
306
330
  [GridListInternalContext, internal],
307
331
  // RAC: the items share one SharedElementTransition scope (a SelectionIndicator animates between them).
308
332
  [SharedElementContext, sharedElements],
309
333
  ]}>
310
- {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({ collection: null, content, idScope })}
334
+ {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({
335
+ collection: rootCollection,
336
+ content,
337
+ idScope,
338
+ scrollRef: gridEl,
339
+ focusedKey,
340
+ })}
311
341
  </Provider>
312
342
  <Show when={isEmpty}>
313
343
  {() => renderEmptyState ? (<div role="row" aria-rowindex={1} style={{ display: "contents" }}>
@@ -331,6 +361,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function
331
361
  if (!ctx)
332
362
  throw new Error("GridListItem must be rendered inside a GridList.");
333
363
  const { state, shared, locale, direction, keyboardNavigationBehavior, isVirtualized } = ctx;
364
+ const built = ctx.collection;
334
365
  const key = item.key;
335
366
  const spec = rowSpec(key, item, props);
336
367
  // Live `isDisabled` (LIVE-SWEEP round 2): the row's spec with the current value (read in the computed / at event time).
@@ -361,6 +392,9 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function
361
392
  list: shared,
362
393
  };
363
394
  const result = gridListItemProps(itemOptions(node), v);
395
+ // RA useGridListItem (virtualized): the row's index in the whole collection (the one-node collection's is 1).
396
+ if (isVirtualized && built)
397
+ result.rowProps["aria-rowindex"] = gridListRowIndex(built(), key);
364
398
  const checkbox = gridListSelectionCheckboxProps({ key, locale, id: checkboxId }, v).checkboxProps;
365
399
  return {
366
400
  ...result,
@@ -616,6 +650,8 @@ export const GridListHeaderInnerContext = resumableContext(createContext(null, {
616
650
  /** A GridListSection represents a section within a GridList (RAC `GridListSection`). */
617
651
  export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, function GridListSection(props, forwardedRef, item) {
618
652
  const { CollectionBranch } = useContext(CollectionRendererContext);
653
+ // The grid list's built collection (rows render from it); null outside a GridList (live content).
654
+ const collection = (useContext(GridListInternalContext)?.collection ?? null);
619
655
  const heading = createSlotId();
620
656
  const ariaLabel = props["aria-label"];
621
657
  const aria = gridListSectionProps({ "aria-label": ariaLabel, headingId: heading.id });
@@ -639,7 +675,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode,
639
675
  [GridListHeaderContext, { ...aria.rowProps }],
640
676
  [GridListHeaderInnerContext, { ...aria.rowHeaderProps, id: heading }],
641
677
  ]}>
642
- {CollectionBranch({ collection: null, parent: item })}
678
+ {CollectionBranch({ collection, parent: item })}
643
679
  </Provider>),
644
680
  };
645
681
  return props.render(attrs, undefined);
@@ -649,7 +685,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode,
649
685
  [GridListHeaderContext, { ...aria.rowProps }],
650
686
  [GridListHeaderInnerContext, { ...aria.rowHeaderProps, id: heading }],
651
687
  ]}>
652
- {CollectionBranch({ collection: null, parent: item })}
688
+ {CollectionBranch({ collection, parent: item })}
653
689
  </Provider>
654
690
  </div>);
655
691
  });
@@ -23,7 +23,8 @@
23
23
  import { createContext, signal, useContext } from "@rivetjs/reactivity";
24
24
  import { resumableContext } from "@rivetjs/runtime/resume-format";
25
25
  import { mergeProps } from "./merge-props.jsx";
26
- import { disableTextSelection, focusWithoutScrolling, getEventTarget, getOwnerDocument, isMac, isVirtualClick, isVirtualPointerEvent, linkState, nodeContains, nonTextInputTypes, openLink, preventFocus, read, restoreTextSelection, } from "./utils.js";
26
+ import { disableTextSelection, focusWithoutScrolling, getEventTarget, getOwnerDocument, isMac, isVirtualClick, isVirtualPointerEvent, linkState, nodeContains, nonTextInputTypes, preventFocus, read, restoreTextSelection, } from "./utils.js";
27
+ import { openLink } from "../utils/links.jsx";
27
28
  /** `Press.track`: bind `dragstart` while pressed. */
28
29
  export const PRESS_TRACK_DRAG = 1;
29
30
  /** `Press.track`: bind `pointerenter` / `pointerleave` while pressed. */
@@ -1,8 +1,7 @@
1
1
  export { isAndroid, isChrome, isFirefox, isIOS, isIPad, isIPhone, isMac, isWebKit, isVirtualClick, isVirtualPointerEvent } from "../platform/index.js";
2
2
  export { getActiveElement, getOwnerDocument, getOwnerWindow, isFocusWithin, isShadowRoot, nodeContains } from "../utils/dom.js";
3
- export { focusWithoutScrolling, runAfterTransition } from "../utils/index.js";
4
- export { chain } from "./merge-props.jsx";
5
- export { linkState, openLink, type Modifiers } from "../utils/links.jsx";
3
+ export { focusWithoutScrolling, runAfterTransition } from "../utils/transitions.js";
4
+ export { linkState } from "../utils/link-state.js";
6
5
  export type PointerType = "mouse" | "pen" | "touch" | "keyboard" | "virtual";
7
6
  export type FocusableElement = HTMLElement | SVGElement;
8
7
  /** A value or an accessor of it (signals qualify): options are read at event time. */