@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
@@ -5,18 +5,19 @@
5
5
  //
6
6
  // One implementation per helper: platform probes come from `../platform` (return `undefined` on the
7
7
  // server), DOM / shadow-DOM functions from `../utils/dom.ts`, `runAfterTransition` /
8
- // `focusWithoutScrolling` from `../utils/index.ts`, `chain` from `./merge-props.tsx`, `openLink` /
9
- // `linkState` from `../utils/links.tsx` (the flag usePress and focus-visible read is the one the
10
- // router sets). They are re-exported here for the hooks (and existing importers).
8
+ // `focusWithoutScrolling` from the leaf `../utils/transitions.ts`, `linkState` from the leaf
9
+ // `../utils/link-state.ts` (the flag usePress and focus-visible read is the one `openLink` sets).
10
+ // They are re-exported here for the hooks. Leaves only (DOCS-COLD 2026-10-20): `chain` (`./merge-props.tsx`)
11
+ // and `openLink` (`../utils/links.tsx`) are imported from their modules, so a focus / overlay helper
12
+ // importing this file does not pull prop merging or the router module into its chunk.
11
13
  import { getEventTarget as domEventTarget } from "../utils/dom.js";
12
14
  import { isIOS, isWebKit } from "../platform/index.js";
13
15
  import { getActiveElement, getOwnerDocument, getOwnerWindow, isShadowRoot, nodeContains } from "../utils/dom.js";
14
- import { focusWithoutScrolling, runAfterTransition } from "../utils/index.js";
16
+ import { focusWithoutScrolling, runAfterTransition } from "../utils/transitions.js";
15
17
  export { isAndroid, isChrome, isFirefox, isIOS, isIPad, isIPhone, isMac, isWebKit, isVirtualClick, isVirtualPointerEvent } from "../platform/index.js";
16
18
  export { getActiveElement, getOwnerDocument, getOwnerWindow, isFocusWithin, isShadowRoot, nodeContains } from "../utils/dom.js";
17
- export { focusWithoutScrolling, runAfterTransition } from "../utils/index.js";
18
- export { chain } from "./merge-props.jsx";
19
- export { linkState, openLink } from "../utils/links.jsx";
19
+ export { focusWithoutScrolling, runAfterTransition } from "../utils/transitions.js";
20
+ export { linkState } from "../utils/link-state.js";
20
21
  /** Read a `MaybeAccessor` (functions are called; signals are functions). */
