@rivetjs/cdk 0.1.0-alpha.0 → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_adapters/selection.d.ts +13 -2
- package/_adapters/selection.js +39 -15
- package/button/index.jsx +5 -3
- package/collections/components.d.ts +36 -1
- package/collections/components.jsx +224 -16
- package/collections/nodes.d.ts +9 -1
- package/collections/nodes.js +26 -0
- package/collections/row-stamps.d.ts +17 -0
- package/collections/row-stamps.js +108 -0
- package/collections/server-dev.d.ts +1 -0
- package/collections/server-dev.js +2 -0
- package/collections/server-off.d.ts +1 -0
- package/collections/server-off.js +2 -0
- package/collections/server-on.d.ts +1 -0
- package/collections/server-on.jsx +10 -0
- package/drag-drop/directives/drag.d.ts +16 -0
- package/drag-drop/directives/drag.js +28 -0
- package/drag-drop/drag.jsx +6 -4
- package/drag-drop/lazy-refs.js +15 -4
- package/grid-list/behaviour.d.ts +6 -0
- package/grid-list/behaviour.js +25 -7
- package/grid-list/index.d.ts +3 -1
- package/grid-list/index.jsx +60 -24
- package/interactions/press.jsx +2 -1
- package/interactions/utils.d.ts +2 -3
- package/interactions/utils.js +8 -7
- package/listbox/behaviour.jsx +37 -14
- package/listbox/index.jsx +65 -29
- package/menu/index.jsx +38 -7
- package/package.json +4 -4
- package/portal/index.d.ts +8 -0
- package/portal/index.jsx +8 -1
- package/radio-group/index.jsx +4 -1
- package/selection/keyed.js +6 -16
- package/selection/selectable-collection.js +42 -17
- package/sidebar/index.jsx +5 -4
- package/state/menu.d.ts +3 -0
- package/state/menu.js +1 -0
- package/table/index.jsx +512 -168
- package/tree/index.jsx +26 -4
- package/utils/data-attrs.d.ts +4 -2
- package/utils/data-attrs.jsx +27 -3
- package/utils/index.d.ts +1 -15
- package/utils/index.js +5 -102
- package/utils/link-state.d.ts +6 -0
- package/utils/link-state.js +6 -0
- package/utils/links.d.ts +1 -6
- package/utils/links.jsx +2 -4
- package/utils/transitions.d.ts +15 -0
- package/utils/transitions.js +104 -0
- package/virtualizer/behaviour.d.ts +35 -4
- package/virtualizer/behaviour.js +131 -9
- package/virtualizer/collection-renderer.d.ts +62 -0
- package/virtualizer/collection-renderer.jsx +190 -0
- package/virtualizer/engine.d.ts +1 -1
- package/virtualizer/index.d.ts +1 -0
- package/virtualizer/index.jsx +1 -0
- package/virtualizer/list-layout.js +4 -2
- package/virtualizer/state.js +12 -2
- package/virtualizer/virtualizer.d.ts +8 -6
- package/virtualizer/virtualizer.jsx +4 -1
package/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({
|
|
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({
|
|
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">
|
package/utils/data-attrs.d.ts
CHANGED
|
@@ -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;
|
package/utils/data-attrs.jsx
CHANGED
|
@@ -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, ...
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
18
|
-
export
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
42
|
-
|
|
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. */
|