@octanejs/motion 0.1.32 → 0.1.34
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/README.md +10 -2
- package/package.json +7 -5
- package/src/context.ts +32 -1
- package/src/index.ts +349 -73
- package/src/layout.ts +102 -0
- package/src/react-m.ts +174 -0
- package/src/useReducedMotion.ts +86 -0
package/README.md
CHANGED
|
@@ -43,6 +43,12 @@ function List(props) @{
|
|
|
43
43
|
events, …) and `style` MotionValues spread/bound onto the element.
|
|
44
44
|
- `AnimatePresence` — exit animations on removal.
|
|
45
45
|
- `MotionConfig` — global `transition` / `reducedMotion` defaults via context.
|
|
46
|
+
- `useReducedMotion()` — a live `prefers-reduced-motion` subscription; operating-system
|
|
47
|
+
setting changes update mounted consumers.
|
|
48
|
+
- `LayoutGroup` — namespaces `layoutId` values so independent shared-layout surfaces
|
|
49
|
+
do not cross-animate.
|
|
50
|
+
- `LazyMotion` + `domAnimation` / `domMax` + `m` — feature-gated hosts. The
|
|
51
|
+
`./react-m` entry exposes every HTML/SVG host as a named export.
|
|
46
52
|
- `variants` — label resolution (`animate="visible"`) + parent→child propagation +
|
|
47
53
|
`staggerChildren` / `delayChildren` (number or `stagger()` function) / `staggerDirection`.
|
|
48
54
|
- `useMotionValue()`, `useScroll()`, `useAnimate()` — MotionValues, scroll-linked
|
|
@@ -74,13 +80,15 @@ real `<tag>` through `hostComponent`, captures the node, and drives:
|
|
|
74
80
|
vs the previous commit (`layout`) or a same-id element that just unmounted
|
|
75
81
|
(`layoutId`) — apply the inverse transform then animate it back to identity. The
|
|
76
82
|
same cleanup-before-detach ordering lets a leaving `layoutId` element record its
|
|
77
|
-
box for
|
|
83
|
+
box for a same-commit replacement; unused boxes expire after that commit.
|
|
84
|
+
`layout="position"` applies only translation, `layout="size"` only scaling, and
|
|
85
|
+
layout animations use `transition.layout` when provided.
|
|
78
86
|
|
|
79
87
|
## Not yet ported
|
|
80
88
|
|
|
81
89
|
The full layout **projection tree** — nested projection, child scale correction, and
|
|
82
90
|
continuous shared-layout during drag (the `layout`/`layoutId` here are single-element
|
|
83
|
-
FLIPs). Also drag momentum/elastic physics
|
|
91
|
+
FLIPs). Also drag momentum/elastic physics and
|
|
84
92
|
`useTransform`'s output-map form (`useTransform(mv, [0, 100], { opacity: [0, 1] })`).
|
|
85
93
|
|
|
86
94
|
Stagger specifics: `when: 'beforeChildren' | 'afterChildren'` parent/child sequencing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/motion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -35,20 +35,22 @@
|
|
|
35
35
|
"README.md"
|
|
36
36
|
],
|
|
37
37
|
"exports": {
|
|
38
|
-
".": "./src/index.ts"
|
|
38
|
+
".": "./src/index.ts",
|
|
39
|
+
"./react-m": "./src/react-m.ts"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"motion": "^12.42.2"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
|
-
"octane": "0.1.
|
|
45
|
+
"octane": "0.1.35"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"esbuild": "^0.28.1",
|
|
48
49
|
"vitest": "^4.1.10",
|
|
49
|
-
"octane": "0.1.
|
|
50
|
+
"octane": "0.1.35"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
|
-
"test": "vitest run"
|
|
53
|
+
"test": "vitest run --root ../.. --project motion",
|
|
54
|
+
"typecheck": "tsgo --noEmit -p tsconfig.json"
|
|
53
55
|
}
|
|
54
56
|
}
|
package/src/context.ts
CHANGED
|
@@ -6,8 +6,39 @@ import { createContext, useContext } from 'octane';
|
|
|
6
6
|
export interface MotionConfigValue {
|
|
7
7
|
transition?: any;
|
|
8
8
|
reducedMotion?: 'always' | 'never' | 'user';
|
|
9
|
+
shouldReduceMotion?: boolean;
|
|
9
10
|
}
|
|
10
|
-
export const MotionConfigContext = createContext<MotionConfigValue>({
|
|
11
|
+
export const MotionConfigContext = createContext<MotionConfigValue>({
|
|
12
|
+
reducedMotion: 'never',
|
|
13
|
+
shouldReduceMotion: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// LayoutGroup namespaces shared layout ids. Nested groups inherit and compose
|
|
17
|
+
// ids by default, matching Motion's `parent-child-layoutId` convention.
|
|
18
|
+
export interface LayoutGroupValue {
|
|
19
|
+
id?: string;
|
|
20
|
+
/** @internal Collision-free namespace segments used by the layoutId registry. */
|
|
21
|
+
layoutIdNamespace?: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
export const LayoutGroupContext = createContext<LayoutGroupValue>({});
|
|
24
|
+
|
|
25
|
+
export interface MotionFeatureBundle {
|
|
26
|
+
animation: boolean;
|
|
27
|
+
gestures: boolean;
|
|
28
|
+
drag: boolean;
|
|
29
|
+
layout: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LazyMotionValue {
|
|
33
|
+
features: MotionFeatureBundle | null;
|
|
34
|
+
strict: boolean;
|
|
35
|
+
provided: boolean;
|
|
36
|
+
}
|
|
37
|
+
export const LazyMotionContext = createContext<LazyMotionValue>({
|
|
38
|
+
features: null,
|
|
39
|
+
strict: false,
|
|
40
|
+
provided: false,
|
|
41
|
+
});
|
|
11
42
|
|
|
12
43
|
// Variant labels propagated from a parent motion element to its descendants, so a
|
|
13
44
|
// child with `variants` but no explicit `animate` inherits the parent's active
|
package/src/index.ts
CHANGED
|
@@ -7,16 +7,37 @@
|
|
|
7
7
|
// drives animation/gesture/layout/drag from layout effects — exactly the refs +
|
|
8
8
|
// effects + rendering path this is meant to exercise.
|
|
9
9
|
import { animate, hover, press, inView } from 'motion';
|
|
10
|
-
import { childSlot, hostComponent, useLayoutEffect, useState, provideContext } from 'octane';
|
|
11
10
|
import {
|
|
11
|
+
childSlot,
|
|
12
|
+
hostComponent,
|
|
13
|
+
useContext,
|
|
14
|
+
useEffect,
|
|
15
|
+
useLayoutEffect,
|
|
16
|
+
useMemo,
|
|
17
|
+
useState,
|
|
18
|
+
provideContext,
|
|
19
|
+
} from 'octane';
|
|
20
|
+
import {
|
|
21
|
+
LayoutGroupContext,
|
|
22
|
+
LazyMotionContext,
|
|
12
23
|
MotionConfigContext,
|
|
13
24
|
VariantContext,
|
|
14
25
|
StaggerContext,
|
|
26
|
+
type MotionFeatureBundle,
|
|
15
27
|
resolveVariant,
|
|
16
28
|
splitVariant,
|
|
17
29
|
} from './context';
|
|
18
30
|
import { isMotionValue, isTransformKey, applyStyleValue } from './useMotionValue';
|
|
19
|
-
import {
|
|
31
|
+
import { useReducedMotionAtSlot } from './useReducedMotion';
|
|
32
|
+
import {
|
|
33
|
+
layoutAnimationMode,
|
|
34
|
+
layoutCellKey,
|
|
35
|
+
layoutTransition,
|
|
36
|
+
projectLayout,
|
|
37
|
+
recordLayoutCell,
|
|
38
|
+
takeLayoutCell,
|
|
39
|
+
type LayoutBox,
|
|
40
|
+
} from './layout';
|
|
20
41
|
|
|
21
42
|
// A plain-TS component gets its OWN block per instance (componentSlot), so fixed
|
|
22
43
|
// slot symbols don't collide across instances — and these are distinct within one.
|
|
@@ -32,18 +53,12 @@ const MV = Symbol.for('octane-motion:motionvalues');
|
|
|
32
53
|
const LAYOUT_ID = Symbol.for('octane-motion:layoutid');
|
|
33
54
|
const STAGGER_ORCH = Symbol.for('octane-motion:stagger-orch');
|
|
34
55
|
const STAGGER = Symbol.for('octane-motion:stagger');
|
|
56
|
+
const CONFIG_REDUCED = Symbol.for('octane-motion:config-reduced');
|
|
57
|
+
const LAYOUT_GROUP_STATE = Symbol.for('octane-motion:layout-group-state');
|
|
58
|
+
const LAZY_STATE = Symbol.for('octane-motion:lazy-state');
|
|
59
|
+
const LAZY_EFFECT = Symbol.for('octane-motion:lazy-effect');
|
|
35
60
|
|
|
36
|
-
|
|
37
|
-
// next element to mount with the same id crossfades (FLIPs) from it. (A basic
|
|
38
|
-
// shared-layout "magic move"; the full projection tree is out of scope.)
|
|
39
|
-
interface Box {
|
|
40
|
-
left: number;
|
|
41
|
-
top: number;
|
|
42
|
-
width: number;
|
|
43
|
-
height: number;
|
|
44
|
-
}
|
|
45
|
-
const layoutCells = new Map<string, Box>();
|
|
46
|
-
const boxOf = (n: HTMLElement): Box => {
|
|
61
|
+
const boxOf = (n: HTMLElement): LayoutBox => {
|
|
47
62
|
const r = n.getBoundingClientRect();
|
|
48
63
|
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
49
64
|
};
|
|
@@ -110,13 +125,93 @@ function clamp(v: number, min: number, max: number): number {
|
|
|
110
125
|
return v < min ? min : v > max ? max : v;
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
|
|
128
|
+
const POSITIONAL_KEYS = new Set([
|
|
129
|
+
'width',
|
|
130
|
+
'height',
|
|
131
|
+
'top',
|
|
132
|
+
'left',
|
|
133
|
+
'right',
|
|
134
|
+
'bottom',
|
|
135
|
+
'transform',
|
|
136
|
+
'transformPerspective',
|
|
137
|
+
'x',
|
|
138
|
+
'y',
|
|
139
|
+
'z',
|
|
140
|
+
'translateX',
|
|
141
|
+
'translateY',
|
|
142
|
+
'translateZ',
|
|
143
|
+
'scale',
|
|
144
|
+
'scaleX',
|
|
145
|
+
'scaleY',
|
|
146
|
+
'rotate',
|
|
147
|
+
'rotateX',
|
|
148
|
+
'rotateY',
|
|
149
|
+
'rotateZ',
|
|
150
|
+
'skew',
|
|
151
|
+
'skewX',
|
|
152
|
+
'skewY',
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
function reducedTransition(values: any, transition: any, shouldReduceMotion: boolean): any {
|
|
156
|
+
if (!shouldReduceMotion || values == null || typeof values !== 'object') return transition;
|
|
157
|
+
|
|
158
|
+
let reduced: Record<string, any> | null = null;
|
|
159
|
+
for (const key in values) {
|
|
160
|
+
if (!POSITIONAL_KEYS.has(key)) continue;
|
|
161
|
+
const next = (reduced ??= { ...(transition || {}) });
|
|
162
|
+
const valueTransition =
|
|
163
|
+
transition?.[key] != null && typeof transition[key] === 'object' ? transition[key] : {};
|
|
164
|
+
next[key] = { ...valueTransition, type: false, delay: 0, duration: 0 };
|
|
165
|
+
}
|
|
166
|
+
return reduced ?? transition;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function animateTarget(
|
|
170
|
+
node: HTMLElement,
|
|
171
|
+
values: any,
|
|
172
|
+
transition: any,
|
|
173
|
+
shouldReduceMotion: boolean,
|
|
174
|
+
): any {
|
|
175
|
+
return animate(node, values, reducedTransition(values, transition, shouldReduceMotion));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type MotionComponent = (props: any, scope: any) => void;
|
|
179
|
+
|
|
180
|
+
function createMotionComponent(tag: string, preloadedFeatures: boolean): MotionComponent {
|
|
114
181
|
return function MotionComponent(props: any, scope: any): void {
|
|
115
182
|
const config = useContext(MotionConfigContext);
|
|
183
|
+
const layoutGroup = useContext(LayoutGroupContext);
|
|
184
|
+
const lazy = useContext(LazyMotionContext);
|
|
116
185
|
const inherited = useContext(VariantContext);
|
|
117
186
|
// Read the PARENT's stagger orchestration before providing our own below.
|
|
118
187
|
const parentStagger = useContext(StaggerContext);
|
|
119
188
|
const variants = props.variants;
|
|
189
|
+
const features = preloadedFeatures ? domMax : lazy.features;
|
|
190
|
+
const animationEnabled = preloadedFeatures || features?.animation === true;
|
|
191
|
+
const gesturesEnabled = preloadedFeatures || features?.gestures === true;
|
|
192
|
+
const dragEnabled = preloadedFeatures || features?.drag === true;
|
|
193
|
+
const layoutEnabled = preloadedFeatures || features?.layout === true;
|
|
194
|
+
const shouldReduceMotion = config.shouldReduceMotion === true;
|
|
195
|
+
const layoutIdKey =
|
|
196
|
+
props.layoutId === undefined
|
|
197
|
+
? undefined
|
|
198
|
+
: layoutCellKey(
|
|
199
|
+
layoutGroup.layoutIdNamespace ?? (layoutGroup.id ? [layoutGroup.id] : undefined),
|
|
200
|
+
props.layoutId,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Reason: browser bundlers replace this expression even though they do not
|
|
204
|
+
// define a global `process`; guarding `process` would silently disable strict mode.
|
|
205
|
+
if (
|
|
206
|
+
process.env.NODE_ENV !== 'production' &&
|
|
207
|
+
preloadedFeatures &&
|
|
208
|
+
lazy.provided &&
|
|
209
|
+
lazy.strict
|
|
210
|
+
) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
'You have rendered a `motion` component within a strict `LazyMotion` component. Import and render from `@octanejs/motion/react-m` instead.',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
120
215
|
|
|
121
216
|
// Variant labels: an explicit prop wins, else inherit the parent's label.
|
|
122
217
|
const initialLabel = props.initial !== undefined ? props.initial : inherited.initial;
|
|
@@ -201,11 +296,18 @@ function createMotionComponent(tag: string) {
|
|
|
201
296
|
latest.onDrag = props.onDrag;
|
|
202
297
|
latest.onDragStart = props.onDragStart;
|
|
203
298
|
latest.onDragEnd = props.onDragEnd;
|
|
299
|
+
latest.shouldReduceMotion = shouldReduceMotion;
|
|
300
|
+
latest.animationEnabled = animationEnabled;
|
|
301
|
+
latest.layoutEnabled = layoutEnabled;
|
|
302
|
+
latest.layoutIdKey = layoutIdKey;
|
|
303
|
+
latest.layoutMode = layoutAnimationMode(props.layout);
|
|
204
304
|
|
|
205
305
|
// `initial`: apply instantly on mount (before the animate effect runs).
|
|
206
306
|
useLayoutEffect(
|
|
207
307
|
() => {
|
|
208
|
-
if (
|
|
308
|
+
if (animationEnabled && resolvedInitial) {
|
|
309
|
+
animate(node, resolvedInitial, { duration: 0 });
|
|
310
|
+
}
|
|
209
311
|
},
|
|
210
312
|
[],
|
|
211
313
|
ENTER,
|
|
@@ -216,7 +318,7 @@ function createMotionComponent(tag: string) {
|
|
|
216
318
|
// count are both known by now — all children registered during the parent render).
|
|
217
319
|
useLayoutEffect(
|
|
218
320
|
() => {
|
|
219
|
-
if (resolvedAnimate) {
|
|
321
|
+
if (animationEnabled && resolvedAnimate) {
|
|
220
322
|
let t = transition;
|
|
221
323
|
const o = latest.staggerParent;
|
|
222
324
|
if (o && o.active) {
|
|
@@ -232,12 +334,12 @@ function createMotionComponent(tag: string) {
|
|
|
232
334
|
}
|
|
233
335
|
if (delay > 0) t = { ...(t || {}), delay: (t?.delay || 0) + delay };
|
|
234
336
|
}
|
|
235
|
-
const controls =
|
|
337
|
+
const controls = animateTarget(node, resolvedAnimate, t, latest.shouldReduceMotion);
|
|
236
338
|
if (props.onAnimationComplete) whenDone(controls, () => props.onAnimationComplete());
|
|
237
339
|
return () => controls.stop();
|
|
238
340
|
}
|
|
239
341
|
},
|
|
240
|
-
[stableKey(resolvedAnimate), stableKey(transition)],
|
|
342
|
+
[animationEnabled, stableKey(resolvedAnimate), stableKey(transition), shouldReduceMotion],
|
|
241
343
|
ANIMATE,
|
|
242
344
|
);
|
|
243
345
|
|
|
@@ -284,6 +386,7 @@ function createMotionComponent(tag: string) {
|
|
|
284
386
|
// read from `latest`, so prop changes take effect without re-binding.
|
|
285
387
|
useLayoutEffect(
|
|
286
388
|
() => {
|
|
389
|
+
if (!gesturesEnabled) return;
|
|
287
390
|
const cleanups: Array<() => void> = [];
|
|
288
391
|
const gesture = (
|
|
289
392
|
bind: (el: Element, onStart: () => () => void) => () => void,
|
|
@@ -291,9 +394,14 @@ function createMotionComponent(tag: string) {
|
|
|
291
394
|
transitionKey: string,
|
|
292
395
|
) =>
|
|
293
396
|
bind(node, () => {
|
|
294
|
-
|
|
397
|
+
animateTarget(
|
|
398
|
+
node,
|
|
399
|
+
latest[valuesKey],
|
|
400
|
+
latest[transitionKey] ?? latest.transition,
|
|
401
|
+
latest.shouldReduceMotion,
|
|
402
|
+
);
|
|
295
403
|
return () => {
|
|
296
|
-
|
|
404
|
+
animateTarget(node, latest.base, latest.transition, latest.shouldReduceMotion);
|
|
297
405
|
};
|
|
298
406
|
});
|
|
299
407
|
if (props.whileHover)
|
|
@@ -301,8 +409,14 @@ function createMotionComponent(tag: string) {
|
|
|
301
409
|
if (props.whileTap) cleanups.push(gesture(press as any, 'whileTap', 'whileTapTransition'));
|
|
302
410
|
if (props.whileFocus) {
|
|
303
411
|
const onFocus = () =>
|
|
304
|
-
|
|
305
|
-
|
|
412
|
+
animateTarget(
|
|
413
|
+
node,
|
|
414
|
+
latest.whileFocus,
|
|
415
|
+
latest.whileFocusTransition ?? latest.transition,
|
|
416
|
+
latest.shouldReduceMotion,
|
|
417
|
+
);
|
|
418
|
+
const onBlur = () =>
|
|
419
|
+
animateTarget(node, latest.base, latest.transition, latest.shouldReduceMotion);
|
|
306
420
|
node.addEventListener('focus', onFocus);
|
|
307
421
|
node.addEventListener('blur', onBlur);
|
|
308
422
|
cleanups.push(() => {
|
|
@@ -312,7 +426,7 @@ function createMotionComponent(tag: string) {
|
|
|
312
426
|
}
|
|
313
427
|
return () => cleanups.forEach((c) => c());
|
|
314
428
|
},
|
|
315
|
-
[],
|
|
429
|
+
[gesturesEnabled],
|
|
316
430
|
GESTURE,
|
|
317
431
|
);
|
|
318
432
|
|
|
@@ -320,20 +434,27 @@ function createMotionComponent(tag: string) {
|
|
|
320
434
|
// when it leaves (unless `viewport.once`). Reuses motion's `inView`.
|
|
321
435
|
useLayoutEffect(
|
|
322
436
|
() => {
|
|
323
|
-
if (!props.whileInView) return;
|
|
437
|
+
if (!gesturesEnabled || !props.whileInView) return;
|
|
324
438
|
const stop = inView(
|
|
325
439
|
node,
|
|
326
440
|
() => {
|
|
327
|
-
|
|
441
|
+
animateTarget(
|
|
442
|
+
node,
|
|
443
|
+
latest.whileInView,
|
|
444
|
+
latest.whileInViewTransition ?? latest.transition,
|
|
445
|
+
latest.shouldReduceMotion,
|
|
446
|
+
);
|
|
328
447
|
return () => {
|
|
329
|
-
if (!props.viewport?.once)
|
|
448
|
+
if (!props.viewport?.once) {
|
|
449
|
+
animateTarget(node, latest.base, latest.transition, latest.shouldReduceMotion);
|
|
450
|
+
}
|
|
330
451
|
};
|
|
331
452
|
},
|
|
332
453
|
props.viewport,
|
|
333
454
|
);
|
|
334
455
|
return () => stop();
|
|
335
456
|
},
|
|
336
|
-
[],
|
|
457
|
+
[gesturesEnabled],
|
|
337
458
|
INVIEW,
|
|
338
459
|
);
|
|
339
460
|
|
|
@@ -341,7 +462,7 @@ function createMotionComponent(tag: string) {
|
|
|
341
462
|
// (`drag="x"`/`"y"`) and `dragConstraints` (a box of left/right/top/bottom px).
|
|
342
463
|
useLayoutEffect(
|
|
343
464
|
() => {
|
|
344
|
-
if (!props.drag) return;
|
|
465
|
+
if (!dragEnabled || !props.drag) return;
|
|
345
466
|
let active = false;
|
|
346
467
|
let startX = 0;
|
|
347
468
|
let startY = 0;
|
|
@@ -388,7 +509,7 @@ function createMotionComponent(tag: string) {
|
|
|
388
509
|
window.removeEventListener('pointerup', onUp);
|
|
389
510
|
};
|
|
390
511
|
},
|
|
391
|
-
[],
|
|
512
|
+
[dragEnabled],
|
|
392
513
|
DRAG,
|
|
393
514
|
);
|
|
394
515
|
|
|
@@ -399,21 +520,28 @@ function createMotionComponent(tag: string) {
|
|
|
399
520
|
// tree — nested/shared layout, scale correction — is out of scope.)
|
|
400
521
|
useLayoutEffect(
|
|
401
522
|
() => {
|
|
402
|
-
if (!props.layout)
|
|
523
|
+
if (!layoutEnabled || !props.layout) {
|
|
524
|
+
latest.layoutBox = undefined;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
403
527
|
const prevTransform = node.style.transform;
|
|
404
528
|
node.style.transform = '';
|
|
405
529
|
const r = node.getBoundingClientRect();
|
|
406
530
|
const box = { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
407
531
|
const prev = latest.layoutBox;
|
|
408
532
|
latest.layoutBox = box;
|
|
409
|
-
if (!prev)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
533
|
+
if (!prev) {
|
|
534
|
+
node.style.transform = prevTransform;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const projection = projectLayout(prev, box, latest.layoutMode);
|
|
538
|
+
if (projection) {
|
|
539
|
+
if (latest.shouldReduceMotion) {
|
|
540
|
+
node.style.transform = prevTransform;
|
|
541
|
+
} else {
|
|
542
|
+
node.style.transform = projection.from;
|
|
543
|
+
animate(node, { transform: projection.to }, layoutTransition(latest.transition));
|
|
544
|
+
}
|
|
417
545
|
} else {
|
|
418
546
|
node.style.transform = prevTransform;
|
|
419
547
|
}
|
|
@@ -423,41 +551,43 @@ function createMotionComponent(tag: string) {
|
|
|
423
551
|
);
|
|
424
552
|
|
|
425
553
|
// `layoutId`: shared-element crossfade. On mount, if a same-id element recently
|
|
426
|
-
// unmounted, FLIP from its recorded box to ours
|
|
427
|
-
// the next same-id element (the cleanup runs while still in the DOM).
|
|
554
|
+
// unmounted, FLIP from its recorded box to ours.
|
|
428
555
|
useLayoutEffect(
|
|
429
556
|
() => {
|
|
430
|
-
|
|
557
|
+
if (!layoutEnabled) return;
|
|
558
|
+
const id = layoutIdKey;
|
|
431
559
|
if (!id) return;
|
|
432
|
-
const prev =
|
|
560
|
+
const prev = takeLayoutCell(id);
|
|
433
561
|
if (prev) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
562
|
+
if (!latest.shouldReduceMotion) {
|
|
563
|
+
const prevTransform = node.style.transform;
|
|
564
|
+
node.style.transform = '';
|
|
565
|
+
const box = boxOf(node);
|
|
566
|
+
const projection = projectLayout(prev, box, latest.layoutMode);
|
|
567
|
+
if (projection) {
|
|
568
|
+
node.style.transform = projection.from;
|
|
569
|
+
animate(node, { transform: projection.to }, layoutTransition(latest.transition));
|
|
570
|
+
} else {
|
|
571
|
+
node.style.transform = prevTransform;
|
|
572
|
+
}
|
|
444
573
|
}
|
|
445
574
|
}
|
|
446
|
-
return () => {
|
|
447
|
-
if (node.isConnected) layoutCells.set(id, boxOf(node));
|
|
448
|
-
};
|
|
449
575
|
},
|
|
450
|
-
[],
|
|
576
|
+
[layoutEnabled, layoutIdKey],
|
|
451
577
|
LAYOUT_ID,
|
|
452
578
|
);
|
|
453
579
|
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
// animate the exit on the clone, and remove the clone when it finishes.
|
|
580
|
+
// Unmount: this cleanup runs while the node is still in the DOM. Record shared
|
|
581
|
+
// layout state here—not in the keyed claim effect, whose cleanup also runs on
|
|
582
|
+
// dependency changes—then clone any exiting node outside the block's range.
|
|
458
583
|
useLayoutEffect(
|
|
459
584
|
() => () => {
|
|
460
585
|
const n: HTMLElement | null = latest.node;
|
|
586
|
+
const layoutId = latest.layoutIdKey;
|
|
587
|
+
if (latest.layoutEnabled && layoutId && n?.isConnected) {
|
|
588
|
+
recordLayoutCell(layoutId, boxOf(n));
|
|
589
|
+
}
|
|
590
|
+
if (!latest.animationEnabled) return;
|
|
461
591
|
const exit = latest.exit;
|
|
462
592
|
if (!exit || !n || !n.isConnected || n.parentNode == null) return;
|
|
463
593
|
const parent = n.parentNode as HTMLElement;
|
|
@@ -469,7 +599,12 @@ function createMotionComponent(tag: string) {
|
|
|
469
599
|
clone.style.width = `${rect.width}px`;
|
|
470
600
|
clone.style.height = `${rect.height}px`;
|
|
471
601
|
parent.appendChild(clone);
|
|
472
|
-
const controls =
|
|
602
|
+
const controls = animateTarget(
|
|
603
|
+
clone,
|
|
604
|
+
exit,
|
|
605
|
+
latest.exitTransition ?? latest.transition,
|
|
606
|
+
latest.shouldReduceMotion,
|
|
607
|
+
);
|
|
473
608
|
whenDone(controls, () => clone.remove());
|
|
474
609
|
},
|
|
475
610
|
[],
|
|
@@ -478,17 +613,41 @@ function createMotionComponent(tag: string) {
|
|
|
478
613
|
};
|
|
479
614
|
}
|
|
480
615
|
|
|
616
|
+
export const domAnimation: MotionFeatureBundle = Object.freeze({
|
|
617
|
+
animation: true,
|
|
618
|
+
gestures: true,
|
|
619
|
+
drag: false,
|
|
620
|
+
layout: false,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
export const domMax: MotionFeatureBundle = Object.freeze({
|
|
624
|
+
animation: true,
|
|
625
|
+
gestures: true,
|
|
626
|
+
drag: true,
|
|
627
|
+
layout: true,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
export type MotionProxy = Record<string, MotionComponent>;
|
|
631
|
+
|
|
632
|
+
function motionProxy(preloadedFeatures: boolean): MotionProxy {
|
|
633
|
+
return new Proxy(
|
|
634
|
+
{},
|
|
635
|
+
{
|
|
636
|
+
get(cache: Record<string, MotionComponent>, tag: string | symbol) {
|
|
637
|
+
if (typeof tag !== 'string') return undefined;
|
|
638
|
+
return cache[tag] ?? (cache[tag] = createMotionComponent(tag, preloadedFeatures));
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
481
644
|
// `motion.div`, `motion.span`, … — a proxy that lazily builds (and caches) a
|
|
482
645
|
// component per tag.
|
|
483
|
-
export const motion
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return cache[tag] ?? (cache[tag] = createMotionComponent(tag));
|
|
489
|
-
},
|
|
490
|
-
},
|
|
491
|
-
);
|
|
646
|
+
export const motion = motionProxy(true);
|
|
647
|
+
|
|
648
|
+
// `m` renders the same hosts but activates animation/gesture/layout behavior
|
|
649
|
+
// only from its nearest LazyMotion feature bundle.
|
|
650
|
+
export const m = motionProxy(false);
|
|
492
651
|
|
|
493
652
|
// AnimatePresence — renders its children; each `motion.*` with an `exit` prop
|
|
494
653
|
// self-animates its own removal (see the exit cleanup above), so this is a thin
|
|
@@ -497,13 +656,122 @@ export function AnimatePresence(props: any, scope: any): void {
|
|
|
497
656
|
renderTransparentChildren(props.children, scope);
|
|
498
657
|
}
|
|
499
658
|
|
|
659
|
+
export interface LayoutGroupProps {
|
|
660
|
+
children?: unknown;
|
|
661
|
+
id?: string;
|
|
662
|
+
inherit?: boolean | 'id';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function LayoutGroup(props: LayoutGroupProps, scope: any): void {
|
|
666
|
+
const parent = useContext(LayoutGroupContext);
|
|
667
|
+
const value = useMemo(
|
|
668
|
+
() => {
|
|
669
|
+
const inherit = props.inherit ?? true;
|
|
670
|
+
const inheritId = inherit === true || inherit === 'id';
|
|
671
|
+
const id =
|
|
672
|
+
inheritId && parent.id ? (props.id ? `${parent.id}-${props.id}` : parent.id) : props.id;
|
|
673
|
+
const parentNamespace = parent.layoutIdNamespace ?? (parent.id ? [parent.id] : undefined);
|
|
674
|
+
const layoutIdNamespace =
|
|
675
|
+
inheritId && parent.id
|
|
676
|
+
? props.id
|
|
677
|
+
? [...(parentNamespace ?? [parent.id]), props.id]
|
|
678
|
+
: parentNamespace
|
|
679
|
+
: props.id
|
|
680
|
+
? [props.id]
|
|
681
|
+
: undefined;
|
|
682
|
+
return { id, layoutIdNamespace };
|
|
683
|
+
},
|
|
684
|
+
[parent.id, parent.layoutIdNamespace, props.id, props.inherit],
|
|
685
|
+
LAYOUT_GROUP_STATE,
|
|
686
|
+
);
|
|
687
|
+
provideContext(scope, LayoutGroupContext, value);
|
|
688
|
+
renderTransparentChildren(props.children, scope);
|
|
689
|
+
}
|
|
690
|
+
|
|
500
691
|
// MotionConfig — provides global defaults (transition, reduced motion) to every
|
|
501
692
|
// motion element below it. A plain-TS component: stamps the config context, then
|
|
502
693
|
// renders children.
|
|
503
694
|
export function MotionConfig(props: any, scope: any): void {
|
|
695
|
+
const parent = useContext(MotionConfigContext);
|
|
696
|
+
const reducedMotion = props.reducedMotion ?? parent.reducedMotion ?? 'never';
|
|
697
|
+
// Octane slots hooks by call site, so this subscription can stay conditional.
|
|
698
|
+
// Avoid paying for matchMedia listeners when the policy never consults it.
|
|
699
|
+
const userPreference = reducedMotion === 'user' ? useReducedMotionAtSlot(CONFIG_REDUCED) : false;
|
|
504
700
|
provideContext(scope, MotionConfigContext, {
|
|
505
|
-
transition: props.transition,
|
|
506
|
-
reducedMotion
|
|
701
|
+
transition: props.transition ?? parent.transition,
|
|
702
|
+
reducedMotion,
|
|
703
|
+
shouldReduceMotion: reducedMotion === 'always' || (reducedMotion === 'user' && userPreference),
|
|
704
|
+
});
|
|
705
|
+
renderTransparentChildren(props.children, scope);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export type LazyMotionFeatures =
|
|
709
|
+
| MotionFeatureBundle
|
|
710
|
+
| (() =>
|
|
711
|
+
| Promise<MotionFeatureBundle | { default: MotionFeatureBundle }>
|
|
712
|
+
| MotionFeatureBundle
|
|
713
|
+
| { default: MotionFeatureBundle });
|
|
714
|
+
|
|
715
|
+
export interface LazyMotionProps {
|
|
716
|
+
children?: unknown;
|
|
717
|
+
features: LazyMotionFeatures;
|
|
718
|
+
strict?: boolean;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
interface LoadedLazyFeatures {
|
|
722
|
+
features: MotionFeatureBundle | null;
|
|
723
|
+
error?: { value: unknown };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function normalizeFeatures(
|
|
727
|
+
value: MotionFeatureBundle | { default: MotionFeatureBundle },
|
|
728
|
+
): MotionFeatureBundle {
|
|
729
|
+
return 'default' in value ? value.default : value;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function LazyMotion(props: LazyMotionProps, scope: any): void {
|
|
733
|
+
const [loaded, setLoaded] = useState<LoadedLazyFeatures>(() => ({ features: null }), LAZY_STATE);
|
|
734
|
+
const source = props.features;
|
|
735
|
+
const isLoader = typeof source === 'function';
|
|
736
|
+
const features = isLoader ? loaded.features : normalizeFeatures(source);
|
|
737
|
+
if (isLoader && loaded.error) throw loaded.error.value;
|
|
738
|
+
|
|
739
|
+
// Match Motion's mount-time loader semantics. In particular, an idiomatic
|
|
740
|
+
// inline `features={() => import(...)}` must survive parent re-renders.
|
|
741
|
+
useEffect(
|
|
742
|
+
() => {
|
|
743
|
+
if (typeof source !== 'function') return;
|
|
744
|
+
let current = true;
|
|
745
|
+
const fail = (error: unknown) => {
|
|
746
|
+
if (current) setLoaded({ features: null, error: { value: error } });
|
|
747
|
+
};
|
|
748
|
+
try {
|
|
749
|
+
void Promise.resolve(source()).then((value) => {
|
|
750
|
+
if (!current) return;
|
|
751
|
+
let features: MotionFeatureBundle;
|
|
752
|
+
try {
|
|
753
|
+
features = normalizeFeatures(value);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
fail(error);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
setLoaded({ features });
|
|
759
|
+
}, fail);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
fail(error);
|
|
762
|
+
}
|
|
763
|
+
return () => {
|
|
764
|
+
current = false;
|
|
765
|
+
};
|
|
766
|
+
},
|
|
767
|
+
[],
|
|
768
|
+
LAZY_EFFECT,
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
provideContext(scope, LazyMotionContext, {
|
|
772
|
+
features,
|
|
773
|
+
strict: props.strict === true,
|
|
774
|
+
provided: true,
|
|
507
775
|
});
|
|
508
776
|
renderTransparentChildren(props.children, scope);
|
|
509
777
|
}
|
|
@@ -518,7 +786,15 @@ export { useScroll } from './useScroll';
|
|
|
518
786
|
export { useTransform } from './useTransform';
|
|
519
787
|
export { useSpring } from './useSpring';
|
|
520
788
|
export { useMotionValueEvent } from './useMotionValueEvent';
|
|
521
|
-
export {
|
|
789
|
+
export { useReducedMotion } from './useReducedMotion';
|
|
790
|
+
export {
|
|
791
|
+
LayoutGroupContext,
|
|
792
|
+
LazyMotionContext,
|
|
793
|
+
MotionConfigContext,
|
|
794
|
+
VariantContext,
|
|
795
|
+
StaggerContext,
|
|
796
|
+
} from './context';
|
|
522
797
|
|
|
523
798
|
// Re-export motion's framework-agnostic helpers (animate, stagger, value types, …).
|
|
524
799
|
export * from 'motion';
|
|
800
|
+
export type { TargetAndTransition, Transition } from 'motion';
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export interface LayoutBox {
|
|
2
|
+
left: number;
|
|
3
|
+
top: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type LayoutAnimationMode = true | 'position' | 'size';
|
|
9
|
+
|
|
10
|
+
export interface LayoutProjection {
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function layoutAnimationMode(layout: unknown): LayoutAnimationMode {
|
|
16
|
+
return layout === 'position' || layout === 'size' ? layout : true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function projectLayout(
|
|
20
|
+
previous: LayoutBox,
|
|
21
|
+
next: LayoutBox,
|
|
22
|
+
mode: LayoutAnimationMode,
|
|
23
|
+
): LayoutProjection | null {
|
|
24
|
+
const translate = mode !== 'size';
|
|
25
|
+
const scale = mode !== 'position';
|
|
26
|
+
const x = translate ? previous.left - next.left : 0;
|
|
27
|
+
const y = translate ? previous.top - next.top : 0;
|
|
28
|
+
const scaleX = scale && next.width ? previous.width / next.width : 1;
|
|
29
|
+
const scaleY = scale && next.height ? previous.height / next.height : 1;
|
|
30
|
+
|
|
31
|
+
if (x === 0 && y === 0 && scaleX === 1 && scaleY === 1) return null;
|
|
32
|
+
if (mode === 'position') {
|
|
33
|
+
return {
|
|
34
|
+
from: `translate(${x}px, ${y}px)`,
|
|
35
|
+
to: 'translate(0px, 0px)',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (mode === 'size') {
|
|
39
|
+
return {
|
|
40
|
+
from: `scale(${scaleX}, ${scaleY})`,
|
|
41
|
+
to: 'scale(1, 1)',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
from: `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`,
|
|
46
|
+
to: 'translate(0px, 0px) scale(1, 1)',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function layoutTransition(transition: any): any {
|
|
51
|
+
const selected = transition?.layout ?? transition?.default ?? transition;
|
|
52
|
+
if (selected !== transition && selected?.inherit && transition) {
|
|
53
|
+
const { inherit: _inherit, ...valueTransition } = selected;
|
|
54
|
+
return { ...transition, ...valueTransition };
|
|
55
|
+
}
|
|
56
|
+
return selected;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function layoutCellKey(namespace: readonly string[] | undefined, layoutId: unknown): string {
|
|
60
|
+
let key = '';
|
|
61
|
+
if (namespace) {
|
|
62
|
+
for (const segment of namespace) key += `${segment.length}:${segment}|`;
|
|
63
|
+
}
|
|
64
|
+
const id = String(layoutId);
|
|
65
|
+
return `${key}${id.length}:${id}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Shared layout snapshots only bridge replacements in the current synchronous
|
|
69
|
+
// commit. Preserve every handoff in that commit, then expire anything unclaimed
|
|
70
|
+
// at the next microtask so no snapshot survives into a later turn.
|
|
71
|
+
const layoutCells = new Map<string, LayoutBox>();
|
|
72
|
+
let pendingClear: object | null = null;
|
|
73
|
+
|
|
74
|
+
function scheduleLayoutCellClear(): void {
|
|
75
|
+
if (pendingClear) return;
|
|
76
|
+
const token = {};
|
|
77
|
+
pendingClear = token;
|
|
78
|
+
queueMicrotask(() => {
|
|
79
|
+
if (pendingClear !== token) return;
|
|
80
|
+
layoutCells.clear();
|
|
81
|
+
pendingClear = null;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function recordLayoutCell(id: string, box: LayoutBox): void {
|
|
86
|
+
layoutCells.set(id, box);
|
|
87
|
+
scheduleLayoutCellClear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function takeLayoutCell(id: string): LayoutBox | undefined {
|
|
91
|
+
const box = layoutCells.get(id);
|
|
92
|
+
if (box) layoutCells.delete(id);
|
|
93
|
+
return box;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const layoutCellsForTests = {
|
|
97
|
+
size: (): number => layoutCells.size,
|
|
98
|
+
reset(): void {
|
|
99
|
+
layoutCells.clear();
|
|
100
|
+
pendingClear = null;
|
|
101
|
+
},
|
|
102
|
+
};
|
package/src/react-m.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Tree-shakable-style named host surface matching `motion/react-m`.
|
|
2
|
+
//
|
|
3
|
+
// Each export is the lazy-feature variant of its corresponding motion host.
|
|
4
|
+
// Consumers normally use this entry as a namespace:
|
|
5
|
+
// import * as m from '@octanejs/motion/react-m'
|
|
6
|
+
// <m.div />
|
|
7
|
+
import { m } from './index';
|
|
8
|
+
|
|
9
|
+
export const {
|
|
10
|
+
a,
|
|
11
|
+
abbr,
|
|
12
|
+
address,
|
|
13
|
+
animate,
|
|
14
|
+
area,
|
|
15
|
+
article,
|
|
16
|
+
aside,
|
|
17
|
+
audio,
|
|
18
|
+
b,
|
|
19
|
+
base,
|
|
20
|
+
bdi,
|
|
21
|
+
bdo,
|
|
22
|
+
big,
|
|
23
|
+
blockquote,
|
|
24
|
+
body,
|
|
25
|
+
button,
|
|
26
|
+
canvas,
|
|
27
|
+
caption,
|
|
28
|
+
circle,
|
|
29
|
+
cite,
|
|
30
|
+
clipPath,
|
|
31
|
+
code,
|
|
32
|
+
col,
|
|
33
|
+
colgroup,
|
|
34
|
+
data,
|
|
35
|
+
datalist,
|
|
36
|
+
dd,
|
|
37
|
+
defs,
|
|
38
|
+
del,
|
|
39
|
+
desc,
|
|
40
|
+
details,
|
|
41
|
+
dfn,
|
|
42
|
+
dialog,
|
|
43
|
+
div,
|
|
44
|
+
dl,
|
|
45
|
+
dt,
|
|
46
|
+
ellipse,
|
|
47
|
+
em,
|
|
48
|
+
embed,
|
|
49
|
+
feBlend,
|
|
50
|
+
feColorMatrix,
|
|
51
|
+
feComponentTransfer,
|
|
52
|
+
feComposite,
|
|
53
|
+
feConvolveMatrix,
|
|
54
|
+
feDiffuseLighting,
|
|
55
|
+
feDisplacementMap,
|
|
56
|
+
feDistantLight,
|
|
57
|
+
feDropShadow,
|
|
58
|
+
feFlood,
|
|
59
|
+
feFuncA,
|
|
60
|
+
feFuncB,
|
|
61
|
+
feFuncG,
|
|
62
|
+
feFuncR,
|
|
63
|
+
feGaussianBlur,
|
|
64
|
+
feImage,
|
|
65
|
+
feMerge,
|
|
66
|
+
feMergeNode,
|
|
67
|
+
feMorphology,
|
|
68
|
+
feOffset,
|
|
69
|
+
fePointLight,
|
|
70
|
+
feSpecularLighting,
|
|
71
|
+
feSpotLight,
|
|
72
|
+
feTile,
|
|
73
|
+
feTurbulence,
|
|
74
|
+
fieldset,
|
|
75
|
+
figcaption,
|
|
76
|
+
figure,
|
|
77
|
+
filter,
|
|
78
|
+
footer,
|
|
79
|
+
foreignObject,
|
|
80
|
+
form,
|
|
81
|
+
g,
|
|
82
|
+
h1,
|
|
83
|
+
h2,
|
|
84
|
+
h3,
|
|
85
|
+
h4,
|
|
86
|
+
h5,
|
|
87
|
+
h6,
|
|
88
|
+
head,
|
|
89
|
+
header,
|
|
90
|
+
hgroup,
|
|
91
|
+
hr,
|
|
92
|
+
html,
|
|
93
|
+
i,
|
|
94
|
+
iframe,
|
|
95
|
+
image,
|
|
96
|
+
img,
|
|
97
|
+
input,
|
|
98
|
+
ins,
|
|
99
|
+
kbd,
|
|
100
|
+
keygen,
|
|
101
|
+
label,
|
|
102
|
+
legend,
|
|
103
|
+
li,
|
|
104
|
+
line,
|
|
105
|
+
linearGradient,
|
|
106
|
+
link,
|
|
107
|
+
main,
|
|
108
|
+
map,
|
|
109
|
+
mark,
|
|
110
|
+
marker,
|
|
111
|
+
mask,
|
|
112
|
+
menu,
|
|
113
|
+
menuitem,
|
|
114
|
+
metadata,
|
|
115
|
+
meter,
|
|
116
|
+
nav,
|
|
117
|
+
object,
|
|
118
|
+
ol,
|
|
119
|
+
optgroup,
|
|
120
|
+
option,
|
|
121
|
+
output,
|
|
122
|
+
p,
|
|
123
|
+
param,
|
|
124
|
+
path,
|
|
125
|
+
pattern,
|
|
126
|
+
picture,
|
|
127
|
+
polygon,
|
|
128
|
+
polyline,
|
|
129
|
+
pre,
|
|
130
|
+
progress,
|
|
131
|
+
q,
|
|
132
|
+
radialGradient,
|
|
133
|
+
rect,
|
|
134
|
+
rp,
|
|
135
|
+
rt,
|
|
136
|
+
ruby,
|
|
137
|
+
s,
|
|
138
|
+
samp,
|
|
139
|
+
script,
|
|
140
|
+
section,
|
|
141
|
+
select,
|
|
142
|
+
small,
|
|
143
|
+
source,
|
|
144
|
+
span,
|
|
145
|
+
stop,
|
|
146
|
+
strong,
|
|
147
|
+
style,
|
|
148
|
+
sub,
|
|
149
|
+
summary,
|
|
150
|
+
sup,
|
|
151
|
+
svg,
|
|
152
|
+
symbol,
|
|
153
|
+
table,
|
|
154
|
+
tbody,
|
|
155
|
+
td,
|
|
156
|
+
text,
|
|
157
|
+
textPath,
|
|
158
|
+
textarea,
|
|
159
|
+
tfoot,
|
|
160
|
+
th,
|
|
161
|
+
thead,
|
|
162
|
+
time,
|
|
163
|
+
title,
|
|
164
|
+
tr,
|
|
165
|
+
track,
|
|
166
|
+
tspan,
|
|
167
|
+
u,
|
|
168
|
+
ul,
|
|
169
|
+
use,
|
|
170
|
+
video,
|
|
171
|
+
view,
|
|
172
|
+
wbr,
|
|
173
|
+
webview,
|
|
174
|
+
} = m;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// `useReducedMotion()` — subscribe to the user's operating-system preference.
|
|
2
|
+
//
|
|
3
|
+
// Motion's React hook reads the shared `prefers-reduced-motion` query. This
|
|
4
|
+
// binding keeps the query shared too, but also publishes later `change` events
|
|
5
|
+
// so mounted Octane consumers update when the setting changes.
|
|
6
|
+
import { useSyncExternalStore } from 'octane';
|
|
7
|
+
|
|
8
|
+
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
|
9
|
+
|
|
10
|
+
type MatchMedia = typeof window.matchMedia;
|
|
11
|
+
|
|
12
|
+
let activeMatchMedia: MatchMedia | null = null;
|
|
13
|
+
let activeQuery: MediaQueryList | null = null;
|
|
14
|
+
let detachActiveQuery: (() => void) | null = null;
|
|
15
|
+
const listeners = new Set<() => void>();
|
|
16
|
+
|
|
17
|
+
function notifyListeners(): void {
|
|
18
|
+
for (const listener of listeners) listener();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function attachQuery(query: MediaQueryList): () => void {
|
|
22
|
+
const onChange = () => notifyListeners();
|
|
23
|
+
|
|
24
|
+
if (typeof query.addEventListener === 'function') {
|
|
25
|
+
query.addEventListener('change', onChange);
|
|
26
|
+
return () => query.removeEventListener('change', onChange);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Safari < 14 exposes the deprecated MediaQueryList listener API only.
|
|
30
|
+
query.addListener(onChange);
|
|
31
|
+
return () => query.removeListener(onChange);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getQuery(): MediaQueryList | null {
|
|
35
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const matchMedia = window.matchMedia;
|
|
40
|
+
if (activeQuery === null || activeMatchMedia !== matchMedia) {
|
|
41
|
+
detachActiveQuery?.();
|
|
42
|
+
detachActiveQuery = null;
|
|
43
|
+
activeMatchMedia = matchMedia;
|
|
44
|
+
activeQuery = matchMedia.call(window, REDUCED_MOTION_QUERY);
|
|
45
|
+
if (listeners.size > 0) detachActiveQuery = attachQuery(activeQuery);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return activeQuery;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getSnapshot(): boolean {
|
|
52
|
+
return getQuery()?.matches ?? false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getServerSnapshot(): boolean {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function subscribe(listener: () => void): () => void {
|
|
60
|
+
listeners.add(listener);
|
|
61
|
+
const query = getQuery();
|
|
62
|
+
if (query !== null && detachActiveQuery === null) {
|
|
63
|
+
detachActiveQuery = attachQuery(query);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
listeners.delete(listener);
|
|
68
|
+
if (listeners.size === 0) {
|
|
69
|
+
detachActiveQuery?.();
|
|
70
|
+
detachActiveQuery = null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useReducedMotionAtSlot(slot: symbol): boolean {
|
|
76
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot, slot);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useReducedMotion(): boolean;
|
|
80
|
+
export function useReducedMotion(...args: any[]): boolean {
|
|
81
|
+
// Calls from compiled `.tsrx` append the call-site slot. Accept no public
|
|
82
|
+
// arguments, but deliberately strip that compiler-owned trailing Symbol.
|
|
83
|
+
const tail = args[args.length - 1];
|
|
84
|
+
const slot = typeof tail === 'symbol' ? tail : undefined;
|
|
85
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot, slot);
|
|
86
|
+
}
|