21
22
  export function read(value) {
22
23
  return typeof value === "function" ? value() : value;
@@ -17,9 +17,10 @@
17
17
  // list's `onAction(key)`, then the bubbling `react-aria-item-action` (useSelectableItem).
18
18
  // - Option hover callbacks: `rv-hover-start` / `rv-hover-end` events on the option element (same scheme).
19
19
  // - Items marked `data-rv-long-press` (a long press interaction, `@rivetjs/cdk/morph/item`) select on press up.
20
- // Deviation: no drag and drop (C8), no `isVirtualized` posinset/setsize (Virtualizer lane).
20
+ // Deviation: no drag and drop (C8).
21
21
  import { signal, untrack } from "@rivetjs/reactivity";
22
22
  import { listCollection, listSelectionManager } from "../collections/list-state.js";
23
+ import { itemPosition } from "../collections/nodes.js";
23
24
  import { SelectionManager } from "../selection/manager.js";
24
25
  import { isFocusedKey } from "../selection/keyed.js";
25
26
  import { selectableCollectionAutoFocus, selectableCollectionBlur, selectableCollectionClearVirtualFocus, selectableCollectionFocus, selectableCollectionKeyDown, selectableCollectionKeyDownCapture, selectableCollectionMouseDown, selectableCollectionVirtualFocus, } from "../selection/selectable-collection.js";
@@ -179,20 +180,28 @@ export function optionProps(props, state, data) {
179
180
  const states = () => selectableItemStates(options());
180
181
  const isFocused = () => states().isFocused;
181
182
  const id = getItemId(data.id, key);
183
+ const attrs = {
184
+ id,
185
+ role: "option",
186
+ "data-key": String(key),
187
+ "data-key-type": typeof key === "number" ? "number" : undefined,
188
+ "data-collection": data.id,
189
+ "aria-label": props["aria-label"],
190
+ "aria-labelledby": props.labelId,
191
+ "aria-describedby": props.descriptionId,
192
+ "aria-disabled": () => (states().isDisabled ? "true" : undefined),
193
+ "aria-selected": () => (state.selectionState.selectionMode === "none" ? undefined : states().isSelected ? "true" : "false"),
194
+ tabIndex: () => (read(data.shouldUseVirtualFocus) || states().isDisabled ? undefined : isFocusedKey(state.selectionState, key) ? 0 : -1),
195
+ };
196
+ if (data.isVirtualized)
197
+ // useOption: a virtualized list renders a window of its options; their position comes from the collection.
198
+ // (Object-literal arrows: resumable closures.)
199
+ Object.assign(attrs, {
200
+ "aria-posinset": () => optionPosInSet(state, key),
201
+ "aria-setsize": () => optionSetSize(state, key),
202
+ });
182
203
  return {
183
- optionProps: {
184
- id,
185
- role: "option",
186
- "data-key": String(key),
187
- "data-key-type": typeof key === "number" ? "number" : undefined,
188
- "data-collection": data.id,
189
- "aria-label": props["aria-label"],
190
- "aria-labelledby": props.labelId,
191
- "aria-describedby": props.descriptionId,
192
- "aria-disabled": () => (states().isDisabled ? "true" : undefined),
193
- "aria-selected": () => (state.selectionState.selectionMode === "none" ? undefined : states().isSelected ? "true" : "false"),
194
- tabIndex: () => (read(data.shouldUseVirtualFocus) || states().isDisabled ? undefined : isFocusedKey(state.selectionState, key) ? 0 : -1),
195
- },
204
+ optionProps: attrs,
196
205
  isSelected: () => states().isSelected,
197
206
  isDisabled: () => states().isDisabled,
198
207
  isFocused,
@@ -203,6 +212,20 @@ export function optionProps(props, state, data) {
203
212
  hasAction: () => states().hasAction,
204
213
  };
205
214
  }
215
+ /** The built collection of a list state (a virtualized ListBox renders from it). */
216
+ function builtOf(state) {
217
+ const c = state.collection;
218
+ return typeof c === "function" ? c() : c;
219
+ }
220
+ // Position within the option's section (`itemPosition`: sections are groups; RA counts the header in `index`).
221
+ const optionPosInSet = (state, key) => {
222
+ const c = builtOf(state);
223
+ return c ? itemPosition(c, key)?.[0] : undefined;
224
+ };
225
+ const optionSetSize = (state, key) => {
226
+ const c = builtOf(state);
227
+ return c ? itemPosition(c, key)?.[1] : undefined;
228
+ };
206
229
  function rootOf(e) {
207
230
  return e.currentTarget;
208
231
  }
package/listbox/index.jsx CHANGED
@@ -2,10 +2,12 @@
2
2
  // Parts: ListBox, ListBoxItem, ListBoxSection, ListBoxLoadMoreItem (+ Header / Text / Separator / Collection /
3
3
  // SelectionIndicator inside); contexts ListBoxContext, ListStateContext.
4
4
  // Rivet:
5
- // - Collections run in live mode (`../collections/components.tsx`): options render in place and the listbox reads
6
- // its collection back from the rendered options at event time (no item model on resumed pages). A ListStateContext
7
- // from a parent (Select, ComboBox) whose state carries a built `collection` renders node by node instead
8
- // (`CollectionRoot({ collection })`), filtered by the SelectableCollectionContext `filter`.
5
+ // - A standalone ListBox builds its collection (`computed(buildCollectionFrom(content))` in the body, B5 build mode;
6
+ // LAZY-ROWS T3, 2026-10-19 — live mode before) and renders node by node (`CollectionRoot({ collection })`): options
7
+ // are the rows of the renderer's keyed `For` (`(key) => renderNodeAt(collection, key)`, lazy-row eligible), the
8
+ // handlers navigate the built collection; a resumed page rebuilds it from the content on first need (no per-option
9
+ // state). A ListStateContext from a parent (Select, ComboBox) whose state carries a built `collection` renders from
10
+ // that one, filtered by the SelectableCollectionContext `filter`.
9
11
  // - One delegated handler set on the listbox element runs useSelectableList + every option's useSelectableItem /
10
12
  // useHover (`./behaviour.tsx`); option `data-hovered` / `data-pressed` are written by those handlers, root
11
13
  // `data-focused` / `data-focus-visible` bind to the ListData focus signals they set; option `data-selected` / `data-focused` / `data-focus-visible` / `data-disabled` bind
@@ -17,14 +19,13 @@
17
19
  // objects with a `children` array → `ListBoxSection` + `Header`).
18
20
  // - Option label / description slot ids (useSlotId) are created only for non-text children (text children can hold
19
21
  // no `<Text slot>`: RA's effect would drop the ids anyway).
20
- // - `isEmpty`: `items` length, else the built size of the children when `renderEmptyState` is given (a build pass),
21
- // else false; with a parent state, its collection size.
22
+ // - `isEmpty`: `items` length, else the built size of the children when `renderEmptyState` is given, else false; with a parent state, its collection size.
22
23
  // `render` (RAC DOMRenderProps) on ListBox (`<div>`), ListBoxSection (`<section>`) and ListBoxItem (`<div>` / `<a>`): given, the attributes object goes to `render(attrs, renderProps)`
23
24
  // (custom events as `on<Type>` keys: `onRv-item-press`). Deviations: no drag and drop (`dragAndDropHooks`, drop indicators: C8); `ListBoxContext` is not re-provided to the
24
25
  // options (its value holds the JSX children: not serializable); no FocusScope; the SharedElementTransition scope is
25
26
  // a `SharedElementContext` provide;
26
27
  // live-mode filtering hides options but keeps empty sections.
27
- import { createContext, provide, untrack, useContext } from "@rivetjs/reactivity";
28
+ import { computed, createContext, provide, untrack, useContext } from "@rivetjs/reactivity";
28
29
  import { Show, clientEffect, ref as createRef } from "@rivetjs/runtime/dom";
29
30
  import { isSlot } from "@rivetjs/runtime/slot";
30
31
  import { ItemNode, LoaderNode, SectionNode } from "../collections/base.js";
@@ -105,7 +106,16 @@ export function ListBox(inProps) {
105
106
  }
106
107
  function StandaloneListBox(input) {
107
108
  const { props, listBoxRef, children } = input;
108
- const layoutDelegate = useContext(CollectionRendererContext).layoutDelegate;
109
+ const renderer = useContext(CollectionRendererContext);
110
+ const layoutDelegate = renderer.layoutDelegate;
111
+ const items = props.items;
112
+ const dependencies = props.dependencies;
113
+ const content = () => Collection({ items: items, children, dependencies });
114
+ // The list renders from its built collection (RAC: inside a Virtualizer only the visible window has options; LAZY-ROWS
115
+ // T3: every standalone list, so its options are the rows of the renderer's keyed `For`, `(key) => renderNodeAt(…)`).
116
+ // A computed in the body (the compiler records it as a resumable computed with the host's provider frame): a resumed
117
+ // page rebuilds it from `content` on its first read, so no per-option state ships.
118
+ const collection = computed(() => buildCollectionFrom(content));
109
119
  // Live props: a controlled `selectedKeys={keys()}` stays live (accessor; never written); the copy reads no
110
120
  // `selectedKeys` getter (a resumed region is not re-created on a change).
111
121
  const selectedKeys = liveKeys(props, "selectedKeys");
@@ -117,16 +127,13 @@ function StandaloneListBox(input) {
117
127
  id: props.id ?? mergedIdOf(useContext(SelectableCollectionContext)?.id) ?? createId(),
118
128
  items: undefined,
119
129
  children: undefined,
120
- collection: undefined,
130
+ collection,
121
131
  layoutDelegate,
122
132
  });
123
- const items = props.items;
124
- const dependencies = props.dependencies;
125
- const content = () => Collection({ items: items, children, dependencies });
126
133
  // isEmpty: the items' length, or (with an empty state to show) the size of a built collection of the children.
127
134
  const empty = props.renderEmptyState;
128
135
  // Static children do not change: their emptiness is computed once (a build pass, only with an empty state).
129
- const staticEmpty = items != null ? false : empty && children != null ? untrack(() => buildCollectionFrom(() => Collection({ children })).size === 0) : children == null;
136
+ const staticEmpty = items != null ? false : empty && children != null ? untrack(() => collection().size === 0) : children == null;
130
137
  const isEmpty = () => (items != null ? isEmptyIterable(typeof items === "function" ? items() : items) : staticEmpty);
131
138
  return <ListBoxInner state={state} props={own} listBoxRef={listBoxRef} content={content} isEmpty={isEmpty}/>;
132
139
  }
@@ -149,6 +156,12 @@ function ListBoxInner(input) {
149
156
  // RAC mergeIds: the ListBox's own id wins over the parent's (ComboBox listbox / Autocomplete collection id).
150
157
  const id = props.id || state.id;
151
158
  const data = createListData(props, id, state.collection ? undefined : filter, useContext(RouterContext));
159
+ const renderer = useContext(CollectionRendererContext);
160
+ // Resumable (as GridList): the render path's lazy collection slot captures `customRenderer`, undefined for the default renderer.
161
+ const customRenderer = renderer === DefaultCollectionRenderer ? undefined : renderer;
162
+ const isVirtualized = !!renderer.isVirtualized && !!state.collection;
163
+ if (isVirtualized)
164
+ data.isVirtualized = true;
152
165
  // A parent's built collection (filtered like RAC UNSTABLE_useFilteredListState); live mode: none.
153
166
  const provided = state.collection;
154
167
  const builtCollection = () => {
@@ -171,6 +184,7 @@ function ListBoxInner(input) {
171
184
  onFocus: props.onFocus,
172
185
  onBlur: props.onBlur,
173
186
  onFocusChange: props.onFocusChange,
187
+ isVirtualized: isVirtualized || undefined,
174
188
  };
175
189
  // mergeIds: a parent's label self-reference (ComboBox `labelsProps(listBoxId, …)`) follows the merged id.
176
190
  // A host's accessor (Select: the menu is labelled by its Label only while one renders) is read in the binding.
@@ -212,13 +226,18 @@ function ListBoxInner(input) {
212
226
  });
213
227
  }
214
228
  const setRef = mergeRefValues(root, userRef);
215
- const renderer = useContext(CollectionRendererContext);
216
- // Resumable (as GridList): the render path's lazy collection slot captures `customRenderer`, undefined for the default renderer.
217
- const customRenderer = renderer === DefaultCollectionRenderer ? undefined : renderer;
229
+ // Virtualized: the listbox element is the scroll container (RAC scrollRef); its focused option stays rendered.
230
+ const focusedKey = isVirtualized ? state.selectionState.focusedKey : undefined;
231
+ const virtualScroll = isVirtualized ? customRenderer?.onScroll : undefined;
232
+ const virtualScrolled = (e) => {
233
+ virtualScroll?.(e);
234
+ onScroll?.(e);
235
+ };
236
+ const scrolled = virtualScroll ? virtualScrolled : onScroll;
218
237
  const sharedElements = {};
219
238
  if (props.render) {
220
- // The body is a compiled `<Provider>` binding (resumable, as Menu), not the eager `provide()` chain of the default
221
- // path: a render closure capturing that chain's result cannot be serialized.
239
+ // The body is a compiled `<Provider>` binding (resumable, as Menu; the default path below uses the same one): a render
240
+ // closure capturing an eager `provide()` chain's result cannot be serialized.
222
241
  const renderBody = (<Provider values={[
223
242
  [ListStateContext, state],
224
243
  [ListBoxDataContext, data],
@@ -226,7 +245,7 @@ function ListBoxInner(input) {
226
245
  [SectionContext, { name: "ListBoxSection", render: ListBoxSectionInner }],
227
246
  [SharedElementContext, sharedElements],
228
247
  ]}>
229
- {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({ collection, content, scrollRef: root })}
248
+ {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({ collection, content, scrollRef: root, focusedKey })}
230
249
  </Provider>);
231
250
  const attrs = {
232
251
  ...dom,
@@ -303,7 +322,7 @@ function ListBoxInner(input) {
303
322
  listBoxDragStart(e, behaviour, state);
304
323
  onDragStart?.(e);
305
324
  },
306
- onScroll: (e) => onScroll?.(e),
325
+ onScroll: (e) => scrolled?.(e),
307
326
  // Custom events: the spread takes `on<Type>` keys (no `on:` form).
308
327
  "onReact-aria-focus": (e) => listBoxVirtualFocusEvent(e, behaviour, state),
309
328
  "onReact-aria-clear-focus": (e) => listBoxClearFocusEvent(e, behaviour, state),
@@ -318,9 +337,6 @@ function ListBoxInner(input) {
318
337
  };
319
338
  return props.render(attrs, values);
320
339
  }
321
- const body = provide(ListStateContext, state, () => provide(ListBoxDataContext, data, () => provide(SeparatorContext, { elementType: "div" }, () => provide(SectionContext, { name: "ListBoxSection", render: ListBoxSectionInner }, () =>
322
- // RAC: the options share one SharedElementTransition scope (a SelectionIndicator animates between them).
323
- provide(SharedElementContext, sharedElements, () => renderer.CollectionRoot({ collection, content, scrollRef: root }))))));
324
340
  return (<div {...dom} ref={setRef} id={id} role="listbox" preload="visible" slot={slot} class={rp.class()} style={rp.style()} data-collection={id} aria-label={ariaLabel} aria-labelledby={labelledByAccessor ? labelledByAccessor() : ariaLabelledby} aria-describedby={ariaDescribedby} aria-details={ariaDetails} aria-orientation={orientation} aria-multiselectable={state.selectionState.selectionMode === "multiple" ? "true" : undefined} tabIndex={tabIndex()} data-empty={dataBool(isEmpty())} data-focused={dataBool(data.isFocused())} data-focus-visible={dataBool(data.isFocused() && data.focusVisible())} data-layout={layout} data-orientation={orientation} data-rk={listBoxNavigationKeys(layout, orientation)} onKeyDownCapture={(e) => listBoxKeyDownCapture(e, behaviour, state)} onKeyDown={(e) => {
325
341
  onKeyDown?.(e);
326
342
  listBoxKeyDown(e, behaviour, state);
@@ -360,8 +376,21 @@ function ListBoxInner(input) {
360
376
  }} onDragStart={(e) => {
361
377
  listBoxDragStart(e, behaviour, state);
362
378
  onDragStart?.(e);
363
- }} onScroll={onScroll} on:react-aria-focus={(e) => listBoxVirtualFocusEvent(e, behaviour, state)} on:react-aria-clear-focus={(e) => listBoxClearFocusEvent(e, behaviour, state)}>
364
- {body}
379
+ }} onScroll={scrolled} on:react-aria-focus={(e) => listBoxVirtualFocusEvent(e, behaviour, state)} on:react-aria-clear-focus={(e) => listBoxClearFocusEvent(e, behaviour, state)}>
380
+ {/* A compiled `<Provider>` (as the `render` path, GridList, Table): the renderer's keyed `For` is created inside its
381
+ child region, so the reads of its `each` (the collection: items, Autocomplete filter) re-run only that region on a
382
+ resumed page. An eager provide() chain in the body created the For in the ListBox's enclosing region: its first
383
+ change re-created the whole ListBox (a fresh state whose focus the adopted options' resumed bindings never see). */}
384
+ <Provider values={[
385
+ [ListStateContext, state],
386
+ [ListBoxDataContext, data],
387
+ [SeparatorContext, { elementType: "div" }],
388
+ [SectionContext, { name: "ListBoxSection", render: ListBoxSectionInner }],
389
+ // RAC: the options share one SharedElementTransition scope (a SelectionIndicator animates between them).
390
+ [SharedElementContext, sharedElements],
391
+ ]}>
392
+ {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({ collection, content, scrollRef: root, focusedKey })}
393
+ </Provider>
365
394
  {renderEmptyState ? (<Show when={isEmpty}>
366
395
  {() => (<div role="option" style={{ display: "contents" }}>
367
396
  {renderEmptyState(values)}
@@ -495,6 +524,9 @@ function listBoxItem(props, forwardedRef, item, state, data) {
495
524
  const ariaDisabled = optionAttrs["aria-disabled"];
496
525
  const ariaSelected = optionAttrs["aria-selected"];
497
526
  const tabIndex = optionAttrs.tabIndex;
527
+ // useOption (virtualized list): the option's position in the whole collection.
528
+ const posInSet = optionAttrs["aria-posinset"];
529
+ const setSize = optionAttrs["aria-setsize"];
498
530
  const ref = forwardedRef;
499
531
  // `ping` / `referrerpolicy` are missing from the anchor typings: spread.
500
532
  // `data-rv-disabled` (the item's own isDisabled, read by DOM collections) goes through the spread: an accessor when live.
@@ -520,6 +552,8 @@ function listBoxItem(props, forwardedRef, item, state, data) {
520
552
  "aria-describedby": () => describedBy?.(),
521
553
  "aria-disabled": () => ariaDisabled(),
522
554
  "aria-selected": () => ariaSelected(),
555
+ "aria-posinset": () => posInSet?.(),
556
+ "aria-setsize": () => setSize?.(),
523
557
  tabindex: () => (href ? (tabIndex() ?? -1) : tabIndex()),
524
558
  "data-selected": () => dataBool(opt.isSelected()),
525
559
  "data-disabled": () => dataBool(opt.isDisabled()),
@@ -537,9 +571,9 @@ function listBoxItem(props, forwardedRef, item, state, data) {
537
571
  };
538
572
  return props.render(attrs, values);
539
573
  }
540
- return href ? (<a {...linkDom} ref={ref} id={id} role="option" class={rp.class()} style={rp.style()} href={href} hreflang={hrefLang} target={target} rel={rel} download={download === true ? "" : download || undefined} data-key={String(key)} data-key-type={keyType} data-collection={data.id} data-text-value={textValue} data-rv-action={onAction ? "" : undefined} data-rv-press={hasPress ? "" : undefined} aria-label={ariaLabel} aria-labelledby={labelledBy?.()} aria-describedby={describedBy?.()} aria-disabled={ariaDisabled()} aria-selected={ariaSelected()} tabIndex={tabIndex() ?? -1} data-selected={dataBool(opt.isSelected())} data-disabled={dataBool(opt.isDisabled())} data-focused={dataBool(opt.isFocused())} data-focus-visible={dataBool(opt.isFocusVisible())} data-selection-mode={selectionMode === "none" ? undefined : selectionMode} onKeyDown={itemKeyDown ? (e) => keyboardKeyDown({ onKeyDown: itemKeyDown }, e) : undefined} onKeyUp={itemKeyUp ? (e) => keyboardKeyUp({ onKeyUp: itemKeyUp }, e) : undefined} on:rv-item-action={onAction ? () => onAction() : undefined} on:rv-item-press={hasPress ? (e) => itemPressEvent(e, onPressStart, onPressEnd, onPressChange, onPress, onPressUp, onClick) : undefined} on:rv-hover-start={hasHover ? (e) => hoverStart(e, onHoverStart, onHoverChange) : undefined} on:rv-hover-end={hasHover ? (e) => hoverEnd(e, onHoverEnd, onHoverChange) : undefined}>
574
+ return href ? (<a {...linkDom} ref={ref} id={id} role="option" class={rp.class()} style={rp.style()} href={href} hreflang={hrefLang} target={target} rel={rel} download={download === true ? "" : download || undefined} data-key={String(key)} data-key-type={keyType} data-collection={data.id} data-text-value={textValue} data-rv-action={onAction ? "" : undefined} data-rv-press={hasPress ? "" : undefined} aria-label={ariaLabel} aria-labelledby={labelledBy?.()} aria-describedby={describedBy?.()} aria-disabled={ariaDisabled()} aria-selected={ariaSelected()} aria-posinset={posInSet?.()} aria-setsize={setSize?.()} tabIndex={tabIndex() ?? -1} data-selected={dataBool(opt.isSelected())} data-disabled={dataBool(opt.isDisabled())} data-focused={dataBool(opt.isFocused())} data-focus-visible={dataBool(opt.isFocusVisible())} data-selection-mode={selectionMode === "none" ? undefined : selectionMode} onKeyDown={itemKeyDown ? (e) => keyboardKeyDown({ onKeyDown: itemKeyDown }, e) : undefined} onKeyUp={itemKeyUp ? (e) => keyboardKeyUp({ onKeyUp: itemKeyUp }, e) : undefined} on:rv-item-action={onAction ? () => onAction() : undefined} on:rv-item-press={hasPress ? (e) => itemPressEvent(e, onPressStart, onPressEnd, onPressChange, onPress, onPressUp, onClick) : undefined} on:rv-hover-start={hasHover ? (e) => hoverStart(e, onHoverStart, onHoverChange) : undefined} on:rv-hover-end={hasHover ? (e) => hoverEnd(e, onHoverEnd, onHoverChange) : undefined}>
541
575
  {content()}
542
- </a>) : (<div {...linkDom} ref={ref} id={id} role="option" class={rp.class()} style={rp.style()} data-key={String(key)} data-key-type={keyType} data-collection={data.id} data-text-value={textValue} data-rv-action={onAction ? "" : undefined} data-rv-press={hasPress ? "" : undefined} aria-label={ariaLabel} aria-labelledby={labelledBy?.()} aria-describedby={describedBy?.()} aria-disabled={ariaDisabled()} aria-selected={ariaSelected()} tabIndex={tabIndex()} data-selected={dataBool(opt.isSelected())} data-disabled={dataBool(opt.isDisabled())} data-focused={dataBool(opt.isFocused())} data-focus-visible={dataBool(opt.isFocusVisible())} data-selection-mode={selectionMode === "none" ? undefined : selectionMode} onKeyDown={itemKeyDown ? (e) => keyboardKeyDown({ onKeyDown: itemKeyDown }, e) : undefined} onKeyUp={itemKeyUp ? (e) => keyboardKeyUp({ onKeyUp: itemKeyUp }, e) : undefined} on:rv-item-action={onAction ? () => onAction() : undefined} on:rv-item-press={hasPress ? (e) => itemPressEvent(e, onPressStart, onPressEnd, onPressChange, onPress, onPressUp, onClick) : undefined} on:rv-hover-start={hasHover ? (e) => hoverStart(e, onHoverStart, onHoverChange) : undefined} on:rv-hover-end={hasHover ? (e) => hoverEnd(e, onHoverEnd, onHoverChange) : undefined}>
576
+ </a>) : (<div {...linkDom} ref={ref} id={id} role="option" class={rp.class()} style={rp.style()} data-key={String(key)} data-key-type={keyType} data-collection={data.id} data-text-value={textValue} data-rv-action={onAction ? "" : undefined} data-rv-press={hasPress ? "" : undefined} aria-label={ariaLabel} aria-labelledby={labelledBy?.()} aria-describedby={describedBy?.()} aria-disabled={ariaDisabled()} aria-selected={ariaSelected()} aria-posinset={posInSet?.()} aria-setsize={setSize?.()} tabIndex={tabIndex()} data-selected={dataBool(opt.isSelected())} data-disabled={dataBool(opt.isDisabled())} data-focused={dataBool(opt.isFocused())} data-focus-visible={dataBool(opt.isFocusVisible())} data-selection-mode={selectionMode === "none" ? undefined : selectionMode} onKeyDown={itemKeyDown ? (e) => keyboardKeyDown({ onKeyDown: itemKeyDown }, e) : undefined} onKeyUp={itemKeyUp ? (e) => keyboardKeyUp({ onKeyUp: itemKeyUp }, e) : undefined} on:rv-item-action={onAction ? () => onAction() : undefined} on:rv-item-press={hasPress ? (e) => itemPressEvent(e, onPressStart, onPressEnd, onPressChange, onPress, onPressUp, onClick) : undefined} on:rv-hover-start={hasHover ? (e) => hoverStart(e, onHoverStart, onHoverChange) : undefined} on:rv-hover-end={hasHover ? (e) => hoverEnd(e, onHoverEnd, onHoverChange) : undefined}>
543
577
  {content()}
544
578
  </div>);
545
579
  };
@@ -576,8 +610,10 @@ export const ListBoxLoadMoreItem = /*#__PURE__*/ createLeafComponent(LoaderNode,
576
610
  const state = useContext(ListStateContext);
577
611
  const { isLoading, onLoadMore, scrollOffset } = props;
578
612
  const sentinel = createRef();
579
- // RAC passes `state.collection`; live mode: the sentinel re-arms when the loading state changes.
580
- const collection = state?.collection ? () => resolveCollection(state.collection) : () => (read(isLoading), null);
613
+ // RAC passes `state.collection` (re-created on every ListBox render: a loading change re-arms the observer); Rivet: the
614
+ // sentinel re-arms when the collection or the loading state changes (a live `isLoading` does not rebuild the collection).
615
+ const built = state?.collection;
616
+ const collection = () => (read(isLoading), built ? resolveCollection(built) : null);
581
617
  loadMoreSentinel({ collection, onLoadMore, scrollOffset }, sentinel);
582
618
  const dom = globalDOMProps(props, ["style"]);
583
619
  const cls = props.class ?? "rv-ListBoxLoadingIndicator";
package/menu/index.jsx CHANGED
@@ -19,6 +19,9 @@
19
19
  // (useSafelyMouseToSubmenu) attaches while the submenu is mounted.
20
20
  // - render props are accessors; `data-hovered` / `data-pressed` / `data-focus-visible` are written by the
21
21
  // delegated handler (signals only when a `class` / `style` / `children` function asks for them).
22
+ // - Inside a `<CollectionVirtualizer layout={ListLayout}>` the menu builds its collection (`builtCollectionOf`): the
23
+ // state's `virtual` (built collection + layout delegate) makes the delegated handler navigate it, items render as
24
+ // views with `aria-posinset` / `aria-setsize`, the menu element is the scroll container. Submenus: not covered.
22
25
  // - `class` = RAC `className` (defaults `rv-Menu`, `rv-MenuItem`, `rv-MenuSection`, `rv-MenuLoadingIndicator`).
23
26
  // - SelectableCollectionContext (Autocomplete) merges under the menu props: `shouldUseVirtualFocus` /
24
27
  // `disallowTypeAhead` / `autoFocus` may be accessors (read in bindings; a client effect mirrors them into
@@ -26,8 +29,8 @@
26
29
  // `Show`, live collection); `onFocusIn` goes on the menu element; `react-aria-focus` /
27
30
  // `react-aria-clear-focus` are handled on the menu element.
28
31
  // `render` (RAC DOMRenderProps) on Menu (`<div>`), MenuSection (`<section>`) and MenuItem (`<div>` / `<a>`): given, the attributes object goes to `render(attrs, renderProps)`. Deviations: no FocusScope around the menu (the popover's focus scope contains focus;
29
- // a standalone menu has no FocusManager); no virtualizer; `isEmpty` counts rendered (visible) items (live
30
- // collection); live-mode filtering keeps empty sections (RAC drops them); no `--trigger-width` on the popover.
32
+ // a standalone menu has no FocusManager); `isEmpty` counts rendered (visible) items (live collection; a
33
+ // virtualized menu: its rendered window); live-mode filtering keeps empty sections (RAC drops them); no `--trigger-width` on the popover.
31
34
  import { _regionState, onCleanup, signal, untrack, useContext } from "@rivetjs/reactivity";
32
35
  import { Show, clientEffect, ref as createRef } from "@rivetjs/runtime/dom";
33
36
  import { isSlot } from "@rivetjs/runtime/slot";
@@ -39,7 +42,8 @@ import { globalDOMProps } from "../utils/dom-props.js";
39
42
  import { dataBool } from "../utils/data-attrs.jsx";
40
43
  import { PressResponder } from "../interactions/pressable.jsx";
41
44
  import { OverlayTriggerStateContext, PopoverContext } from "../overlay/contexts.js";
42
- import { CollectionRendererContext, Collection, SectionContext, createBranchComponent, createLeafComponent, isCollectionProbe, isHidden, } from "../collections/components.jsx";
45
+ import { CollectionRendererContext, Collection, SectionContext, createBranchComponent, createLeafComponent, builtCollectionOf, isCollectionProbe, isHidden, } from "../collections/components.jsx";
46
+ import { itemPosition } from "../collections/nodes.js";
43
47
  import { CollectionNode, ItemNode, LoaderNode, SectionNode } from "../collections/base.js";
44
48
  import { HeaderContext } from "../header/index.jsx";
45
49
  import { SeparatorContext } from "../separator/index.jsx";
@@ -60,6 +64,11 @@ function liveItemDisabled(state, key, own) {
60
64
  return () => itemDisabled(state, key, !!own());
61
65
  }
62
66
  /** `aria-disabled` of a live disabled flag. */
67
+ /** `aria-posinset` (0) / `aria-setsize` (1) of a virtualized menu's item (`itemPosition`: per section). */
68
+ function menuItemPosition(state, key, part) {
69
+ const collection = state.virtual?.collection();
70
+ return collection ? itemPosition(collection, key)?.[part] : undefined;
71
+ }
63
72
  function ariaDisabledOf(disabled) {
64
73
  return () => (disabled() ? "true" : undefined);
65
74
  }
@@ -214,6 +223,15 @@ export function Menu(inProps) {
214
223
  const triggerState = useContext(RootMenuTriggerStateContext);
215
224
  const scope = useContext(SubmenuScopeContext);
216
225
  const userOnClose = props.onClose;
226
+ const items = props.items, children = props.children;
227
+ const content = () => <Collection items={items} children={children}/>;
228
+ const renderer = useContext(CollectionRendererContext);
229
+ // RAC: inside a Virtualizer the menu renders from its built collection (only the visible window has items; the
230
+ // delegated handler navigates the built collection, page keys use the layout). A local const arrow (resumable)
231
+ // over a per-instance memo key.
232
+ const builtKey = renderer.isVirtualized ? {} : null;
233
+ const virtualCollection = () => builtCollectionOf(builtKey, content);
234
+ const virtual = builtKey ? { collection: virtualCollection, layoutDelegate: renderer.layoutDelegate } : undefined;
217
235
  const state = createMenuState({
218
236
  selectionMode: props.selectionMode,
219
237
  // Live props: a controlled `selectedKeys={keys()}` stays live (accessor; never written).
@@ -235,6 +253,7 @@ export function Menu(inProps) {
235
253
  level: scope ? scope.sub.submenuLevel + 1 : 0,
236
254
  ...(scope ? { submenuTrigger: scope.sub, parentId: scope.parentId } : {}),
237
255
  ...(props.id ? { id: props.id } : {}),
256
+ ...(virtual ? { virtual } : {}),
238
257
  });
239
258
  // MenuTrigger's reactive menu id (DialogContext `idRef` pattern): the trigger's aria-controls follows a Menu `id`
240
259
  // (RA mergeIds; on a resumed page the merge runs in the browser, where idSignal has no updaters).
@@ -284,9 +303,12 @@ export function Menu(inProps) {
284
303
  return () => menuSubmenuClosed(sub);
285
304
  });
286
305
  });
287
- const items = props.items, children = props.children;
288
- const content = () => <Collection items={items} children={children}/>;
289
- const { CollectionRoot } = useContext(CollectionRendererContext);
306
+ const { CollectionRoot } = renderer;
307
+ // Virtualized: the menu element is the scroll container (RAC scrollRef); its focused item stays rendered and its
308
+ // scroll events reach the renderer.
309
+ const rootCollection = builtKey ? virtualCollection : null;
310
+ const focusedKey = builtKey ? state.focusedKey : undefined;
311
+ const virtualScroll = builtKey ? renderer.onScroll : undefined;
290
312
  // Items first (they count themselves for `data-empty`), then the element.
291
313
  const body = (<Provider values={[
292
314
  [MenuStateContext, state],
@@ -301,7 +323,7 @@ export function Menu(inProps) {
301
323
  // Ensure the root MenuTriggerState is defined, in case Menu is rendered outside a MenuTrigger.
302
324
  [RootMenuTriggerStateContext, state.root],
303
325
  ]}>
304
- {() => CollectionRoot({ collection: null, content })}
326
+ {() => CollectionRoot({ collection: rootCollection, content, scrollRef: element, focusedKey })}
305
327
  </Provider>);
306
328
  const renderEmptyState = props.renderEmptyState;
307
329
  const dom = globalDOMProps(props, MENU_OWNED);
@@ -366,6 +388,7 @@ export function Menu(inProps) {
366
388
  onDragStart?.(e);
367
389
  },
368
390
  onScroll: (e) => {
391
+ virtualScroll?.(e);
369
392
  menuEvent(e, aria, state);
370
393
  onScroll?.(e);
371
394
  },
@@ -416,6 +439,7 @@ export function Menu(inProps) {
416
439
  menuEvent(e, aria, state);
417
440
  onDragStart?.(e);
418
441
  }} onScroll={(e) => {
442
+ virtualScroll?.(e);
419
443
  menuEvent(e, aria, state);
420
444
  onScroll?.(e);
421
445
  }} on:react-aria-focus={(e) => menuVirtualFocus(e, state)} on:react-aria-clear-focus={(e) => menuClearVirtualFocus(e, state)}>
@@ -601,6 +625,13 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function Men
601
625
  "aria-haspopup": hasSubmenu ? "menu" : undefined,
602
626
  "data-has-submenu": dataBool(hasSubmenu),
603
627
  };
628
+ if (state.virtual)
629
+ // useMenuItem (virtualized): the item's position comes from the built collection (only a window is rendered).
630
+ // (Object-literal arrows: resumable closures.)
631
+ Object.assign(attrs, {
632
+ "aria-posinset": () => menuItemPosition(state, key, 0),
633
+ "aria-setsize": () => menuItemPosition(state, key, 1),
634
+ });
604
635
  const renderItem = () => {
605
636
  count();
606
637
  const content = body();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivetjs/cdk",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.1",
4
4
  "description": "Headless, accessible components and utilities for Rivet: overlays, collections, focus, interactions, dates, drag and drop.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -614,9 +614,9 @@
614
614
  }
615
615
  },
616
616
  "dependencies": {
617
- "@rivetjs/kit": "0.1.0-alpha.0",
618
- "@rivetjs/reactivity": "0.1.0-alpha.0",
619
- "@rivetjs/runtime": "0.1.0-alpha.0"
617
+ "@rivetjs/kit": "0.1.0-alpha.1",
618
+ "@rivetjs/reactivity": "0.1.0-alpha.1",
619
+ "@rivetjs/runtime": "0.1.0-alpha.1"
620
620
  },
621
621
  "engines": {
622
622
  "node": ">=24"
package/portal/index.d.ts CHANGED
@@ -8,6 +8,14 @@ export declare function PortalProvider(props: {
8
8
  container?: PortalMount;
9
9
  children?: Content | (() => Content);
10
10
  }): import("@rivetjs/runtime/jsx-runtime").CompiledElement;
11
+ /**
12
+ * Rivet: optional hooks of the overlay layer, unset by default (cdk alone pays one property read per move).
13
+ * `place(host, from)` runs right after a `<Portal>` host moved from its in-place parent `from` (the trigger's subtree,
14
+ * still connected) to its mount; a styled layer copies scope attributes there (`@rivetjs/ui` stamps its theme scope).
15
+ */
16
+ export declare const portalHooks: {
17
+ place?(host: HTMLElement, from: Element | null): void;
18
+ };
11
19
  export interface PortalProps {
12
20
  /** Where to render. @default the nearest PortalProvider's container, else document.body */
13
21
  mount?: PortalMount;
package/portal/index.jsx CHANGED
@@ -30,6 +30,12 @@ export function PortalProvider(props) {
30
30
  {renderSlot(children)}
31
31
  </Provider>);
32
32
  }
33
+ /**
34
+ * Rivet: optional hooks of the overlay layer, unset by default (cdk alone pays one property read per move).
35
+ * `place(host, from)` runs right after a `<Portal>` host moved from its in-place parent `from` (the trigger's subtree,
36
+ * still connected) to its mount; a styled layer copies scope attributes there (`@rivetjs/ui` stamps its theme scope).
37
+ */
38
+ export const portalHooks = {};
33
39
  /**
34
40
  * Render `children` at the end of `mount` (default `document.body`), inside a
35
41
  * `<div data-rivet-portal>`. Server: rendered in place and moved at `load`.
@@ -45,8 +51,9 @@ export function Portal(props) {
45
51
  if (element.parentNode !== target) {
46
52
  // Moving a node blurs focus inside it (content that focused itself on mount, e.g. a menu item).
47
53
  const focused = element.ownerDocument.activeElement;
48
- const inside = !!focused && element.contains(focused);
54
+ const inside = !!focused && element.contains(focused), from = element.parentElement;
49
55
  target.append(element);
56
+ portalHooks.place?.(element, from);
50
57
  if (inside && element.ownerDocument.activeElement !== focused)
51
58
  focused.focus({ preventScroll: true });
52
59
  }
@@ -98,8 +98,11 @@ function RadioGroupItems(props) {
98
98
  return (<label {...itemDom} onMouseDown={(event) => {
99
99
  // RAC Radio mergeProps(DOMProps, labelProps): the user's handler first, then RA useRadio's
100
100
  // "Prevent label from being focused when mouse down on it" (the click still focuses the input).
101
+ // Not for a press on the preset's visible native radio itself: canceling its mousedown cancels its
102
+ // focus (RA's input usePress focuses it on pointerdown), so arrow keys after a click had no target.
101
103
  option.onMouseDown?.(event);
102
- event.preventDefault();
104
+ if (!(event.target instanceof HTMLInputElement))
105
+ event.preventDefault();
103
106
  }} class={itemClass} data-selected={state.selectedValue() === valueProp ? "true" : undefined} data-disabled={disabled || undefined} data-readonly={isReadOnly || undefined} data-invalid={invalid() ? "true" : undefined} data-required={isRequired || undefined}>
104
107
  <input type="radio" id={inputId} class={props.inputClass} name={state.name} form={props.form} value={option.value} disabled={disabled || undefined} required={(isRequired && behaviour === "native") || undefined} aria-describedby={[descriptionId, invalid() && showsError() ? ids.errorMessageId : undefined, groupDescription].filter(Boolean).join(" ") || undefined} data-rv-error={validityError(state.validation)} checked={state.selectedValue() === option.value} onChange={(event) => radioChange(state, option.value, event)} onFocus={(event) => {
105
108
  radioFocus(state, option.value, event);
@@ -7,7 +7,7 @@
7
7
  // The selectors live in module WeakMaps keyed by the state's signals (shared by spread copies of a state, e.g.
8
8
  // `state/list.ts` ListState, and never serialized). `selector` is owner-free: its watcher lives while
9
9
  // bindings read keys.
10
- import { selector } from "@rivetjs/reactivity";
10
+ import { memberKeys, selector } from "@rivetjs/reactivity";
11
11
  /** `selectionOf` result: not selected, in the selected Set, or covered by an "all" selection. */
12
12
  export const NOT_SELECTED = 0, IN_SELECTION = 1, ALL_SELECTED = 2;
13
13
  const EMPTY = new Set();
@@ -18,20 +18,10 @@ const hasFocus = new WeakMap();
18
18
  const selectionIn = (key, selection) => (selection === "all" ? ALL_SELECTED : selection.has(key) ? IN_SELECTION : NOT_SELECTED);
19
19
  const isEmpty = (_key, selection) => selection !== "all" && selection.size === 0;
20
20
  const onlyKey = () => [0];
21
- /** Keys whose membership may differ between two selections (null: "all" involved, compare every key). */
22
- function changedKeys(next, previous) {
23
- if (next === "all" || previous === "all")
24
- return null;
25
- const keys = [];
26
- for (const key of next)
27
- if (!previous.has(key))
28
- keys.push(key);
29
- for (const key of previous)
30
- if (!next.has(key))
31
- keys.push(key);
32
- return keys;
33
- }
34
- const changedFocus = (next, previous) => [next, previous];
21
+ // Change hints are reactivity's own (`memberKeys`: keys in one of two Sets, null when "all" is involved; the default
22
+ // pair hint for focus): resume knows them, so a lazy collection row reading `selectionOf` / `isFocusedKey` of a
23
+ // signal-backed state wakes only when its key flips (LAZY-T6 keyed wake, `_trackReads` keyed hook).
24
+ const changedKeys = memberKeys;
35
25
  /** The selection read of `state` (react-stately `useMultipleSelectionState`, or the single-key backing). */
36
26
  function readerOf(state) {
37
27
  const single = state.selectedKey;
@@ -63,7 +53,7 @@ export function isFocusedKey(state, key) {
63
53
  const id = state.focusedKey;
64
54
  let select = focused.get(id);
65
55
  if (select === undefined)
66
- focused.set(id, (select = selector(id, undefined, { keys: changedFocus })));
56
+ focused.set(id, (select = selector(id)));
67
57
  return select(key);
68
58
  }
69
59
  /** `state.focusedKey() != null`; a binding re-runs only when that flips (not on every focus move). */