phase 0.0.1-alpha.1 → 0.0.2
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/LICENSE +21 -0
- package/README.md +917 -0
- package/dist/ease.d.ts +34 -0
- package/dist/ease.d.ts.map +1 -0
- package/dist/ease.js +56 -0
- package/dist/ease.js.map +1 -0
- package/dist/index-D3epuj2x.d.ts +249 -0
- package/dist/index-D3epuj2x.d.ts.map +1 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +702 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +1066 -0
- package/dist/react.js.map +1 -0
- package/dist/reduced-motion-CEJtegNG.js +948 -0
- package/dist/reduced-motion-CEJtegNG.js.map +1 -0
- package/package.json +78 -17
- package/index.js +0 -24
package/dist/react.js
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { clamp01, easeOutCubic } from "./ease.js";
|
|
3
|
+
import { _ as missingContextError, a as subscribeDpr, c as createLoop, d as subscribeMediaQuery, f as createSight, h as invalidDurationError, i as readDpr, l as createLifecycle, n as prefersReducedMotion, o as createRenderState, r as whenIdle, s as createScrollProgress, t as REDUCED_MOTION_QUERY, u as readMediaQuery } from "./reduced-motion-CEJtegNG.js";
|
|
4
|
+
import { createContext, use, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
//#region src/react/use-synced-ref/index.ts
|
|
7
|
+
/**
|
|
8
|
+
* Ref whose `.current` is always the latest value, updated synchronously on
|
|
9
|
+
* every render. Readable from any callback or effect without triggering re-render.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const propsRef = useSyncedRef(props);
|
|
13
|
+
* useEffect(() => {
|
|
14
|
+
* // propsRef.current is always fresh
|
|
15
|
+
* }, []);
|
|
16
|
+
*/
|
|
17
|
+
function useSyncedRef(value) {
|
|
18
|
+
"use no memo";
|
|
19
|
+
const ref = useRef(value);
|
|
20
|
+
ref.current = value;
|
|
21
|
+
return ref;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/react/use-stable-callback/index.ts
|
|
25
|
+
/**
|
|
26
|
+
* Returns a function with **stable identity** that always calls the latest
|
|
27
|
+
* version of `callback`. Safe in deps arrays and as a prop to `memo()`'d children.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const handleClick = useStableCallback((e: MouseEvent) => {
|
|
31
|
+
* console.log(latestValue); // always fresh
|
|
32
|
+
* });
|
|
33
|
+
*/
|
|
34
|
+
function useStableCallback(callback) {
|
|
35
|
+
"use no memo";
|
|
36
|
+
const callbackRef = useRef(callback);
|
|
37
|
+
callbackRef.current = callback;
|
|
38
|
+
return useCallback((...args) => callbackRef.current(...args), []);
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/react/_internal/degraded-config/index.ts
|
|
42
|
+
/**
|
|
43
|
+
* Map flat `degraded` / `degradedFps` hook options onto the loop's discriminated
|
|
44
|
+
* union. `degradedFps` is only meaningful in `'throttle'` mode.
|
|
45
|
+
*/
|
|
46
|
+
function degradedConfig(degraded, degradedFps) {
|
|
47
|
+
if (degraded === "pause") return { degraded: "pause" };
|
|
48
|
+
if (degraded === "ignore") return { degraded: "ignore" };
|
|
49
|
+
return {
|
|
50
|
+
degraded: "throttle",
|
|
51
|
+
degradedFps
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/react/use-loop/index.ts
|
|
56
|
+
const INITIAL_STATE$3 = {
|
|
57
|
+
phase: "idle",
|
|
58
|
+
phaseReason: "initial",
|
|
59
|
+
quality: "full",
|
|
60
|
+
qualityReason: void 0
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Ref-based animation loop that never triggers re-renders from the frame loop.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const { ref, phase } = useLoop({
|
|
67
|
+
* onTick: (frame) => {
|
|
68
|
+
* ref.current.style.transform = `translateX(${frame.elapsed * 0.1}px)`;
|
|
69
|
+
* },
|
|
70
|
+
* });
|
|
71
|
+
* return <div ref={ref} />;
|
|
72
|
+
*/
|
|
73
|
+
function useLoop(options) {
|
|
74
|
+
const { fps, enabled = true, reducedMotion, degraded, degradedFps, intersectionOptions } = options;
|
|
75
|
+
const onTickRef = useSyncedRef(options.onTick);
|
|
76
|
+
const internalRef = useRef(null);
|
|
77
|
+
const ref = options.ref ?? internalRef;
|
|
78
|
+
const [state, setState] = useState(INITIAL_STATE$3);
|
|
79
|
+
const loopRef = useRef(null);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const element = ref.current;
|
|
82
|
+
if (!element || !enabled) {
|
|
83
|
+
setState(INITIAL_STATE$3);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const loop = createLoop({
|
|
87
|
+
element,
|
|
88
|
+
onTick: (frame) => onTickRef.current(frame),
|
|
89
|
+
fps,
|
|
90
|
+
reducedMotion,
|
|
91
|
+
intersectionOptions,
|
|
92
|
+
...degradedConfig(degraded, degradedFps),
|
|
93
|
+
onPhaseChange: (phase, reason) => {
|
|
94
|
+
const current = loopRef.current;
|
|
95
|
+
setState({
|
|
96
|
+
phase,
|
|
97
|
+
phaseReason: reason,
|
|
98
|
+
quality: current?.quality ?? "full",
|
|
99
|
+
qualityReason: current?.qualityReason
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
loopRef.current = loop;
|
|
104
|
+
return () => {
|
|
105
|
+
loop.stop();
|
|
106
|
+
loopRef.current = null;
|
|
107
|
+
};
|
|
108
|
+
}, [
|
|
109
|
+
enabled,
|
|
110
|
+
fps,
|
|
111
|
+
reducedMotion,
|
|
112
|
+
degraded,
|
|
113
|
+
degradedFps
|
|
114
|
+
]);
|
|
115
|
+
return {
|
|
116
|
+
ref,
|
|
117
|
+
...state
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/react/use-lifecycle/index.ts
|
|
122
|
+
const INITIAL_STATE$2 = {
|
|
123
|
+
phase: "idle",
|
|
124
|
+
phaseReason: "initial"
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* React binding for `createLifecycle`. The activation signal for loops you own.
|
|
128
|
+
*
|
|
129
|
+
* Returns `active` / `paused` so a consumer-owned render loop (WebGL, three.js, a
|
|
130
|
+
* Web Worker) can pause when off-screen or under reduced motion. When `phase`
|
|
131
|
+
* should drive the loop for you, use `useLoop` or `useCanvas` instead.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* const { ref, isActive } = useLifecycle();
|
|
135
|
+
* useEffect(() => {
|
|
136
|
+
* if (!isActive) return;
|
|
137
|
+
* const id = requestAnimationFrame(function render() {
|
|
138
|
+
* renderer.render();
|
|
139
|
+
* requestAnimationFrame(render);
|
|
140
|
+
* });
|
|
141
|
+
* return () => cancelAnimationFrame(id);
|
|
142
|
+
* }, [isActive]);
|
|
143
|
+
* return <canvas ref={ref} />;
|
|
144
|
+
*/
|
|
145
|
+
function useLifecycle(options) {
|
|
146
|
+
const { reducedMotion, intersectionOptions, enabled = true } = options ?? {};
|
|
147
|
+
const paused = options?.paused ?? false;
|
|
148
|
+
const onPhaseChangeRef = useSyncedRef(options?.onPhaseChange);
|
|
149
|
+
const internalRef = useRef(null);
|
|
150
|
+
const ref = options?.ref ?? internalRef;
|
|
151
|
+
const [state, setState] = useState(INITIAL_STATE$2);
|
|
152
|
+
const lifecycleRef = useRef(null);
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
const element = ref.current;
|
|
155
|
+
if (!element || !enabled) {
|
|
156
|
+
setState(INITIAL_STATE$2);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const lifecycle = createLifecycle({
|
|
160
|
+
element,
|
|
161
|
+
reducedMotion,
|
|
162
|
+
intersectionOptions,
|
|
163
|
+
onPhaseChange: (phase, phaseReason) => {
|
|
164
|
+
onPhaseChangeRef.current?.(phase, phaseReason);
|
|
165
|
+
setState({
|
|
166
|
+
phase,
|
|
167
|
+
phaseReason
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
lifecycleRef.current = lifecycle;
|
|
172
|
+
if (paused) lifecycle.pause();
|
|
173
|
+
return () => {
|
|
174
|
+
lifecycle.stop();
|
|
175
|
+
lifecycleRef.current = null;
|
|
176
|
+
};
|
|
177
|
+
}, [enabled, reducedMotion]);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const lifecycle = lifecycleRef.current;
|
|
180
|
+
if (!lifecycle) return;
|
|
181
|
+
if (paused) lifecycle.pause();
|
|
182
|
+
else lifecycle.resume();
|
|
183
|
+
}, [paused]);
|
|
184
|
+
return {
|
|
185
|
+
ref,
|
|
186
|
+
...state,
|
|
187
|
+
isActive: state.phase === "active"
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/react/use-device-pixel-ratio/index.ts
|
|
192
|
+
/**
|
|
193
|
+
* Reactive devicePixelRatio that updates when the user moves the window
|
|
194
|
+
* between monitors with different DPR values.
|
|
195
|
+
*
|
|
196
|
+
* Returns `1` during SSR and initial hydration, then the live value.
|
|
197
|
+
*/
|
|
198
|
+
function useDevicePixelRatio() {
|
|
199
|
+
const [dpr, setDpr] = useState(1);
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
setDpr(readDpr());
|
|
202
|
+
return subscribeDpr(setDpr);
|
|
203
|
+
}, []);
|
|
204
|
+
return dpr;
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/react/use-media/index.ts
|
|
208
|
+
/**
|
|
209
|
+
* Subscribe to a media query via the shared MQL pool.
|
|
210
|
+
*
|
|
211
|
+
* Returns `false` during SSR and initial hydration render,
|
|
212
|
+
* then the live value from the first `useEffect`.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* const isNarrow = useMediaQuery('(max-width: 600px)');
|
|
216
|
+
*/
|
|
217
|
+
function useMediaQuery(query) {
|
|
218
|
+
const [matches, setMatches] = useState(false);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
setMatches(readMediaQuery(query));
|
|
221
|
+
return subscribeMediaQuery(query, setMatches);
|
|
222
|
+
}, [query]);
|
|
223
|
+
return matches;
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/react/use-reduced-motion/index.ts
|
|
227
|
+
/**
|
|
228
|
+
* Reactive boolean that tracks the user's `prefers-reduced-motion` OS setting.
|
|
229
|
+
*
|
|
230
|
+
* Returns `false` during SSR and initial hydration, then the live value.
|
|
231
|
+
* Re-renders only when the preference changes.
|
|
232
|
+
*/
|
|
233
|
+
function usePrefersReducedMotion() {
|
|
234
|
+
return useMediaQuery(REDUCED_MOTION_QUERY);
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/react/use-sight/index.ts
|
|
238
|
+
const INITIAL_STATE$1 = {
|
|
239
|
+
phase: "unknown",
|
|
240
|
+
phaseReason: "initial"
|
|
241
|
+
};
|
|
242
|
+
function useSight(options) {
|
|
243
|
+
const [state, setState] = useState(INITIAL_STATE$1);
|
|
244
|
+
const observe = options?.observe ?? "continuous";
|
|
245
|
+
const phaseRef = useRef("unknown");
|
|
246
|
+
const phaseReasonRef = useRef("initial");
|
|
247
|
+
const onVisibilityChangeRef = useSyncedRef(options?.onVisibilityChange);
|
|
248
|
+
const internalRef = useRef(null);
|
|
249
|
+
const ref = options?.ref ?? internalRef;
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const element = ref.current;
|
|
252
|
+
if (!element) return;
|
|
253
|
+
let frozen = false;
|
|
254
|
+
const sight = createSight({
|
|
255
|
+
element,
|
|
256
|
+
intersectionOptions: {
|
|
257
|
+
root: options?.root,
|
|
258
|
+
rootMargin: options?.rootMargin,
|
|
259
|
+
threshold: options?.threshold
|
|
260
|
+
},
|
|
261
|
+
onPhaseChange: (phase, reason) => {
|
|
262
|
+
if (frozen) return;
|
|
263
|
+
phaseRef.current = phase;
|
|
264
|
+
phaseReasonRef.current = reason;
|
|
265
|
+
if (onVisibilityChangeRef.current) onVisibilityChangeRef.current(phase, reason);
|
|
266
|
+
else setState({
|
|
267
|
+
phase,
|
|
268
|
+
phaseReason: reason
|
|
269
|
+
});
|
|
270
|
+
if (observe === "once" && phase === "visible") {
|
|
271
|
+
frozen = true;
|
|
272
|
+
sight.stop();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return () => sight.stop();
|
|
277
|
+
}, [observe]);
|
|
278
|
+
return {
|
|
279
|
+
ref,
|
|
280
|
+
...state,
|
|
281
|
+
phaseRef,
|
|
282
|
+
phaseReasonRef
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/core/_internal/pool/ro-pool.ts
|
|
287
|
+
let observer = null;
|
|
288
|
+
const callbacks = /* @__PURE__ */ new Map();
|
|
289
|
+
/**
|
|
290
|
+
* Observe an element via a singleton ResizeObserver.
|
|
291
|
+
* One RO instance for the entire page. RO takes zero constructor options.
|
|
292
|
+
*
|
|
293
|
+
* @returns Cleanup function that unobserves the element.
|
|
294
|
+
*/
|
|
295
|
+
function observeResize(element, callback) {
|
|
296
|
+
callbacks.set(element, callback);
|
|
297
|
+
getObserver().observe(element);
|
|
298
|
+
let disposed = false;
|
|
299
|
+
return () => {
|
|
300
|
+
if (disposed) return;
|
|
301
|
+
disposed = true;
|
|
302
|
+
if (callbacks.get(element) === callback) {
|
|
303
|
+
callbacks.delete(element);
|
|
304
|
+
observer?.unobserve(element);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/** Lazy-created singleton. RO takes zero constructor options, so one instance can observe everything. */
|
|
309
|
+
function getObserver() {
|
|
310
|
+
if (!observer) observer = new ResizeObserver((entries) => {
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
const cb = callbacks.get(entry.target);
|
|
313
|
+
if (cb) cb(entry);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return observer;
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/react/use-size/index.ts
|
|
320
|
+
function useSize(options) {
|
|
321
|
+
const [size, setSize] = useState(null);
|
|
322
|
+
const sizeRef = useRef(null);
|
|
323
|
+
const prevWidth = useRef(null);
|
|
324
|
+
const prevHeight = useRef(null);
|
|
325
|
+
const onResizeRef = useSyncedRef(options?.onResize);
|
|
326
|
+
const internalRef = useRef(null);
|
|
327
|
+
const ref = options?.ref ?? internalRef;
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
const element = ref.current;
|
|
330
|
+
if (!element) return;
|
|
331
|
+
return observeResize(element, (entry) => {
|
|
332
|
+
const box = entry.contentBoxSize[0];
|
|
333
|
+
if (!box) return;
|
|
334
|
+
const width = box.inlineSize;
|
|
335
|
+
const height = box.blockSize;
|
|
336
|
+
if (width === prevWidth.current && height === prevHeight.current) return;
|
|
337
|
+
prevWidth.current = width;
|
|
338
|
+
prevHeight.current = height;
|
|
339
|
+
const next = {
|
|
340
|
+
width,
|
|
341
|
+
height
|
|
342
|
+
};
|
|
343
|
+
sizeRef.current = next;
|
|
344
|
+
if (onResizeRef.current) onResizeRef.current(next);
|
|
345
|
+
else setSize(next);
|
|
346
|
+
});
|
|
347
|
+
}, []);
|
|
348
|
+
return {
|
|
349
|
+
ref,
|
|
350
|
+
size,
|
|
351
|
+
sizeRef
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/react/use-container-query/index.ts
|
|
356
|
+
/**
|
|
357
|
+
* Returns whether an element matches a size-based container breakpoint.
|
|
358
|
+
*
|
|
359
|
+
* Unlike `useSize` (which re-renders on every pixel of resize), this hook only
|
|
360
|
+
* re-renders when the match result changes, i.e. when the element crosses a
|
|
361
|
+
* breakpoint boundary. Uses the shared ResizeObserver singleton.
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* const { ref, matches } = useContainerQuery({ minWidth: 600 });
|
|
365
|
+
* return <div ref={ref}>{matches ? 'wide' : 'narrow'}</div>;
|
|
366
|
+
*/
|
|
367
|
+
function useContainerQuery(breakpoint, options) {
|
|
368
|
+
const [matches, setMatches] = useState(false);
|
|
369
|
+
const matchesRef = useRef(false);
|
|
370
|
+
const internalRef = useRef(null);
|
|
371
|
+
const ref = options?.ref ?? internalRef;
|
|
372
|
+
const { minWidth, maxWidth, minHeight, maxHeight } = breakpoint;
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
const element = ref.current;
|
|
375
|
+
if (!element) return;
|
|
376
|
+
return observeResize(element, (entry) => {
|
|
377
|
+
const box = entry.contentBoxSize[0];
|
|
378
|
+
if (!box) return;
|
|
379
|
+
const width = box.inlineSize;
|
|
380
|
+
const height = box.blockSize;
|
|
381
|
+
const nowMatches = evaluateBreakpoint(width, height, minWidth, maxWidth, minHeight, maxHeight);
|
|
382
|
+
if (nowMatches !== matchesRef.current) {
|
|
383
|
+
matchesRef.current = nowMatches;
|
|
384
|
+
setMatches(nowMatches);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}, [
|
|
388
|
+
minWidth,
|
|
389
|
+
maxWidth,
|
|
390
|
+
minHeight,
|
|
391
|
+
maxHeight
|
|
392
|
+
]);
|
|
393
|
+
return {
|
|
394
|
+
ref,
|
|
395
|
+
matches
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function evaluateBreakpoint(width, height, minWidth, maxWidth, minHeight, maxHeight) {
|
|
399
|
+
if (minWidth !== void 0 && width < minWidth) return false;
|
|
400
|
+
if (maxWidth !== void 0 && width > maxWidth) return false;
|
|
401
|
+
if (minHeight !== void 0 && height < minHeight) return false;
|
|
402
|
+
if (maxHeight !== void 0 && height > maxHeight) return false;
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/react/use-scroll-progress/index.ts
|
|
407
|
+
function useScrollProgress(options) {
|
|
408
|
+
const [progress, setProgress] = useState(0);
|
|
409
|
+
const progressRef = useRef(0);
|
|
410
|
+
const steps = options?.steps;
|
|
411
|
+
const rootMargin = options?.rootMargin;
|
|
412
|
+
const onProgressRef = useSyncedRef(options?.onProgress);
|
|
413
|
+
const internalRef = useRef(null);
|
|
414
|
+
const ref = options?.ref ?? internalRef;
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
const element = ref.current;
|
|
417
|
+
if (!element) return;
|
|
418
|
+
const scrollProgress = createScrollProgress({
|
|
419
|
+
element,
|
|
420
|
+
onProgress: (ratio) => {
|
|
421
|
+
progressRef.current = ratio;
|
|
422
|
+
if (onProgressRef.current) onProgressRef.current(ratio);
|
|
423
|
+
else setProgress(ratio);
|
|
424
|
+
},
|
|
425
|
+
steps,
|
|
426
|
+
root: options?.root,
|
|
427
|
+
rootMargin
|
|
428
|
+
});
|
|
429
|
+
return () => scrollProgress.stop();
|
|
430
|
+
}, [steps, rootMargin]);
|
|
431
|
+
return {
|
|
432
|
+
ref,
|
|
433
|
+
progress,
|
|
434
|
+
progressRef
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/react/use-render-state/index.ts
|
|
439
|
+
/**
|
|
440
|
+
* Track whether the browser is rendering an element or skipping it under
|
|
441
|
+
* `content-visibility` (e.g. a `Defer` subtree). Returns `'rendered'` until the
|
|
442
|
+
* browser reports otherwise.
|
|
443
|
+
*
|
|
444
|
+
* Use it to pause raw, non-phase work (a hand-written rAF loop, `setInterval`)
|
|
445
|
+
* when the subtree stops painting. phase loops self-pause off-screen already.
|
|
446
|
+
* Has no layout effect. Safe for CLS.
|
|
447
|
+
*
|
|
448
|
+
* @example
|
|
449
|
+
* const ref = useRef<HTMLDivElement>(null);
|
|
450
|
+
* const phase = useRenderState(ref);
|
|
451
|
+
* useEffect(() => {
|
|
452
|
+
* if (phase === 'skipped') clock.pause();
|
|
453
|
+
* else clock.resume();
|
|
454
|
+
* }, [phase]);
|
|
455
|
+
* return <Defer ref={ref}><Heavy /></Defer>;
|
|
456
|
+
*/
|
|
457
|
+
function useRenderState(ref) {
|
|
458
|
+
const [phase, setPhase] = useState("rendered");
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
const element = ref.current;
|
|
461
|
+
if (!element) return;
|
|
462
|
+
const render = createRenderState({
|
|
463
|
+
element,
|
|
464
|
+
onPhaseChange: setPhase
|
|
465
|
+
});
|
|
466
|
+
return () => render.stop();
|
|
467
|
+
}, []);
|
|
468
|
+
return phase;
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/react/use-idle/index.ts
|
|
472
|
+
/**
|
|
473
|
+
* Returns `false`, then `true` once the browser is idle after mount. Use it to
|
|
474
|
+
* defer non-critical work or mounting until the main thread is free.
|
|
475
|
+
*
|
|
476
|
+
* SSR-safe: returns `false` on the server and during the first client render.
|
|
477
|
+
*
|
|
478
|
+
* @example
|
|
479
|
+
* const idle = useIdle();
|
|
480
|
+
* return idle ? <Analytics /> : null;
|
|
481
|
+
*/
|
|
482
|
+
function useIdle(options) {
|
|
483
|
+
const [idle, setIdle] = useState(false);
|
|
484
|
+
const timeout = options?.timeout;
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
return whenIdle(() => setIdle(true), { timeout });
|
|
487
|
+
}, [timeout]);
|
|
488
|
+
return idle;
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/react/use-when-idle/index.ts
|
|
492
|
+
/**
|
|
493
|
+
* Run a callback once, when the browser is idle after mount. The effect-shaped
|
|
494
|
+
* counterpart to `useIdle`. Use it for side effects (prefetching a chunk,
|
|
495
|
+
* warming a cache, `import()`) rather than rendering.
|
|
496
|
+
*
|
|
497
|
+
* Cancels automatically on unmount, and always calls the latest `callback`
|
|
498
|
+
* without re-subscribing. SSR-safe: nothing runs on the server.
|
|
499
|
+
*
|
|
500
|
+
* @example
|
|
501
|
+
* // Prefetch a heavy panel during idle time so it opens instantly later.
|
|
502
|
+
* useWhenIdle(() => void import('./chat-panel'));
|
|
503
|
+
*/
|
|
504
|
+
function useWhenIdle(callback, options) {
|
|
505
|
+
const callbackRef = useSyncedRef(callback);
|
|
506
|
+
const timeout = options?.timeout;
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
return whenIdle(() => callbackRef.current(), { timeout });
|
|
509
|
+
}, [timeout]);
|
|
510
|
+
}
|
|
511
|
+
//#endregion
|
|
512
|
+
//#region src/react/use-canvas/index.ts
|
|
513
|
+
const INITIAL_STATE = {
|
|
514
|
+
phase: "idle",
|
|
515
|
+
phaseReason: "initial",
|
|
516
|
+
quality: "full",
|
|
517
|
+
qualityReason: void 0
|
|
518
|
+
};
|
|
519
|
+
/**
|
|
520
|
+
* Canvas-specific animation with DPR-aware sizing, ResizeObserver coalescing,
|
|
521
|
+
* context management, and GPU context loss recovery.
|
|
522
|
+
*
|
|
523
|
+
* @example
|
|
524
|
+
* useCanvas({
|
|
525
|
+
* containerRef,
|
|
526
|
+
* canvasRef,
|
|
527
|
+
* draw: (ctx, frame, size) => {
|
|
528
|
+
* ctx.clearRect(0, 0, size.width, size.height);
|
|
529
|
+
* // render...
|
|
530
|
+
* },
|
|
531
|
+
* });
|
|
532
|
+
*/
|
|
533
|
+
function useCanvas(options) {
|
|
534
|
+
const { containerRef, canvasRef, fps, enabled = true, reducedMotion, degraded, degradedFps } = options;
|
|
535
|
+
const drawRef = useSyncedRef(options.draw);
|
|
536
|
+
const [state, setState] = useState(INITIAL_STATE);
|
|
537
|
+
const [restartNonce, setRestartNonce] = useState(0);
|
|
538
|
+
const ctxRef = useRef(null);
|
|
539
|
+
const sizeRef = useRef({
|
|
540
|
+
width: 0,
|
|
541
|
+
height: 0
|
|
542
|
+
});
|
|
543
|
+
const qualityRef = useSyncedRef(state.quality);
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
const container = containerRef.current;
|
|
546
|
+
const canvasEl = canvasRef.current;
|
|
547
|
+
if (!container || !canvasEl || !enabled) return;
|
|
548
|
+
const canvas = canvasEl;
|
|
549
|
+
const initialCtx = canvas.getContext("2d");
|
|
550
|
+
if (!initialCtx) return;
|
|
551
|
+
ctxRef.current = initialCtx;
|
|
552
|
+
let dpr = readDpr();
|
|
553
|
+
let contextLost = false;
|
|
554
|
+
function applySize(width, height, physicalBox) {
|
|
555
|
+
sizeRef.current = {
|
|
556
|
+
width,
|
|
557
|
+
height
|
|
558
|
+
};
|
|
559
|
+
const isDegraded = qualityRef.current === "degraded";
|
|
560
|
+
let bufferWidth;
|
|
561
|
+
let bufferHeight;
|
|
562
|
+
if (isDegraded) {
|
|
563
|
+
bufferWidth = width;
|
|
564
|
+
bufferHeight = height;
|
|
565
|
+
} else if (physicalBox) {
|
|
566
|
+
bufferWidth = physicalBox.inlineSize;
|
|
567
|
+
bufferHeight = physicalBox.blockSize;
|
|
568
|
+
} else {
|
|
569
|
+
bufferWidth = width * dpr;
|
|
570
|
+
bufferHeight = height * dpr;
|
|
571
|
+
}
|
|
572
|
+
canvas.width = bufferWidth;
|
|
573
|
+
canvas.height = bufferHeight;
|
|
574
|
+
canvas.style.width = width + "px";
|
|
575
|
+
canvas.style.height = height + "px";
|
|
576
|
+
const effectiveDpr = isDegraded ? 1 : dpr;
|
|
577
|
+
ctxRef.current?.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
|
|
578
|
+
}
|
|
579
|
+
const unsubDpr = subscribeDpr((newDpr) => {
|
|
580
|
+
dpr = newDpr;
|
|
581
|
+
applySize(sizeRef.current.width, sizeRef.current.height);
|
|
582
|
+
});
|
|
583
|
+
const unobserve = observeResize(container, (entry) => {
|
|
584
|
+
const box = entry.contentBoxSize[0];
|
|
585
|
+
if (!box) return;
|
|
586
|
+
const physicalBox = entry.devicePixelContentBoxSize?.[0];
|
|
587
|
+
applySize(box.inlineSize, box.blockSize, physicalBox);
|
|
588
|
+
});
|
|
589
|
+
function onContextLost(event) {
|
|
590
|
+
event.preventDefault();
|
|
591
|
+
contextLost = true;
|
|
592
|
+
}
|
|
593
|
+
function onContextRestored() {
|
|
594
|
+
const restoredCtx = canvas.getContext("2d");
|
|
595
|
+
if (!restoredCtx) return;
|
|
596
|
+
ctxRef.current = restoredCtx;
|
|
597
|
+
contextLost = false;
|
|
598
|
+
applySize(sizeRef.current.width, sizeRef.current.height);
|
|
599
|
+
}
|
|
600
|
+
canvas.addEventListener("contextlost", onContextLost);
|
|
601
|
+
canvas.addEventListener("contextrestored", onContextRestored);
|
|
602
|
+
let loopInstance = null;
|
|
603
|
+
const loop = createLoop({
|
|
604
|
+
element: container,
|
|
605
|
+
fps,
|
|
606
|
+
reducedMotion,
|
|
607
|
+
...degradedConfig(degraded, degradedFps),
|
|
608
|
+
onTick: (frame) => {
|
|
609
|
+
if (contextLost || !ctxRef.current) return;
|
|
610
|
+
drawRef.current(ctxRef.current, frame, sizeRef.current);
|
|
611
|
+
},
|
|
612
|
+
onPhaseChange: (phase, reason) => {
|
|
613
|
+
setState({
|
|
614
|
+
phase,
|
|
615
|
+
phaseReason: reason,
|
|
616
|
+
quality: loopInstance?.quality ?? "full",
|
|
617
|
+
qualityReason: loopInstance?.qualityReason
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
loopInstance = loop;
|
|
622
|
+
function teardown() {
|
|
623
|
+
loop.stop();
|
|
624
|
+
loopInstance = null;
|
|
625
|
+
unobserve();
|
|
626
|
+
unsubDpr();
|
|
627
|
+
canvas.removeEventListener("contextlost", onContextLost);
|
|
628
|
+
canvas.removeEventListener("contextrestored", onContextRestored);
|
|
629
|
+
}
|
|
630
|
+
return teardown;
|
|
631
|
+
}, [
|
|
632
|
+
enabled,
|
|
633
|
+
fps,
|
|
634
|
+
reducedMotion,
|
|
635
|
+
degraded,
|
|
636
|
+
degradedFps,
|
|
637
|
+
restartNonce
|
|
638
|
+
]);
|
|
639
|
+
return {
|
|
640
|
+
restart: useCallback(() => {
|
|
641
|
+
setRestartNonce((n) => n + 1);
|
|
642
|
+
}, []),
|
|
643
|
+
...state
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/react/use-tween/index.ts
|
|
648
|
+
/**
|
|
649
|
+
* Animate a value from its current position to `target` over `duration`.
|
|
650
|
+
*
|
|
651
|
+
* Uses `useState` per frame. Appropriate for cheap renders (counters, opacity,
|
|
652
|
+
* progress bars). For batch animations, use `useLoop` with ref-based DOM writes.
|
|
653
|
+
*
|
|
654
|
+
* @remarks
|
|
655
|
+
* Unlike `createTicker`/`createLoop`, `useTween` drives its own rAF rather than
|
|
656
|
+
* the shared frame-locked clock. It's a finite, self-completing tween whose value
|
|
657
|
+
* must land in React state, so it doesn't need cross-loop visual sync, strong
|
|
658
|
+
* pause, or delta clamping. Routing it through the shared clock would add bundle
|
|
659
|
+
* weight for no benefit.
|
|
660
|
+
*
|
|
661
|
+
* @example
|
|
662
|
+
* const value = useTween({ target: 100, duration: 500 });
|
|
663
|
+
*/
|
|
664
|
+
function useTween(options) {
|
|
665
|
+
const { target, duration = 300, delay = 0, easing = easeOutCubic, enabled = true, reducedMotion = "complete" } = options;
|
|
666
|
+
const [value, setValue] = useState(target);
|
|
667
|
+
const fromRef = useRef(target);
|
|
668
|
+
const currentRef = useRef(target);
|
|
669
|
+
const isFirstRender = useRef(true);
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
if (!Number.isFinite(duration) || duration <= 0) invalidDurationError("useTween", duration);
|
|
672
|
+
if (isFirstRender.current) {
|
|
673
|
+
isFirstRender.current = false;
|
|
674
|
+
jumpToTarget({
|
|
675
|
+
target,
|
|
676
|
+
fromRef,
|
|
677
|
+
currentRef,
|
|
678
|
+
setValue
|
|
679
|
+
});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (!enabled || reducedMotion !== "ignore" && prefersReducedMotion()) {
|
|
683
|
+
jumpToTarget({
|
|
684
|
+
target,
|
|
685
|
+
fromRef,
|
|
686
|
+
currentRef,
|
|
687
|
+
setValue
|
|
688
|
+
});
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const from = currentRef.current;
|
|
692
|
+
if (from === target) return;
|
|
693
|
+
let rafId;
|
|
694
|
+
let startTime = null;
|
|
695
|
+
function tick(now) {
|
|
696
|
+
if (startTime === null) startTime = now;
|
|
697
|
+
const elapsed = now - startTime - delay;
|
|
698
|
+
if (elapsed < 0) {
|
|
699
|
+
rafId = requestAnimationFrame(tick);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const progress = clamp01(elapsed / duration);
|
|
703
|
+
const current = from + (target - from) * easing(progress);
|
|
704
|
+
currentRef.current = current;
|
|
705
|
+
setValue(current);
|
|
706
|
+
if (progress < 1) rafId = requestAnimationFrame(tick);
|
|
707
|
+
else fromRef.current = target;
|
|
708
|
+
}
|
|
709
|
+
rafId = requestAnimationFrame(tick);
|
|
710
|
+
return () => {
|
|
711
|
+
cancelAnimationFrame(rafId);
|
|
712
|
+
fromRef.current = currentRef.current;
|
|
713
|
+
};
|
|
714
|
+
}, [
|
|
715
|
+
target,
|
|
716
|
+
duration,
|
|
717
|
+
delay,
|
|
718
|
+
enabled,
|
|
719
|
+
reducedMotion
|
|
720
|
+
]);
|
|
721
|
+
return value;
|
|
722
|
+
}
|
|
723
|
+
function jumpToTarget(options) {
|
|
724
|
+
const { target, fromRef, currentRef, setValue } = options;
|
|
725
|
+
fromRef.current = target;
|
|
726
|
+
currentRef.current = target;
|
|
727
|
+
setValue(target);
|
|
728
|
+
}
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region src/react/_internal/use-update-effect/index.ts
|
|
731
|
+
/**
|
|
732
|
+
* Like `useEffect` but skips the first invocation on mount.
|
|
733
|
+
* Used by Presence to distinguish initial render from subsequent `show` changes.
|
|
734
|
+
*/
|
|
735
|
+
function useUpdateEffect(effect, deps) {
|
|
736
|
+
const isMounted = useRef(false);
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
if (!isMounted.current) {
|
|
739
|
+
isMounted.current = true;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
return effect();
|
|
743
|
+
}, deps);
|
|
744
|
+
}
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/react/use-presence/index.ts
|
|
747
|
+
/**
|
|
748
|
+
* Composable presence primitive for mount/unmount lifecycle with CSS transitions.
|
|
749
|
+
*
|
|
750
|
+
* Enter animations use CSS `@starting-style`, gated by `data-enter="animate"`.
|
|
751
|
+
* Exit animations are JS-coordinated: waits for `transitionend`/`animationend`
|
|
752
|
+
* before unmounting.
|
|
753
|
+
*
|
|
754
|
+
* @example
|
|
755
|
+
* const { phase, ref, mounted, enter } = usePresence({ show: isOpen });
|
|
756
|
+
* if (!mounted) return null;
|
|
757
|
+
* return (
|
|
758
|
+
* <div ref={ref} data-phase={phase} data-enter={enter === 'animate' ? 'animate' : undefined}
|
|
759
|
+
* className="transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0" />
|
|
760
|
+
* );
|
|
761
|
+
*/
|
|
762
|
+
function usePresence(options) {
|
|
763
|
+
const { show, mode = "mount", enter: enterOption = "animate", exitDuration = 5e3, reducedMotion = "respect" } = options;
|
|
764
|
+
const ref = useRef(null);
|
|
765
|
+
const [phase, setPhase] = useState(show ? "entered" : "idle");
|
|
766
|
+
const [reason, setReason] = useState("initial");
|
|
767
|
+
const exitTimerRef = useRef(null);
|
|
768
|
+
const exitCleanupRef = useRef(null);
|
|
769
|
+
const clearTimers = useCallback(() => {
|
|
770
|
+
if (exitTimerRef.current !== null) {
|
|
771
|
+
clearTimeout(exitTimerRef.current);
|
|
772
|
+
exitTimerRef.current = null;
|
|
773
|
+
}
|
|
774
|
+
if (exitCleanupRef.current) {
|
|
775
|
+
exitCleanupRef.current();
|
|
776
|
+
exitCleanupRef.current = null;
|
|
777
|
+
}
|
|
778
|
+
}, []);
|
|
779
|
+
useEffect(() => () => clearTimers(), [clearTimers]);
|
|
780
|
+
useUpdateEffect(() => {
|
|
781
|
+
clearTimers();
|
|
782
|
+
if (show) setPhase((prev) => {
|
|
783
|
+
setReason(prev === "exiting" ? "interrupted" : "show");
|
|
784
|
+
return "entered";
|
|
785
|
+
});
|
|
786
|
+
else if (reducedMotion === "respect" && prefersReducedMotion()) {
|
|
787
|
+
setPhase(mode === "reveal" ? "idle" : "exited");
|
|
788
|
+
setReason("animation-end");
|
|
789
|
+
} else handleExit(ref, mode, exitDuration, setPhase, setReason, clearTimers, exitTimerRef, exitCleanupRef);
|
|
790
|
+
}, [show]);
|
|
791
|
+
const mounted = phase !== "idle" && phase !== "exited";
|
|
792
|
+
const wantsAnimation = !(reason === "initial" && enterOption === "instant");
|
|
793
|
+
const motionAllowed = reducedMotion === "ignore" || !prefersReducedMotion();
|
|
794
|
+
return {
|
|
795
|
+
phase,
|
|
796
|
+
phaseReason: reason,
|
|
797
|
+
mounted,
|
|
798
|
+
ref,
|
|
799
|
+
enter: wantsAnimation && motionAllowed ? "animate" : "instant"
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function handleExit(ref, mode, exitDuration, setPhase, setReason, clearTimers, exitTimerRef, exitCleanupRef) {
|
|
803
|
+
setPhase("exiting");
|
|
804
|
+
setReason("hide");
|
|
805
|
+
const exitTarget = mode === "reveal" ? "idle" : "exited";
|
|
806
|
+
const element = ref.current;
|
|
807
|
+
function completeExit() {
|
|
808
|
+
clearTimers();
|
|
809
|
+
setPhase((current) => {
|
|
810
|
+
if (current !== "exiting") return current;
|
|
811
|
+
setReason("animation-end");
|
|
812
|
+
return exitTarget;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
function cleanup() {
|
|
816
|
+
if (element) {
|
|
817
|
+
element.removeEventListener("transitionend", completeExit);
|
|
818
|
+
element.removeEventListener("animationend", completeExit);
|
|
819
|
+
}
|
|
820
|
+
if (exitTimerRef.current !== null) {
|
|
821
|
+
clearTimeout(exitTimerRef.current);
|
|
822
|
+
exitTimerRef.current = null;
|
|
823
|
+
}
|
|
824
|
+
exitCleanupRef.current = null;
|
|
825
|
+
}
|
|
826
|
+
exitCleanupRef.current = cleanup;
|
|
827
|
+
if (element) {
|
|
828
|
+
element.addEventListener("transitionend", completeExit, { once: true });
|
|
829
|
+
element.addEventListener("animationend", completeExit, { once: true });
|
|
830
|
+
}
|
|
831
|
+
exitTimerRef.current = setTimeout(completeExit, exitDuration);
|
|
832
|
+
}
|
|
833
|
+
//#endregion
|
|
834
|
+
//#region src/react/presence/index.tsx
|
|
835
|
+
/**
|
|
836
|
+
* Renders a `div` that manages its own mounting lifecycle.
|
|
837
|
+
*
|
|
838
|
+
* Stamps `data-phase` for exit animations and `data-enter="animate"` to gate
|
|
839
|
+
* CSS `@starting-style` enter animations. Reduced motion is handled automatically.
|
|
840
|
+
*
|
|
841
|
+
* @example
|
|
842
|
+
* <Presence
|
|
843
|
+
* show={isOpen}
|
|
844
|
+
* className="transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0"
|
|
845
|
+
* >
|
|
846
|
+
* Modal content
|
|
847
|
+
* </Presence>
|
|
848
|
+
*/
|
|
849
|
+
function Presence({ show, mode, enter: enterOption, exitDuration, reducedMotion, ref: forwardedRef, children, ...divProps }) {
|
|
850
|
+
const { phase, ref, mounted, enter } = usePresence({
|
|
851
|
+
show,
|
|
852
|
+
mode,
|
|
853
|
+
enter: enterOption,
|
|
854
|
+
exitDuration,
|
|
855
|
+
reducedMotion
|
|
856
|
+
});
|
|
857
|
+
useImperativeHandle(forwardedRef, () => ref.current);
|
|
858
|
+
if (!mounted && mode !== "reveal") return null;
|
|
859
|
+
return /* @__PURE__ */ jsx("div", {
|
|
860
|
+
...divProps,
|
|
861
|
+
ref,
|
|
862
|
+
"data-phase": phase,
|
|
863
|
+
"data-enter": enter === "animate" ? "animate" : void 0,
|
|
864
|
+
children
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/react/when-visible/index.tsx
|
|
869
|
+
/**
|
|
870
|
+
* Mounts children when the element enters the viewport. One-shot (once
|
|
871
|
+
* triggered, stays mounted).
|
|
872
|
+
*
|
|
873
|
+
* Enter animation uses CSS `@starting-style`, gated by `data-enter="animate"`.
|
|
874
|
+
* Reduced motion is automatic: the attribute is not stamped when the user
|
|
875
|
+
* prefers reduced motion.
|
|
876
|
+
*
|
|
877
|
+
* @example
|
|
878
|
+
* <WhenVisible rootMargin="200px" className="transition-opacity data-[enter=animate]:starting:opacity-0">
|
|
879
|
+
* <HeavyChart />
|
|
880
|
+
* </WhenVisible>
|
|
881
|
+
*/
|
|
882
|
+
function WhenVisible({ rootMargin = "200px", threshold, root, fallback, children, ref: forwardedRef, ...divProps }) {
|
|
883
|
+
const sentinelRef = useRef(null);
|
|
884
|
+
const { phase } = useSight({
|
|
885
|
+
ref: sentinelRef,
|
|
886
|
+
observe: "once",
|
|
887
|
+
rootMargin,
|
|
888
|
+
threshold,
|
|
889
|
+
root
|
|
890
|
+
});
|
|
891
|
+
const setRef = (node) => {
|
|
892
|
+
sentinelRef.current = node;
|
|
893
|
+
assignRef(forwardedRef, node);
|
|
894
|
+
};
|
|
895
|
+
if (phase !== "visible") return /* @__PURE__ */ jsx("div", {
|
|
896
|
+
ref: setRef,
|
|
897
|
+
...divProps,
|
|
898
|
+
children: fallback
|
|
899
|
+
});
|
|
900
|
+
const motionAllowed = !prefersReducedMotion();
|
|
901
|
+
return /* @__PURE__ */ jsx("div", {
|
|
902
|
+
...divProps,
|
|
903
|
+
ref: setRef,
|
|
904
|
+
"data-phase": "entered",
|
|
905
|
+
"data-enter": motionAllowed ? "animate" : void 0,
|
|
906
|
+
children
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
function assignRef(ref, node) {
|
|
910
|
+
if (typeof ref === "function") ref(node);
|
|
911
|
+
else if (ref) ref.current = node;
|
|
912
|
+
}
|
|
913
|
+
//#endregion
|
|
914
|
+
//#region src/react/when-idle/index.tsx
|
|
915
|
+
/**
|
|
916
|
+
* Mounts children once the browser is idle after first paint. One-shot (once
|
|
917
|
+
* mounted, stays mounted). Use it to defer non-critical UI off the critical path.
|
|
918
|
+
*
|
|
919
|
+
* Children are not server-rendered (idle never fires during SSR), so reserve
|
|
920
|
+
* this for non-critical content. For viewport-gated mounting use `WhenVisible`;
|
|
921
|
+
* to keep content in the DOM but skip painting use `Defer`.
|
|
922
|
+
*
|
|
923
|
+
* Enter animation uses CSS `@starting-style`, gated by `data-enter="animate"`.
|
|
924
|
+
* Reduced motion is automatic: the attribute is not stamped when the user
|
|
925
|
+
* prefers reduced motion.
|
|
926
|
+
*
|
|
927
|
+
* @example
|
|
928
|
+
* <WhenIdle fallback={<Skeleton />}>
|
|
929
|
+
* <SecondaryPanel />
|
|
930
|
+
* </WhenIdle>
|
|
931
|
+
*/
|
|
932
|
+
function WhenIdle({ timeout, fallback, children, ref: forwardedRef, ...divProps }) {
|
|
933
|
+
if (!useIdle({ timeout })) return /* @__PURE__ */ jsx("div", {
|
|
934
|
+
...divProps,
|
|
935
|
+
children: fallback
|
|
936
|
+
});
|
|
937
|
+
const motionAllowed = !prefersReducedMotion();
|
|
938
|
+
return /* @__PURE__ */ jsx("div", {
|
|
939
|
+
...divProps,
|
|
940
|
+
ref: forwardedRef,
|
|
941
|
+
"data-phase": "entered",
|
|
942
|
+
"data-enter": motionAllowed ? "animate" : void 0,
|
|
943
|
+
children
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/react/defer/index.tsx
|
|
948
|
+
/**
|
|
949
|
+
* Skip the browser's rendering work (style, layout, paint) for off-screen
|
|
950
|
+
* content via `content-visibility: auto`. Pure CSS, no JS, no observer.
|
|
951
|
+
*
|
|
952
|
+
* Children stay in the DOM and are server-rendered (SEO- and CLS-safe).
|
|
953
|
+
* `contain-intrinsic-size: auto <estimatedHeight>` reserves space so the
|
|
954
|
+
* scrollbar does not jump. Defers rendering only, not hydration or mounting.
|
|
955
|
+
*
|
|
956
|
+
* The render-skip styles are encapsulated and cannot be overridden. There is
|
|
957
|
+
* no `style` prop. Style the wrapper with `className`; this keeps the
|
|
958
|
+
* no-layout-shift guarantee intact.
|
|
959
|
+
*
|
|
960
|
+
* @example
|
|
961
|
+
* <Defer estimatedHeight="600px" className="my-section">
|
|
962
|
+
* <ArticleSection />
|
|
963
|
+
* </Defer>
|
|
964
|
+
*
|
|
965
|
+
* @remarks
|
|
966
|
+
* Animations inside a `Defer` keep running while paint is skipped. phase loops
|
|
967
|
+
* self-pause off-screen on their own; for raw rAF/interval work, gate it with
|
|
968
|
+
* `useRenderState`.
|
|
969
|
+
*/
|
|
970
|
+
function Defer({ estimatedHeight = "1000px", children, ref, ...divProps }) {
|
|
971
|
+
const deferStyle = {
|
|
972
|
+
contentVisibility: "auto",
|
|
973
|
+
containIntrinsicSize: `auto ${estimatedHeight}`
|
|
974
|
+
};
|
|
975
|
+
return /* @__PURE__ */ jsx("div", {
|
|
976
|
+
...divProps,
|
|
977
|
+
ref,
|
|
978
|
+
style: deferStyle,
|
|
979
|
+
children
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
//#endregion
|
|
983
|
+
//#region src/react/swap/index.tsx
|
|
984
|
+
const SwapCtx = createContext(null);
|
|
985
|
+
/**
|
|
986
|
+
* Coordinated exit-then-enter transitions for N states.
|
|
987
|
+
* Only one state is entering or exiting at a time (no overlap).
|
|
988
|
+
*
|
|
989
|
+
* The current state fully exits before the new state enters. Rapid changes
|
|
990
|
+
* (A->B->C during A's exit) skip intermediate states and jump to the latest.
|
|
991
|
+
*
|
|
992
|
+
* @example
|
|
993
|
+
* <Swap active={success ? 'success' : 'form'}>
|
|
994
|
+
* <Swap.State id="form" className="transition-all data-[phase=exiting]:opacity-0">
|
|
995
|
+
* <Form />
|
|
996
|
+
* </Swap.State>
|
|
997
|
+
* <Swap.State id="success" className="transition-all data-[enter=animate]:starting:opacity-0">
|
|
998
|
+
* <SuccessMessage />
|
|
999
|
+
* </Swap.State>
|
|
1000
|
+
* </Swap>
|
|
1001
|
+
*/
|
|
1002
|
+
function SwapRoot({ active, exitDuration = 5e3, children, ...divProps }) {
|
|
1003
|
+
const [current, setCurrent] = useState(active);
|
|
1004
|
+
const [hasSwapped, setHasSwapped] = useState(false);
|
|
1005
|
+
const activeRef = useSyncedRef(active);
|
|
1006
|
+
const onExited = useCallback((id) => {
|
|
1007
|
+
setHasSwapped(true);
|
|
1008
|
+
setCurrent((cur) => cur === id ? activeRef.current : cur);
|
|
1009
|
+
}, [activeRef]);
|
|
1010
|
+
const enter = hasSwapped ? "animate" : "instant";
|
|
1011
|
+
const ctx = useMemo(() => ({
|
|
1012
|
+
current,
|
|
1013
|
+
active,
|
|
1014
|
+
exitDuration,
|
|
1015
|
+
enter,
|
|
1016
|
+
onExited
|
|
1017
|
+
}), [
|
|
1018
|
+
current,
|
|
1019
|
+
active,
|
|
1020
|
+
exitDuration,
|
|
1021
|
+
enter,
|
|
1022
|
+
onExited
|
|
1023
|
+
]);
|
|
1024
|
+
return /* @__PURE__ */ jsx(SwapCtx.Provider, {
|
|
1025
|
+
value: ctx,
|
|
1026
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1027
|
+
...divProps,
|
|
1028
|
+
children
|
|
1029
|
+
})
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
function SwapState({ id, ref: forwardedRef, children, ...divProps }) {
|
|
1033
|
+
const ctx = use(SwapCtx);
|
|
1034
|
+
if (!ctx) missingContextError("Swap.State", "Swap");
|
|
1035
|
+
const isCurrent = ctx.current === id;
|
|
1036
|
+
const show = isCurrent && ctx.active === id;
|
|
1037
|
+
const { phase, ref, mounted, enter } = usePresence({
|
|
1038
|
+
show,
|
|
1039
|
+
mode: "mount",
|
|
1040
|
+
enter: ctx.enter,
|
|
1041
|
+
exitDuration: ctx.exitDuration
|
|
1042
|
+
});
|
|
1043
|
+
useImperativeHandle(forwardedRef, () => ref.current);
|
|
1044
|
+
useEffect(() => {
|
|
1045
|
+
if (isCurrent && !show && phase === "exited") ctx.onExited(id);
|
|
1046
|
+
}, [
|
|
1047
|
+
isCurrent,
|
|
1048
|
+
show,
|
|
1049
|
+
phase,
|
|
1050
|
+
id,
|
|
1051
|
+
ctx
|
|
1052
|
+
]);
|
|
1053
|
+
if (!isCurrent || !mounted) return null;
|
|
1054
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1055
|
+
...divProps,
|
|
1056
|
+
ref,
|
|
1057
|
+
"data-phase": phase,
|
|
1058
|
+
"data-enter": enter === "animate" ? "animate" : void 0,
|
|
1059
|
+
children
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
const Swap = Object.assign(SwapRoot, { State: SwapState });
|
|
1063
|
+
//#endregion
|
|
1064
|
+
export { Defer, Presence, Swap, WhenIdle, WhenVisible, useCanvas, useContainerQuery, useDevicePixelRatio, useIdle, useLifecycle, useLoop, useMediaQuery, usePrefersReducedMotion, usePresence, useRenderState, useScrollProgress, useSight, useSize, useStableCallback, useSyncedRef, useTween, useWhenIdle };
|
|
1065
|
+
|
|
1066
|
+
//# sourceMappingURL=react.js.map
|