@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
package/tree/index.jsx CHANGED
@@ -15,6 +15,8 @@
15
15
  // `data-hovered` / `data-focused` / `data-focus-visible` written by the handlers, `data-*` values `"true"`.
16
16
  // - `expandedKeys` / `selectedKeys`: a plain value (controlled) or a Signal (two-way).
17
17
  // - `class` = RAC `className` (defaults `rv-Tree`, `rv-TreeItem`, `rv-TreeLoader`).
18
+ // - Inside a `<CollectionVirtualizer layout={ListLayout}>` the flattened collection is the virtualizer's source: the
19
+ // treegrid element is the scroll container (scroll forwarded to the renderer), the focused row is persisted.
18
20
  // `render` (RAC DOMRenderProps) on Tree / TreeItem / TreeSection (`<div>`) and TreeHeader (via GridListHeader): given, the attributes object goes to `render(attrs, renderProps)`;
19
21
  // the aria prop objects become one accessor per key (object-literal accessors over `gridAttr` / `row`: only literal arrows resume), the row content is the `children` accessor.
20
22
  // Deviations:
@@ -85,7 +87,7 @@ export const TreeContext = resumableContext(createContext(null, { id: "rv-tree"
85
87
  export const TreeStateContext = resumableContext(createContext(null, { id: "rv-tree-state" }));
86
88
  const TreeInternalContext = resumableContext(createContext(null, { id: "rv-tree-internal" }));
87
89
  /** Global DOM events Tree handles itself; the user's run from its own handler. */
88
- const TREE_OWNED = ["onMouseDown", "style"];
90
+ const TREE_OWNED = ["onMouseDown", "onScroll", "style"];
89
91
  /**
90
92
  * A tree provides users with a way to navigate nested hierarchical information, with support for
91
93
  * keyboard navigation and selection.
@@ -158,6 +160,8 @@ export function Tree(inProps) {
158
160
  disallowTypeAhead: props.disallowTypeAhead,
159
161
  linkBehavior: props.linkBehavior,
160
162
  isVirtualized: renderer.isVirtualized,
163
+ // RAC: inside a Virtualizer the layout is the keyboard delegate's layout delegate (Page Up / Down).
164
+ layoutDelegate: renderer.layoutDelegate,
161
165
  UNSTABLE_focusOnEntry: props.UNSTABLE_focusOnEntry,
162
166
  direction: props.direction,
163
167
  locale: props.locale,
@@ -202,6 +206,15 @@ export function Tree(inProps) {
202
206
  state: values.state,
203
207
  };
204
208
  const onMouseDown = props.onMouseDown;
209
+ const onScroll = props.onScroll;
210
+ // Virtualized (RAC Virtualizer + ListLayout over the flattened rows): the treegrid element is the scroll container,
211
+ // its scroll events reach the renderer and its focused row stays rendered.
212
+ const virtualScroll = renderer.isVirtualized ? customRenderer?.onScroll : undefined;
213
+ const focusedKey = renderer.isVirtualized ? state.selectionState.focusedKey : undefined;
214
+ const scrolled = (e) => {
215
+ virtualScroll?.(e);
216
+ onScroll?.(e);
217
+ };
205
218
  const slot = props.slot || undefined;
206
219
  const dom = globalDOMProps(props, TREE_OWNED);
207
220
  const description = () => gridListHighlightDescription(gridOpts, view());
@@ -239,13 +252,18 @@ export function Tree(inProps) {
239
252
  gridListMouseDown(e, gridOpts, view());
240
253
  onMouseDown?.(e);
241
254
  },
255
+ onScroll: (e) => scrolled(e),
242
256
  children: () => [
243
257
  <Provider values={[
244
258
  [TreeStateContext, state],
245
259
  [TreeInternalContext, { shared }],
246
260
  [SharedElementContext, sharedElements],
247
261
  ]}>
248
- {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({ collection: flat, scrollRef: el })}
262
+ {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({
263
+ collection: flat,
264
+ scrollRef: el,
265
+ focusedKey,
266
+ })}
249
267
  </Provider>,
250
268
  renderEmptyState ? (<Show when={() => flat().size === 0}>
251
269
  {() => (<div role="row" style={{ display: "contents" }} aria-level="1">
@@ -266,14 +284,18 @@ export function Tree(inProps) {
266
284
  return (<div {...dom} {...treeProps(gridOpts, view(), hasTabbableChild()).gridProps} ref={rootRef} class={rp.class()} style={rp.style()} slot={slot} data-empty={dataBool(flat().size === 0)} data-selection-mode={values.selectionMode() === "none" ? undefined : values.selectionMode()} onKeyDownCapture={(e) => gridListKeyDownCapture(e, gridOpts, view())} onKeyDown={(e) => gridListKeyDown(e, gridOpts, view())} onFocusIn={(e) => gridListFocus(e, gridOpts, view())} onFocusOut={(e) => gridListBlur(e, gridOpts, view())} onFocus={(e) => focusStateFocus(rootInteraction, e)} onBlur={(e) => focusStateBlur(rootInteraction, e)} onMouseDown={(e) => {
267
285
  gridListMouseDown(e, gridOpts, view());
268
286
  onMouseDown?.(e);
269
- }}>
287
+ }} onScroll={virtualScroll || onScroll ? scrolled : undefined}>
270
288
  <Provider values={[
271
289
  [TreeStateContext, state],
272
290
  [TreeInternalContext, { shared }],
273
291
  // RAC: the rows share one SharedElementTransition scope (a SelectionIndicator animates between them).
274
292
  [SharedElementContext, sharedElements],
275
293
  ]}>
276
- {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({ collection: flat, scrollRef: el })}
294
+ {(customRenderer ?? DefaultCollectionRenderer).CollectionRoot({
295
+ collection: flat,
296
+ scrollRef: el,
297
+ focusedKey,
298
+ })}
277
299
  </Provider>
278
300
  {renderEmptyState ? (<Show when={() => flat().size === 0}>
279
301
  {() => (<div role="row" style={{ display: "contents" }} aria-level="1">
@@ -61,11 +61,13 @@ export declare function interactionStateProps(o: InteractionStateOptions): Recor
61
61
  * - `attrs`: the context's DOM attributes (`aria-*`, `data-*`, `id`, `role`, `title`, …) the props leave
62
62
  * `undefined`, for the element: spread `{...readAttrs(attrs)}` (accessor values allowed);
63
63
  * - a disabled element takes none (RA: `isDisabled ? {} : domProps`); the context `ref` is returned for
64
- * `mergeRefValues`.
64
+ * `mergeRefValues`. An accessor `isDisabled` (live / accessor prop) is never read here: the context handlers do
65
+ * nothing and its attributes read `undefined` while it is true (read at event time / in the attribute binding) —
66
+ * a render-time read would re-create the component's enclosing region on its first change on a resumed page.
65
67
  */
66
68
  export declare function mergeFocusableContext<P extends object>(props: P, ctx: {
67
69
  ref?: unknown;
68
70
  [key: string]: unknown;
69
- } | null | undefined, isDisabled?: boolean): [P, unknown, Record<string, unknown> | undefined];
71
+ } | null | undefined, isDisabled?: boolean | (() => boolean | undefined)): [P, unknown, Record<string, unknown> | undefined];
70
72
  /** `attrs` with accessor values read (call inside the element's spread binding: `{...readAttrs(attrs)}`). */
71
73
  export declare function readAttrs(attrs: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
@@ -128,14 +128,17 @@ const FORWARDED_ATTR = /^(?:aria-|data-)|^(?:id|role|title|lang|dir)$/;
128
128
  * - `attrs`: the context's DOM attributes (`aria-*`, `data-*`, `id`, `role`, `title`, …) the props leave
129
129
  * `undefined`, for the element: spread `{...readAttrs(attrs)}` (accessor values allowed);
130
130
  * - a disabled element takes none (RA: `isDisabled ? {} : domProps`); the context `ref` is returned for
131
- * `mergeRefValues`.
131
+ * `mergeRefValues`. An accessor `isDisabled` (live / accessor prop) is never read here: the context handlers do
132
+ * nothing and its attributes read `undefined` while it is true (read at event time / in the attribute binding) —
133
+ * a render-time read would re-create the component's enclosing region on its first change on a resumed page.
132
134
  */
133
135
  export function mergeFocusableContext(props, ctx, isDisabled) {
134
136
  if (!ctx)
135
137
  return [props, undefined, undefined];
136
- const { ref, ...domProps } = ctx;
137
- if (isDisabled)
138
+ const { ref, ...context } = ctx;
139
+ if (isDisabled === true)
138
140
  return [props, ref, undefined];
141
+ const domProps = typeof isDisabled === "function" ? gateContext(context, isDisabled) : context;
139
142
  if (hasLive(props))
140
143
  return liveFocusableMerge(props, ref, domProps);
141
144
  const out = { ...props };
@@ -153,6 +156,27 @@ export function mergeFocusableContext(props, ctx, isDisabled) {
153
156
  }
154
157
  return [out, ref, attrs];
155
158
  }
159
+ // Accessor `isDisabled`: context handlers skip and context DOM attributes read `undefined` while it is true (RA
160
+ // re-renders with `{}`). The wrappers are returned arrows (resumable symbols; captures: the context value, the accessor).
161
+ function gateContext(context, isDisabled) {
162
+ const out = {};
163
+ for (const key in context) {
164
+ const value = context[key];
165
+ if (/^on[A-Z]/.test(key) && typeof value === "function")
166
+ out[key] = gatedHandler(value, isDisabled);
167
+ else if (FORWARDED_ATTR.test(key))
168
+ out[key] = gatedAttr(value, isDisabled);
169
+ else
170
+ out[key] = value;
171
+ }
172
+ return out;
173
+ }
174
+ function gatedHandler(handler, isDisabled) {
175
+ return (...args) => (isDisabled() ? undefined : handler(...args));
176
+ }
177
+ function gatedAttr(value, isDisabled) {
178
+ return () => (isDisabled() ? undefined : typeof value === "function" ? value() : value);
179
+ }
156
180
  // Live props (P5): own getters stay unread — runtime `mergeProps(context, props, handlers)`: own defined values win
157
181
  // per read (context fills `undefined`), context handlers chain after the own ones at call time.
158
182
  function liveFocusableMerge(props, ref, domProps) {
package/utils/index.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { type Disposer } from "@rivetjs/reactivity";
2
2
  export { chain, mergeProps, mergeRefs, type RefLike } from "../interactions/merge-props.jsx";
3
- /** Focus `element` without scrolling it into view (`focus({ preventScroll: true })`, Baseline). */
4
- export declare function focusWithoutScrolling(element: HTMLElement | SVGElement): void;
3
+ export { _resetTransitions, focusWithoutScrolling, runAfterTransition, trackTransitions } from "./transitions.js";
5
4
  /** Offset of an element edge in client coordinates: left/top, or right/bottom when `reverse`. */
6
5
  export declare function getOffset(element: Element, reverse?: boolean, orientation?: "horizontal" | "vertical"): number;
7
6
  /** Clamps `value` into [min, max]. */
@@ -12,19 +11,6 @@ export declare function roundToStepPrecision(value: number, step: number): numbe
12
11
  export declare function snapValueToStep(value: number, min: number | undefined, max: number | undefined, step: number): number;
13
12
  /** Rounds `value` to `digits` digits in `base`. */
14
13
  export declare function toFixedNumber(value: number, digits: number, base?: number): number;
15
- /**
16
- * Start tracking transitions in `doc` (react-aria does this at import; Rivet modules have no
17
- * import-time effects, so the first `runAfterTransition` call does it). Transitions already running
18
- * then are picked up from `document.getAnimations()`.
19
- */
20
- export declare function trackTransitions(doc?: Document): void;
21
- /** Test hook: stop tracking and forget running transitions and queued callbacks. */
22
- export declare function _resetTransitions(): void;
23
- /**
24
- * Run `fn` once every running CSS transition in the document finished (`fn(true)`), or on the next
25
- * frame when none runs (`fn(false)`; one frame lets a transition on mount start). Returns a cancel function.
26
- */
27
- export declare function runAfterTransition(fn: (isTransition: boolean) => void): () => void;
28
14
  export interface LabelingProps {
29
15
  id?: string;
30
16
  "aria-label"?: string;
package/utils/index.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // useFormReset, useUpdateEffect; react-stately utils/number (clamp, snapValueToStep, toFixedNumber).
5
5
  //
6
6
  // `chain` / `mergeProps` / `mergeRefs` live in `../interactions/merge-props.tsx` (a resume-compiled
7
- // module, so chained handlers stay resumable) and are re-exported here.
7
+ // module, so chained handlers stay resumable), `focusWithoutScrolling` / `runAfterTransition` in `./transitions.ts`;
8
+ // both are re-exported here.
8
9
  // Rivet mapping of the hooks (no hooks, no re-renders):
9
10
  // - `useLayoutEffect` / `useEffect` → `clientEffect(fn, { when })` from `@rivetjs/runtime/dom` in a
10
11
  // component (never runs on the server, resumes at its trigger), or `effect` in browser-only code.
@@ -14,10 +15,9 @@
14
15
  import { effect, untrack } from "@rivetjs/reactivity";
15
16
  import { createId } from "@rivetjs/runtime/id";
16
17
  export { chain, mergeProps, mergeRefs } from "../interactions/merge-props.jsx";
17
- /** Focus `element` without scrolling it into view (`focus({ preventScroll: true })`, Baseline). */
18
- export function focusWithoutScrolling(element) {
19
- element.focus({ preventScroll: true });
20
- }
18
+ // Leaf module (DOCS-COLD 2026-10-20): `interactions/utils.ts` imports these from there, so focus / overlay helpers
19
+ // never reach this barrel's `merge-props` re-export (chunk owner walks follow every static import).
20
+ export { _resetTransitions, focusWithoutScrolling, runAfterTransition, trackTransitions } from "./transitions.js";
21
21
  /** Offset of an element edge in client coordinates: left/top, or right/bottom when `reverse`. */
22
22
  export function getOffset(element, reverse, orientation = "horizontal") {
23
23
  const rect = element.getBoundingClientRect();
@@ -69,103 +69,6 @@ export function toFixedNumber(value, digits, base = 10) {
69
69
  const pow = Math.pow(base, digits);
70
70
  return Math.round(value * pow) / pow;
71
71
  }
72
- // --- runAfterTransition ---------------------------------------------------------------------------
73
- // Elements currently transitioning → their transitioning CSS properties (a set, not a count: Chrome
74
- // sometimes fires both transitionend and transitioncancel).
75
- const transitionsByElement = new Map();
76
- const transitionCallbacks = new Set();
77
- let tracking = null;
78
- const isTransitionEvent = (event) => "propertyName" in event;
79
- const eventTarget = (event) => {
80
- try {
81
- return event.composedPath?.()[0] ?? event.target;
82
- }
83
- catch {
84
- return event.target;
85
- }
86
- };
87
- function onTransitionStart(event) {
88
- const target = eventTarget(event);
89
- if (!isTransitionEvent(event) || !target)
90
- return;
91
- let transitions = transitionsByElement.get(target);
92
- if (!transitions) {
93
- transitionsByElement.set(target, (transitions = new Set()));
94
- // On the element itself: it must fire even when the node is removed mid-transition.
95
- target.addEventListener("transitioncancel", onTransitionEnd, { once: true });
96
- }
97
- transitions.add(event.propertyName);
98
- }
99
- function onTransitionEnd(event) {
100
- const target = eventTarget(event);
101
- if (!isTransitionEvent(event) || !target)
102
- return;
103
- const properties = transitionsByElement.get(target);
104
- if (!properties)
105
- return;
106
- properties.delete(event.propertyName);
107
- if (properties.size === 0) {
108
- target.removeEventListener("transitioncancel", onTransitionEnd);
109
- transitionsByElement.delete(target);
110
- }
111
- if (transitionsByElement.size === 0) {
112
- for (const callback of transitionCallbacks) {
113
- callback(true);
114
- transitionCallbacks.delete(callback);
115
- }
116
- }
117
- }
118
- /**
119
- * Start tracking transitions in `doc` (react-aria does this at import; Rivet modules have no
120
- * import-time effects, so the first `runAfterTransition` call does it). Transitions already running
121
- * then are picked up from `document.getAnimations()`.
122
- */
123
- export function trackTransitions(doc = document) {
124
- if (tracking === doc)
125
- return;
126
- tracking = doc;
127
- doc.addEventListener("transitionrun", onTransitionStart);
128
- doc.addEventListener("transitionend", onTransitionEnd);
129
- try {
130
- for (const animation of doc.getAnimations?.() ?? []) {
131
- const css = animation;
132
- const target = css.effect?.target;
133
- if (css.transitionProperty && target && animation.playState === "running")
134
- onTransitionStart(Object.assign(new Event("transitionrun"), { propertyName: css.transitionProperty, composedPath: () => [target] }));
135
- }
136
- }
137
- catch { }
138
- }
139
- /** Test hook: stop tracking and forget running transitions and queued callbacks. */
140
- export function _resetTransitions() {
141
- tracking?.removeEventListener("transitionrun", onTransitionStart);
142
- tracking?.removeEventListener("transitionend", onTransitionEnd);
143
- tracking = null;
144
- transitionsByElement.clear();
145
- transitionCallbacks.clear();
146
- }
147
- function cleanupDetachedElements() {
148
- for (const [target] of transitionsByElement)
149
- if ("isConnected" in target && !target.isConnected)
150
- transitionsByElement.delete(target);
151
- }
152
- /**
153
- * Run `fn` once every running CSS transition in the document finished (`fn(true)`), or on the next
154
- * frame when none runs (`fn(false)`; one frame lets a transition on mount start). Returns a cancel function.
155
- */
156
- export function runAfterTransition(fn) {
157
- trackTransitions();
158
- const frame = requestAnimationFrame(() => {
159
- cleanupDetachedElements();
160
- if (transitionsByElement.size === 0)
161
- return fn(false);
162
- transitionCallbacks.add(fn);
163
- });
164
- return () => {
165
- cancelAnimationFrame(frame);
166
- transitionCallbacks.delete(fn);
167
- };
168
- }
169
72
  /**
170
73
  * react-aria `useLabels`: with both `aria-label` and `aria-labelledby`, the element labels itself too
171
74
  * (its own id joins `aria-labelledby`, so both are read); id lists are normalized; without any label
@@ -0,0 +1,6 @@
1
+ /** Set while `openLink` dispatches its event (react-aria's `openLink.isOpening`); read by usePress / focus-visible / collections. */
2
+ export declare const linkState: {
3
+ isOpening: boolean;
4
+ };
5
+ /** Whether `openLink` is dispatching its synthetic click (item click handlers let it through). */
6
+ export declare const isOpeningLink: () => boolean;
@@ -0,0 +1,6 @@
1
+ // The flag `openLink` (`./links.tsx`) sets while it dispatches its synthetic click. A leaf module (DOCS-COLD
2
+ // 2026-10-20): focus-visible / usePress read it through `../interactions/utils.ts` without reaching the router module.
3
+ /** Set while `openLink` dispatches its event (react-aria's `openLink.isOpening`); read by usePress / focus-visible / collections. */
4
+ export const linkState = { isOpening: false };
5
+ /** Whether `openLink` is dispatching its synthetic click (item click handlers let it through). */
6
+ export const isOpeningLink = () => linkState.isOpening;
package/utils/links.d.ts CHANGED
@@ -34,12 +34,7 @@ export interface LinkDOMProps {
34
34
  }
35
35
  /** Whether a click on `link` with `modifiers` should navigate in-document (same origin, `_self`, no download, no modifier). */
36
36
  export declare function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers): boolean;
37
- /** Set while `openLink` dispatches its event (react-aria's `openLink.isOpening`); read by usePress / focus-visible / collections. */
38
- export declare const linkState: {
39
- isOpening: boolean;
40
- };
41
- /** Whether `openLink` is dispatching its synthetic click (item click handlers let it through). */
42
- export declare const isOpeningLink: () => boolean;
37
+ export { isOpeningLink, linkState } from "./link-state.js";
43
38
  /**
44
39
  * Open `target` as if clicked with `modifiers` (new tab / window / download follow the browser's
45
40
  * rules for them). Focuses the link without scrolling, then dispatches a click (WebKit on macOS:
package/utils/links.jsx CHANGED
@@ -2,6 +2,7 @@ import { createContext, provide, useContext } from "@rivetjs/reactivity";
2
2
  import { resumableContext } from "@rivetjs/runtime/resume-format";
3
3
  import { isIPad, isMac, isWebKit } from "../platform/index.js";
4
4
  import { renderSlot } from "@rivetjs/runtime/slot";
5
+ import { linkState } from "./link-state.js";
5
6
  /** Whether a click on `link` with `modifiers` should navigate in-document (same origin, `_self`, no download, no modifier). */
6
7
  export function shouldClientNavigate(link, modifiers) {
7
8
  // getAttribute: Firefox defaults link.target to "_parent" inside an iframe.
@@ -14,10 +15,7 @@ export function shouldClientNavigate(link, modifiers) {
14
15
  !modifiers.altKey && // download
15
16
  !modifiers.shiftKey);
16
17
  }
17
- /** Set while `openLink` dispatches its event (react-aria's `openLink.isOpening`); read by usePress / focus-visible / collections. */
18
- export const linkState = { isOpening: false };
19
- /** Whether `openLink` is dispatching its synthetic click (item click handlers let it through). */
20
- export const isOpeningLink = () => linkState.isOpening;
18
+ export { isOpeningLink, linkState } from "./link-state.js";
21
19
  /**
22
20
  * Open `target` as if clicked with `modifiers` (new tab / window / download follow the browser's
23
21
  * rules for them). Focuses the link without scrolling, then dispatches a click (WebKit on macOS:
@@ -0,0 +1,15 @@
1
+ /** Focus `element` without scrolling it into view (`focus({ preventScroll: true })`, Baseline). */
2
+ export declare function focusWithoutScrolling(element: HTMLElement | SVGElement): void;
3
+ /**
4
+ * Start tracking transitions in `doc` (react-aria does this at import; Rivet modules have no
5
+ * import-time effects, so the first `runAfterTransition` call does it). Transitions already running
6
+ * then are picked up from `document.getAnimations()`.
7
+ */
8
+ export declare function trackTransitions(doc?: Document): void;
9
+ /** Test hook: stop tracking and forget running transitions and queued callbacks. */
10
+ export declare function _resetTransitions(): void;
11
+ /**
12
+ * Run `fn` once every running CSS transition in the document finished (`fn(true)`), or on the next
13
+ * frame when none runs (`fn(false)`; one frame lets a transition on mount start). Returns a cancel function.
14
+ */
15
+ export declare function runAfterTransition(fn: (isTransition: boolean) => void): () => void;
@@ -0,0 +1,104 @@
1
+ // react-aria `focusWithoutScrolling` + `runAfterTransition` (Apache-2.0, © Adobe; ported in `./index.ts` before
2
+ // DOCS-COLD 2026-10-20). A leaf module: no imports, so the focus / overlay helpers that need these two do not reach
3
+ // `./index.ts` (which re-exports `merge-props`); `./index.ts` re-exports them.
4
+ /** Focus `element` without scrolling it into view (`focus({ preventScroll: true })`, Baseline). */
5
+ export function focusWithoutScrolling(element) {
6
+ element.focus({ preventScroll: true });
7
+ }
8
+ // --- runAfterTransition ---------------------------------------------------------------------------
9
+ // Elements currently transitioning → their transitioning CSS properties (a set, not a count: Chrome
10
+ // sometimes fires both transitionend and transitioncancel).
11
+ const transitionsByElement = new Map();
12
+ const transitionCallbacks = new Set();
13
+ let tracking = null;
14
+ const isTransitionEvent = (event) => "propertyName" in event;
15
+ const eventTarget = (event) => {
16
+ try {
17
+ return event.composedPath?.()[0] ?? event.target;
18
+ }
19
+ catch {
20
+ return event.target;
21
+ }
22
+ };
23
+ function onTransitionStart(event) {
24
+ const target = eventTarget(event);
25
+ if (!isTransitionEvent(event) || !target)
26
+ return;
27
+ let transitions = transitionsByElement.get(target);
28
+ if (!transitions) {
29
+ transitionsByElement.set(target, (transitions = new Set()));
30
+ // On the element itself: it must fire even when the node is removed mid-transition.
31
+ target.addEventListener("transitioncancel", onTransitionEnd, { once: true });
32
+ }
33
+ transitions.add(event.propertyName);
34
+ }
35
+ function onTransitionEnd(event) {
36
+ const target = eventTarget(event);
37
+ if (!isTransitionEvent(event) || !target)
38
+ return;
39
+ const properties = transitionsByElement.get(target);
40
+ if (!properties)
41
+ return;
42
+ properties.delete(event.propertyName);
43
+ if (properties.size === 0) {
44
+ target.removeEventListener("transitioncancel", onTransitionEnd);
45
+ transitionsByElement.delete(target);
46
+ }
47
+ if (transitionsByElement.size === 0) {
48
+ for (const callback of transitionCallbacks) {
49
+ callback(true);
50
+ transitionCallbacks.delete(callback);
51
+ }
52
+ }
53
+ }
54
+ /**
55
+ * Start tracking transitions in `doc` (react-aria does this at import; Rivet modules have no
56
+ * import-time effects, so the first `runAfterTransition` call does it). Transitions already running
57
+ * then are picked up from `document.getAnimations()`.
58
+ */
59
+ export function trackTransitions(doc = document) {
60
+ if (tracking === doc)
61
+ return;
62
+ tracking = doc;
63
+ doc.addEventListener("transitionrun", onTransitionStart);
64
+ doc.addEventListener("transitionend", onTransitionEnd);
65
+ try {
66
+ for (const animation of doc.getAnimations?.() ?? []) {
67
+ const css = animation;
68
+ const target = css.effect?.target;
69
+ if (css.transitionProperty && target && animation.playState === "running")
70
+ onTransitionStart(Object.assign(new Event("transitionrun"), { propertyName: css.transitionProperty, composedPath: () => [target] }));
71
+ }
72
+ }
73
+ catch { }
74
+ }
75
+ /** Test hook: stop tracking and forget running transitions and queued callbacks. */
76
+ export function _resetTransitions() {
77
+ tracking?.removeEventListener("transitionrun", onTransitionStart);
78
+ tracking?.removeEventListener("transitionend", onTransitionEnd);
79
+ tracking = null;
80
+ transitionsByElement.clear();
81
+ transitionCallbacks.clear();
82
+ }
83
+ function cleanupDetachedElements() {
84
+ for (const [target] of transitionsByElement)
85
+ if ("isConnected" in target && !target.isConnected)
86
+ transitionsByElement.delete(target);
87
+ }
88
+ /**
89
+ * Run `fn` once every running CSS transition in the document finished (`fn(true)`), or on the next
90
+ * frame when none runs (`fn(false)`; one frame lets a transition on mount start). Returns a cancel function.
91
+ */
92
+ export function runAfterTransition(fn) {
93
+ trackTransitions();
94
+ const frame = requestAnimationFrame(() => {
95
+ cleanupDetachedElements();
96
+ if (transitionsByElement.size === 0)
97
+ return fn(false);
98
+ transitionCallbacks.add(fn);
99
+ });
100
+ return () => {
101
+ cancelAnimationFrame(frame);
102
+ transitionCallbacks.delete(fn);
103
+ };
104
+ }
@@ -1,9 +1,9 @@
1
1
  import { type Signal } from "@rivetjs/reactivity";
2
2
  import type { StyleObject } from "@rivetjs/runtime/style";
3
3
  import type { Key } from "../collections/index.js";
4
- import { type CollectionOptions, type Node } from "./collection.js";
4
+ import { type Collection, type CollectionOptions, type Node } from "./collection.js";
5
5
  import { type ScrollView } from "./dom.js";
6
- import type { Layout } from "./layout.js";
6
+ import { Layout } from "./layout.js";
7
7
  import type { LayoutInfo } from "./layout-info.js";
8
8
  import type { ReusableView } from "./reusable-view.js";
9
9
  import { type VirtualizerState } from "./state.js";
@@ -34,12 +34,26 @@ export interface VirtualView<T = unknown> {
34
34
  children: VirtualView<T>[];
35
35
  }
36
36
  export type MaybeAccessor<T> = T | (() => T);
37
+ /** A layout class (`ListLayout`, `TableLayout`, …) or a factory (`() => new ListLayout()`): resumable, never an instance. */
38
+ export type LayoutInput<O> = (new () => Layout<Node<any>, O>) | (() => Layout<Node<any>, O>);
39
+ /** A layout instance from a class or a factory (RAC `Virtualizer` takes a class or an instance). */
40
+ export declare function createLayout<O>(layout: LayoutInput<O>): Layout<Node<any>, O>;
37
41
  /** Serializable component state. */
38
42
  export interface VirtualizerModel<T, O> {
39
43
  id: string;
44
+ /** Standalone `<Virtualizer items>`: the data (collection mode: unused, `source` holds the host's collection). */
40
45
  items: MaybeAccessor<readonly T[]>;
41
- /** Layout factory (`() => new ListLayout()`), called on the server and once in the browser. */
42
- layout: () => Layout<Node<T>, O>;
46
+ /**
47
+ * Collection mode (a collection component inside `<Virtualizer layout>`, RAC CollectionRenderer): the host's built
48
+ * collection (an accessor: a rebuilt collection re-renders the window).
49
+ */
50
+ source?: (() => Collection<Node<T>> | null | undefined) | undefined;
51
+ /** Collection mode: the host's focused key (persisted, RAC `usePersistedKeys`). */
52
+ focus?: (() => Key | null) | undefined;
53
+ /** Collection mode: the host's scroll container (a ref accessor; standalone: the element with `id`). */
54
+ scrollRef?: (() => Element | null | undefined) | undefined;
55
+ /** Layout factory (`() => new ListLayout()`) or class (`ListLayout`), instantiated on the server and once in the browser. */
56
+ layout: LayoutInput<O>;
43
57
  layoutOptions?: O;
44
58
  collection: CollectionOptions<T>;
45
59
  initialSize: {
@@ -85,8 +99,25 @@ export declare function visibleViews<T, O>(model: VirtualizerModel<T, O>): Virtu
85
99
  /** Keys of `views`: the rows' `For` items. Keys keep their identity across windows, so resumed server rows are
86
100
  * adopted and kept (a `For` re-creates an adopted row whose item changed; view descriptors change on every move). */
87
101
  export declare const viewKeys: <T>(views: readonly VirtualView<T>[]) => Key[];
102
+ /** Keys in layout order (top to bottom, then start to end); the engine's reusable views come in recycling order. */
103
+ export declare const orderedViewKeys: <T>(views: readonly VirtualView<T>[]) => Key[];
104
+ /** `rowOrder` parent of the root views. */
105
+ export declare const ROOT_VIEW: {};
106
+ /**
107
+ * The keyed rows' order: layout order (top to bottom) — except while DOM focus is inside the collection, when kept
108
+ * keys keep their DOM order and new ones go last. A keyed list moves (detaches) a kept row to reorder it, and a
109
+ * detached focused option / cell loses focus (keyboard navigation across the window edge inserts rows before the
110
+ * focused one). Positions are absolute: DOM order is not visual order; options carry `aria-posinset`, rows
111
+ * `aria-rowindex` (RAC renders in the engine's recycling order).
112
+ */
113
+ export declare function rowOrder(model: VirtualizerModel<unknown, unknown>, parent: unknown, views: readonly VirtualView<unknown>[]): Key[];
88
114
  /** The view with `key` in `views` (a row's descriptor); a row being removed keeps its last descriptor. */
89
115
  export declare function viewOf<T, O>(model: VirtualizerModel<T, O>, views: readonly VirtualView<T>[], key: Key): VirtualView<T>;
116
+ /**
117
+ * Collection mode: the child views of the view with key `parentKey` (a host part's `CollectionBranch`: section items,
118
+ * table header rows / columns, body rows, cells), from the whole projected tree; `[]` when it is not rendered.
119
+ */
120
+ export declare function childViews<T, O>(model: VirtualizerModel<T, O>, parentKey: Key): VirtualView<T>[];
90
121
  /** Scroll handler of the scroller: drive the engine, load more near the end, then the caller's `onScroll`. */
91
122
  export declare function virtualizerScroll<T, O>(model: VirtualizerModel<T, O>, event: Event): void;
92
123
  /** focusin: the focused view stays rendered (persisted) while scrolled out of view. */