solidjs-motion 0.1.0
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/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/dist/src/default-values.d.ts +6 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/motion-config.d.ts +14 -0
- package/dist/src/motion-proxy.d.ts +103 -0
- package/dist/src/presence-context.d.ts +4 -0
- package/dist/src/presence.d.ts +95 -0
- package/dist/src/primitives/createDrag.d.ts +16 -0
- package/dist/src/primitives/createDragControls.d.ts +30 -0
- package/dist/src/primitives/createGestures.d.ts +8 -0
- package/dist/src/primitives/createInView.d.ts +51 -0
- package/dist/src/primitives/createMotion.d.ts +82 -0
- package/dist/src/primitives/createPan.d.ts +83 -0
- package/dist/src/primitives/createScroll.d.ts +40 -0
- package/dist/src/primitives/gesture-state.d.ts +108 -0
- package/dist/src/primitives/motion-value.d.ts +111 -0
- package/dist/src/primitives/value-registry.d.ts +34 -0
- package/dist/src/reduced-motion.d.ts +29 -0
- package/dist/src/style.d.ts +79 -0
- package/dist/src/types.d.ts +374 -0
- package/dist/src/use-motion.d.ts +35 -0
- package/dist/src/variants.d.ts +64 -0
- package/package.json +78 -0
- package/src/default-values.ts +52 -0
- package/src/index.ts +60 -0
- package/src/motion-config.tsx +37 -0
- package/src/motion-proxy.tsx +377 -0
- package/src/presence-context.ts +32 -0
- package/src/presence.tsx +466 -0
- package/src/primitives/createDrag.ts +670 -0
- package/src/primitives/createDragControls.ts +101 -0
- package/src/primitives/createGestures.ts +145 -0
- package/src/primitives/createInView.ts +124 -0
- package/src/primitives/createMotion.ts +638 -0
- package/src/primitives/createPan.ts +338 -0
- package/src/primitives/createScroll.ts +101 -0
- package/src/primitives/gesture-state.ts +772 -0
- package/src/primitives/motion-value.ts +328 -0
- package/src/primitives/value-registry.ts +114 -0
- package/src/reduced-motion.ts +51 -0
- package/src/style.ts +266 -0
- package/src/types.ts +538 -0
- package/src/use-motion.tsx +412 -0
- package/src/variants.ts +134 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { type AnimationPlaybackControls, animate, isMotionValue, type MotionValue } from "motion"
|
|
2
|
+
import { type Accessor, createEffect, createMemo, onCleanup, untrack } from "solid-js"
|
|
3
|
+
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
|
4
|
+
import { getMotionDefault } from "../default-values"
|
|
5
|
+
import { shouldReduceMotion } from "../reduced-motion"
|
|
6
|
+
import type {
|
|
7
|
+
AnimateValue,
|
|
8
|
+
MotionConfigContextValue,
|
|
9
|
+
MotionElement,
|
|
10
|
+
MotionOptions,
|
|
11
|
+
ResolvedValues,
|
|
12
|
+
Target,
|
|
13
|
+
Transition,
|
|
14
|
+
VariantContextValue,
|
|
15
|
+
} from "../types"
|
|
16
|
+
import { asVariantLabels, mergeTransition, resolveTarget } from "./createMotion"
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Solid-native fine-grained gesture state machine (ADR 0002).
|
|
20
|
+
//
|
|
21
|
+
// Implements three of the four jobs motion's createAnimationState handles:
|
|
22
|
+
// 1. Priority resolution — high-to-low among active states
|
|
23
|
+
// 2. Per-key handoff — when a higher-priority state deactivates, lower-priority
|
|
24
|
+
// states (or fallbacks) take over each key it was animating
|
|
25
|
+
// 3. Variant resolution — reuses Phase 1's resolveTarget for label→Target lookup
|
|
26
|
+
//
|
|
27
|
+
// Job 4 (parent-child variant inheritance via variantChildren) is handled
|
|
28
|
+
// reactively through Phase 1's VariantContext + Q4's active-gated label slots,
|
|
29
|
+
// NOT via this state machine — keeping the inheritance tree Solid-owned.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** State names, ordered low → high priority. Matches motion-dom's variantPriorityOrder. */
|
|
33
|
+
const STATE_NAMES = [
|
|
34
|
+
"animate",
|
|
35
|
+
"whileInView",
|
|
36
|
+
"whileHover",
|
|
37
|
+
"whilePress",
|
|
38
|
+
"whileFocus",
|
|
39
|
+
"whileDrag",
|
|
40
|
+
"exit",
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
export type GestureStateName = (typeof STATE_NAMES)[number]
|
|
44
|
+
|
|
45
|
+
/** High → low priority for the winners walk. Materialized once. */
|
|
46
|
+
const PRIORITY_HIGH_TO_LOW: readonly GestureStateName[] = [...STATE_NAMES].reverse()
|
|
47
|
+
|
|
48
|
+
/** A key's resolved value plus the (optional) per-target transition that produced it. */
|
|
49
|
+
type WinnerEntry = {
|
|
50
|
+
value: unknown
|
|
51
|
+
transition: Transition | undefined
|
|
52
|
+
/** Which state contributed this key — used by the diff effect's onAnimationComplete bookkeeping. */
|
|
53
|
+
stateName: GestureStateName
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SetActive = (state: GestureStateName, isActive: boolean) => void
|
|
57
|
+
|
|
58
|
+
/** The reactive store of active gesture flags, lifted to the caller for sharing. */
|
|
59
|
+
export type ActiveStore = Store<Record<GestureStateName, boolean>>
|
|
60
|
+
export type SetActiveStore = SetStoreFunction<Record<GestureStateName, boolean>>
|
|
61
|
+
export type ActiveStoreTuple = [ActiveStore, SetActiveStore]
|
|
62
|
+
|
|
63
|
+
export type CreateGestureStateMachineDeps = {
|
|
64
|
+
el: MotionElement
|
|
65
|
+
getOpts: () => MotionOptions
|
|
66
|
+
parentVariantCtx: VariantContextValue
|
|
67
|
+
motionConfig: MotionConfigContextValue
|
|
68
|
+
systemReducedMotion: Accessor<boolean>
|
|
69
|
+
/** Captured at construction. Used as the first stop in the removed-key fallback chain (Q7). */
|
|
70
|
+
initialTarget: Target | null
|
|
71
|
+
/**
|
|
72
|
+
* Optional external active store (Q4 — useMotion lifts this up so its
|
|
73
|
+
* `myVariantCtx` can read the same flags it propagates to descendants).
|
|
74
|
+
* When omitted, the state machine creates its own internal store —
|
|
75
|
+
* backward-compatible for `createMotion` direct users.
|
|
76
|
+
*/
|
|
77
|
+
externalActiveStore?: ActiveStoreTuple
|
|
78
|
+
/**
|
|
79
|
+
* Phase 3 — when an enclosing `<Presence initial={false}>` is active, this
|
|
80
|
+
* is passed through to suppress the first-mount animate (mirrors the
|
|
81
|
+
* existing `initial: false` user-opt-out, but driven from above by
|
|
82
|
+
* Presence instead of by the user's own options).
|
|
83
|
+
*/
|
|
84
|
+
suppressFirstMount?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* Phase 3 — readiness gate for the first-mount animate when this motion
|
|
87
|
+
* element is wrapped in a real `<Presence>`. The state machine reads this
|
|
88
|
+
* on each iteration; when it's `false` AND we haven't run yet, the diff
|
|
89
|
+
* effect short-circuits (no animate dispatch, no MV subscriptions sealed).
|
|
90
|
+
* Presence flips it to `true` from its `onEnter` / `onChange.added`
|
|
91
|
+
* callback, at which point the effect re-runs and treats THAT iteration
|
|
92
|
+
* as the first.
|
|
93
|
+
*
|
|
94
|
+
* Outside a Presence (no-op default context), createMotion leaves this
|
|
95
|
+
* `undefined` — the state machine treats absence as `ready=true` and the
|
|
96
|
+
* existing eager-first-iteration behavior is unchanged.
|
|
97
|
+
*
|
|
98
|
+
* Rationale: real `motion.animate()` is a Web Animations API call that
|
|
99
|
+
* runs even on a disconnected element, but its terminal `commitStyles`
|
|
100
|
+
* silently no-ops when the element is off-DOM. For a `mode: "wait"` swap
|
|
101
|
+
* the new child is created BEFORE the old child's exit completes, so
|
|
102
|
+
* dispatching the first animate eagerly would let it complete in the
|
|
103
|
+
* detached state and the element would paint at its `initial` target
|
|
104
|
+
* when it finally enters the DOM. Deferring until `onEnter` (when
|
|
105
|
+
* transition-group has synchronously inserted the element via
|
|
106
|
+
* `setReturned`) closes that gap.
|
|
107
|
+
*/
|
|
108
|
+
enterReady?: Accessor<boolean>
|
|
109
|
+
/**
|
|
110
|
+
* MV-in-style Stage 3 bridge. When provided, the diff effect calls this
|
|
111
|
+
* per animate-target key. A returned `MotionValue` routes the animation
|
|
112
|
+
* through that MV (`animate(mv, value, opts)`) — its change-subscription
|
|
113
|
+
* (in createMotion) composes `el.style` from the registry. `undefined`
|
|
114
|
+
* routes the key down the existing `animate(el, target, opts)` WAA path.
|
|
115
|
+
*
|
|
116
|
+
* createMotion only activates this when at least one external MV is
|
|
117
|
+
* registered (i.e., the user supplied `style: { scale: mv }`-shaped
|
|
118
|
+
* options). Inactive in the common case → 293 baseline tests stay on
|
|
119
|
+
* the original code path, their animateSpy assertions unaffected.
|
|
120
|
+
*/
|
|
121
|
+
getValueForAnimate?: (key: string, fallback: unknown) => MotionValue<unknown> | undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type GestureStateMachine = {
|
|
125
|
+
/** Imperatively toggle a gesture state. Triggers re-resolution + animate(). */
|
|
126
|
+
setActive: SetActive
|
|
127
|
+
/**
|
|
128
|
+
* Resolves when the next animate dispatched while `exit` is the highest-
|
|
129
|
+
* priority active driver state completes. If no exit animation is in
|
|
130
|
+
* flight AND no exit target is defined, resolves immediately.
|
|
131
|
+
*
|
|
132
|
+
* Used by `createMotion`'s presence registration: the registered
|
|
133
|
+
* `runExit` callable does `setActive("exit", true)` then awaits this.
|
|
134
|
+
* When the exit animation settles, `<Presence>` (or the hook) gets a
|
|
135
|
+
* resolved Promise and proceeds with DOM removal.
|
|
136
|
+
*
|
|
137
|
+
* Multiple concurrent waiters are supported — they all resolve from the
|
|
138
|
+
* same animation's completion.
|
|
139
|
+
*/
|
|
140
|
+
onceExitComplete: () => Promise<void>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Construct the per-element gesture state machine.
|
|
145
|
+
*
|
|
146
|
+
* Wired primitives:
|
|
147
|
+
* - `createStore` for the seven active flags — Solid tracks per-path, so
|
|
148
|
+
* toggling `whileHover` doesn't dirty memos reading `whilePress`.
|
|
149
|
+
* - `createMemo` for `stateTargets` — cached, re-runs only when opts/parent
|
|
150
|
+
* context change.
|
|
151
|
+
* - `createMemo` for `winners` — same caching, re-runs when `active` flags or
|
|
152
|
+
* `stateTargets` change.
|
|
153
|
+
* - `createEffect` for the diff-and-animate loop — fires on `winners` change;
|
|
154
|
+
* compares against `lastApplied` to compute changed/removed keys.
|
|
155
|
+
* - `onCleanup` inside the effect for per-iteration MV subscriptions — scoped
|
|
156
|
+
* to each effect run (fires on re-run AND owner disposal). Same iteration-
|
|
157
|
+
* scoped cleanup pattern Phase 1 established.
|
|
158
|
+
*/
|
|
159
|
+
export function createGestureStateMachine(
|
|
160
|
+
deps: CreateGestureStateMachineDeps,
|
|
161
|
+
): GestureStateMachine {
|
|
162
|
+
const {
|
|
163
|
+
el,
|
|
164
|
+
getOpts,
|
|
165
|
+
parentVariantCtx,
|
|
166
|
+
motionConfig,
|
|
167
|
+
systemReducedMotion,
|
|
168
|
+
initialTarget,
|
|
169
|
+
externalActiveStore,
|
|
170
|
+
suppressFirstMount,
|
|
171
|
+
enterReady,
|
|
172
|
+
getValueForAnimate,
|
|
173
|
+
} = deps
|
|
174
|
+
|
|
175
|
+
// ---------- Active flags ----------
|
|
176
|
+
// `animate` defaults true: it's the baseline state (mirrors motion's
|
|
177
|
+
// createTypeState(true) for animate). All other states start inactive.
|
|
178
|
+
// If the caller provided an external store (Q4 — useMotion lifts this up
|
|
179
|
+
// so myVariantCtx can read the same flags), reuse it; else create our own.
|
|
180
|
+
const [active, setActiveStore] =
|
|
181
|
+
externalActiveStore ??
|
|
182
|
+
createStore<Record<GestureStateName, boolean>>({
|
|
183
|
+
animate: true,
|
|
184
|
+
whileInView: false,
|
|
185
|
+
whileHover: false,
|
|
186
|
+
whilePress: false,
|
|
187
|
+
whileFocus: false,
|
|
188
|
+
whileDrag: false,
|
|
189
|
+
exit: false,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// ---------- Per-state resolved targets ----------
|
|
193
|
+
// createMemo (not createComputed): reads only run when opts/parent change,
|
|
194
|
+
// and the value is cached for downstream consumers. The animate call is
|
|
195
|
+
// frame-async tolerant — the side-effect createEffect below handles timing.
|
|
196
|
+
const stateTargets = createMemo<Record<GestureStateName, Target | null>>(() => {
|
|
197
|
+
const opts = getOpts()
|
|
198
|
+
const variants = opts.variants
|
|
199
|
+
const custom = opts.custom ?? parentVariantCtx.custom?.()
|
|
200
|
+
return {
|
|
201
|
+
animate: resolveTarget(
|
|
202
|
+
opts.animate,
|
|
203
|
+
variants,
|
|
204
|
+
asVariantLabels(parentVariantCtx.animate?.()),
|
|
205
|
+
custom,
|
|
206
|
+
),
|
|
207
|
+
whileInView: resolveTarget(
|
|
208
|
+
opts.inView,
|
|
209
|
+
variants,
|
|
210
|
+
asVariantLabels(parentVariantCtx.inView?.()),
|
|
211
|
+
custom,
|
|
212
|
+
),
|
|
213
|
+
whileHover: resolveTarget(
|
|
214
|
+
opts.hover,
|
|
215
|
+
variants,
|
|
216
|
+
asVariantLabels(parentVariantCtx.hover?.()),
|
|
217
|
+
custom,
|
|
218
|
+
),
|
|
219
|
+
whilePress: resolveTarget(
|
|
220
|
+
opts.press,
|
|
221
|
+
variants,
|
|
222
|
+
asVariantLabels(parentVariantCtx.press?.()),
|
|
223
|
+
custom,
|
|
224
|
+
),
|
|
225
|
+
whileFocus: resolveTarget(
|
|
226
|
+
opts.focus,
|
|
227
|
+
variants,
|
|
228
|
+
asVariantLabels(parentVariantCtx.focus?.()),
|
|
229
|
+
custom,
|
|
230
|
+
),
|
|
231
|
+
// whileDrag — resolved like any other gesture state's target. Drag's
|
|
232
|
+
// visual state composes with drag's translation through the shared
|
|
233
|
+
// VisualElement (Q5/C-lean).
|
|
234
|
+
whileDrag: resolveTarget(opts.whileDrag, variants, undefined, custom),
|
|
235
|
+
exit: resolveTarget(opts.exit, variants, asVariantLabels(parentVariantCtx.exit?.()), custom),
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// ---------- Per-key winners (priority resolution + per-key claim) ----------
|
|
240
|
+
// Walks PRIORITY_HIGH_TO_LOW. A state is considered active if EITHER its
|
|
241
|
+
// own flag is true OR the parent's VariantContext provides a label for it
|
|
242
|
+
// (Q4 — gesture inheritance through context). The first active state that
|
|
243
|
+
// defines a key claims it; lower-priority states are skipped for that key.
|
|
244
|
+
//
|
|
245
|
+
// Q5/C-lean exclusion: when `drag` is enabled, `x` and `y` are owned by
|
|
246
|
+
// createDrag (it writes them to the VisualElement's MotionValues during
|
|
247
|
+
// pointer phase). Filter them out of the winners map so motion's animate
|
|
248
|
+
// (called from this effect) doesn't fight drag's writes. Other transform
|
|
249
|
+
// keys (scale, rotate, etc.) still flow normally — they compose with
|
|
250
|
+
// drag's translation through the shared VisualElement.
|
|
251
|
+
//
|
|
252
|
+
// EXCEPTION: when `exit` is active, exit's x/y MUST override drag's claim.
|
|
253
|
+
// Otherwise an element being dragged at the moment of unmount would
|
|
254
|
+
// exit-animate without translation (drag would silently win every frame),
|
|
255
|
+
// which contradicts the priority chain's stated semantic (exit is highest).
|
|
256
|
+
// Drag's pointer listeners will release anyway when the element unmounts;
|
|
257
|
+
// exit's translation reaches DOM until that happens.
|
|
258
|
+
const winners = createMemo<Record<string, WinnerEntry>>(() => {
|
|
259
|
+
const targets = stateTargets()
|
|
260
|
+
const dragEnabled = Boolean(getOpts().drag)
|
|
261
|
+
const out: Record<string, WinnerEntry> = {}
|
|
262
|
+
for (const stateName of PRIORITY_HIGH_TO_LOW) {
|
|
263
|
+
if (!isStateActive(stateName, active, parentVariantCtx)) continue
|
|
264
|
+
const target = targets[stateName]
|
|
265
|
+
if (!target) continue
|
|
266
|
+
for (const key in target) {
|
|
267
|
+
// `transition` is animation config, not a style key — never a winner.
|
|
268
|
+
if (key === "transition") continue
|
|
269
|
+
// Higher-priority state already won this key.
|
|
270
|
+
if (key in out) continue
|
|
271
|
+
// x/y are drag-owned when drag is enabled (Q5/C-lean) — unless exit
|
|
272
|
+
// is currently active, in which case exit's translation wins.
|
|
273
|
+
if (!active.exit && dragEnabled && (key === "x" || key === "y")) continue
|
|
274
|
+
out[key] = {
|
|
275
|
+
value: (target as Record<string, unknown>)[key],
|
|
276
|
+
transition: target.transition,
|
|
277
|
+
stateName,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return out
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// ---------- Diff-and-animate effect ----------
|
|
285
|
+
// The single site that calls motion's animate(). Diffs winners against
|
|
286
|
+
// lastApplied to compute changed keys (a) and removed keys (b). Removed keys
|
|
287
|
+
// walk Q7's fallback chain: initial → motion default → null.
|
|
288
|
+
let prevControls: AnimationPlaybackControls | null = null
|
|
289
|
+
let lastApplied: Record<string, unknown> = {}
|
|
290
|
+
let isFirstRun = true
|
|
291
|
+
|
|
292
|
+
// ---------- onceExitComplete plumbing (Phase 3 — Presence integration) ----------
|
|
293
|
+
// Resolvers queued by `onceExitComplete()` waiters. Drain happens when an
|
|
294
|
+
// exit-driven animate dispatched from this effect resolves. Multiple waiters
|
|
295
|
+
// for the same exit batch all resolve from one drain.
|
|
296
|
+
let pendingExitResolvers: Array<() => void> = []
|
|
297
|
+
function drainPendingExitResolvers(): void {
|
|
298
|
+
const resolvers = pendingExitResolvers
|
|
299
|
+
pendingExitResolvers = []
|
|
300
|
+
for (const r of resolvers) r()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
createEffect(() => {
|
|
304
|
+
const next = winners()
|
|
305
|
+
const opts = getOpts()
|
|
306
|
+
|
|
307
|
+
// Presence-aware readiness gate: when the surrounding `<Presence>` is
|
|
308
|
+
// still holding this element off-DOM (the new child during a mode="wait"
|
|
309
|
+
// swap, or the initial child before appear's enterTransition runs),
|
|
310
|
+
// we MUST NOT dispatch the first animate. Web Animations API will run
|
|
311
|
+
// it to completion off-DOM, then silently drop the final commitStyles —
|
|
312
|
+
// the element would paint at its `initial` target when it eventually
|
|
313
|
+
// enters the DOM. Skip the entire iteration (winners() above subscribed
|
|
314
|
+
// us to future changes); when Presence flips enterReady this effect
|
|
315
|
+
// re-runs and the iteration below treats THAT pass as the first.
|
|
316
|
+
if (isFirstRun && enterReady && !enterReady()) {
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// First-mount guard: either the user opted out via `initial: false` OR
|
|
321
|
+
// an enclosing `<Presence initial={false}>` propagated suppression via
|
|
322
|
+
// `suppressFirstMount`. Either path seeds lastApplied so the next
|
|
323
|
+
// iteration treats current winners as already-applied. We fall through
|
|
324
|
+
// to the MV subscription loop so subsequent MV.set() drives animate.
|
|
325
|
+
let skipAnimate = false
|
|
326
|
+
if (isFirstRun && (untrack(() => opts.initial) === false || suppressFirstMount)) {
|
|
327
|
+
lastApplied = snapshotValues(next)
|
|
328
|
+
skipAnimate = true
|
|
329
|
+
}
|
|
330
|
+
isFirstRun = false
|
|
331
|
+
|
|
332
|
+
// Bail out completely only when there is nothing to do AND nothing has
|
|
333
|
+
// been applied yet (no lastApplied to revert). The previous version of
|
|
334
|
+
// this guard also bailed when `next` was empty even if lastApplied still
|
|
335
|
+
// held values from a previous gesture — preventing the removed-key
|
|
336
|
+
// fallback from reverting. The looser condition keeps the same early-out
|
|
337
|
+
// for the initial idle case while letting deactivation reverts run.
|
|
338
|
+
const bailOnNoTarget =
|
|
339
|
+
!skipAnimate &&
|
|
340
|
+
Object.keys(next).length === 0 &&
|
|
341
|
+
Object.keys(lastApplied).length === 0 &&
|
|
342
|
+
opts.animate === undefined &&
|
|
343
|
+
parentVariantCtx.animate?.() === undefined
|
|
344
|
+
if (bailOnNoTarget) return
|
|
345
|
+
|
|
346
|
+
// Compute changes: (a) keys with new/changed values, (b) removed keys.
|
|
347
|
+
const changes: Record<string, unknown> = {}
|
|
348
|
+
let mergedPerTargetTransition: Transition | undefined
|
|
349
|
+
|
|
350
|
+
if (!skipAnimate) {
|
|
351
|
+
for (const key in next) {
|
|
352
|
+
const entry = next[key]
|
|
353
|
+
// `noUncheckedIndexedAccess` widens record reads to `T | undefined`,
|
|
354
|
+
// but `for (key in obj)` only yields present keys — entry is real.
|
|
355
|
+
if (!entry) continue
|
|
356
|
+
if (lastApplied[key] !== entry.value) {
|
|
357
|
+
changes[key] = entry.value
|
|
358
|
+
// First non-undefined per-target transition wins. (If multiple winners
|
|
359
|
+
// contribute conflicting transitions, the highest-priority one already
|
|
360
|
+
// took precedence in the priority walk.)
|
|
361
|
+
mergedPerTargetTransition ??= entry.transition
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const key in lastApplied) {
|
|
365
|
+
if (key in next) continue
|
|
366
|
+
// Removed-key fallback: own initial → motion default → null.
|
|
367
|
+
const initialValue =
|
|
368
|
+
initialTarget && key in (initialTarget as Record<string, unknown>)
|
|
369
|
+
? (initialTarget as Record<string, unknown>)[key]
|
|
370
|
+
: undefined
|
|
371
|
+
changes[key] = initialValue !== undefined ? initialValue : getMotionDefault(key)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Transition merge: MotionConfig default < user's transition < per-target
|
|
376
|
+
// transition < reduced-motion override (Phase 1's mergeTransition).
|
|
377
|
+
const reduced = shouldReduceMotion(motionConfig.reducedMotion(), systemReducedMotion())
|
|
378
|
+
const transition = mergeTransition(
|
|
379
|
+
motionConfig.transition(),
|
|
380
|
+
opts.transition,
|
|
381
|
+
mergedPerTargetTransition,
|
|
382
|
+
reduced,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
// Track which animate value triggered this — used by onAnimationComplete.
|
|
386
|
+
// If `next` has any key from `animate` state, the effective value is opts.animate.
|
|
387
|
+
// If only gesture states are active, the highest-priority active state's value drives.
|
|
388
|
+
const driverState = highestActiveDriverState(next)
|
|
389
|
+
const effectiveAnimateValue = animateValueForState(driverState, opts, parentVariantCtx)
|
|
390
|
+
|
|
391
|
+
// Animate options builder — read at fire-time so reactive callback swaps
|
|
392
|
+
// apply between calls (per Phase 1 semantics). Closed over by the
|
|
393
|
+
// MV-on-change subscriptions below as well as the diff dispatch.
|
|
394
|
+
const buildAnimateOptions = () => ({
|
|
395
|
+
...transition,
|
|
396
|
+
onPlay: opts.onAnimationStart ? () => untrack(() => opts.onAnimationStart?.()) : undefined,
|
|
397
|
+
onComplete: opts.onAnimationComplete
|
|
398
|
+
? () =>
|
|
399
|
+
untrack(() => {
|
|
400
|
+
if (effectiveAnimateValue != null) {
|
|
401
|
+
opts.onAnimationComplete?.(effectiveAnimateValue)
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
: undefined,
|
|
405
|
+
onStop: opts.onAnimationCancel ? () => untrack(() => opts.onAnimationCancel?.()) : undefined,
|
|
406
|
+
onUpdate: opts.onUpdate
|
|
407
|
+
? (latest: ResolvedValues) => untrack(() => opts.onUpdate?.(latest))
|
|
408
|
+
: undefined,
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
if (!skipAnimate && Object.keys(changes).length > 0) {
|
|
412
|
+
// Update lastApplied to the new winner snapshot (NOT including removed-
|
|
413
|
+
// key fallback values — those become "applied" only after the animation
|
|
414
|
+
// lands, but tracking that requires onUpdate plumbing. For diff purposes,
|
|
415
|
+
// we consider them applied immediately; if the user re-activates a state
|
|
416
|
+
// that brings the key back, the diff sees `lastApplied[key] = fallback`
|
|
417
|
+
// vs `next[key] = newValue` and animates correctly).
|
|
418
|
+
lastApplied = { ...lastApplied, ...changes }
|
|
419
|
+
// Then drop keys that don't appear in `next` from lastApplied so future
|
|
420
|
+
// re-removals don't compare against stale fallback values.
|
|
421
|
+
for (const key in lastApplied) {
|
|
422
|
+
if (!(key in next) && !(key in changes)) delete lastApplied[key]
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------- splitTarget: separate MotionValue refs from plain values ----------
|
|
426
|
+
// Preserved from Phase 1: motion's vanilla animate(el, target) doesn't
|
|
427
|
+
// subscribe to MotionValue refs in target values. We split and seed the
|
|
428
|
+
// animate call with snapshots; per-MV subscription happens below.
|
|
429
|
+
const { plain } = splitTarget(changes)
|
|
430
|
+
|
|
431
|
+
// ---------- Stage 3 bridge: split `plain` by routing destination ----------
|
|
432
|
+
// When createMotion's `getValueForAnimate` returns an MV for a key, the
|
|
433
|
+
// tween runs against that MV (transient or external) and the registry's
|
|
434
|
+
// writer composes el.style.transform. When it returns undefined (the
|
|
435
|
+
// common case — no style MVs), the key falls through to the existing
|
|
436
|
+
// `animate(el, target, opts)` WAA path. With NO routed keys, we make a
|
|
437
|
+
// single WAA call exactly like before, preserving the call shape that
|
|
438
|
+
// baseline tests assert against.
|
|
439
|
+
const routed: Array<{ mv: MotionValue<unknown>; value: unknown }> = []
|
|
440
|
+
const waaPlain: Record<string, unknown> = {}
|
|
441
|
+
for (const key in plain) {
|
|
442
|
+
const value = plain[key]
|
|
443
|
+
const fallback =
|
|
444
|
+
initialTarget && key in (initialTarget as Record<string, unknown>)
|
|
445
|
+
? (initialTarget as Record<string, unknown>)[key]
|
|
446
|
+
: getMotionDefault(key)
|
|
447
|
+
const routedMV = getValueForAnimate?.(key, fallback)
|
|
448
|
+
if (routedMV) {
|
|
449
|
+
routed.push({ mv: routedMV, value })
|
|
450
|
+
} else {
|
|
451
|
+
waaPlain[key] = value
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Cancel any in-flight animation before kicking off the next one.
|
|
456
|
+
prevControls?.stop()
|
|
457
|
+
const animOpts = buildAnimateOptions()
|
|
458
|
+
if (routed.length === 0) {
|
|
459
|
+
// Pure WAA path — identical to the pre-Stage-3 behavior.
|
|
460
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape we can't tighten generically; the runtime call is correct.
|
|
461
|
+
prevControls = animate(el, waaPlain as any, animOpts)
|
|
462
|
+
} else {
|
|
463
|
+
// Bridge path — one tween per routed MV plus (optionally) a single
|
|
464
|
+
// WAA call for non-routed keys. `aggregateControls` combines them
|
|
465
|
+
// into a thenable that .stop()s each and resolves when all settle,
|
|
466
|
+
// so the exit-drain logic below works uniformly across both shapes.
|
|
467
|
+
const controls: AnimationPlaybackControls[] = []
|
|
468
|
+
for (const { mv, value } of routed) {
|
|
469
|
+
// biome-ignore lint/suspicious/noExplicitAny: same as above
|
|
470
|
+
controls.push(animate(mv as any, value as any, animOpts as any))
|
|
471
|
+
}
|
|
472
|
+
if (Object.keys(waaPlain).length > 0) {
|
|
473
|
+
// biome-ignore lint/suspicious/noExplicitAny: same as above
|
|
474
|
+
controls.push(animate(el, waaPlain as any, animOpts))
|
|
475
|
+
}
|
|
476
|
+
prevControls = aggregateControls(controls)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Drain `onceExitComplete()` waiters when this dispatch is driven by
|
|
480
|
+
// the exit state — i.e., Presence is awaiting the unmount animation.
|
|
481
|
+
// motion's AnimationPlaybackControls is thenable at runtime (motion
|
|
482
|
+
// returns a thenable handle) but the public type doesn't surface
|
|
483
|
+
// `.then` — narrow via PromiseLike. The promise settles on natural
|
|
484
|
+
// completion OR cancellation (`.stop()` from a subsequent effect
|
|
485
|
+
// run). Both should drain — from the caller's perspective the exit
|
|
486
|
+
// animation is "done" either way. The freshness check ensures a stale
|
|
487
|
+
// dispatch doesn't drain a newer animation's waiters.
|
|
488
|
+
if (driverState === "exit") {
|
|
489
|
+
const dispatched = prevControls
|
|
490
|
+
const thenable = dispatched as unknown as PromiseLike<unknown>
|
|
491
|
+
thenable.then(() => {
|
|
492
|
+
if (prevControls === dispatched) drainPendingExitResolvers()
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
} else if (driverState === "exit") {
|
|
496
|
+
// Exit is the driver but no animate ran (target absent or no key diff).
|
|
497
|
+
// Drain immediately so any awaiting `runExit` callers don't hang.
|
|
498
|
+
drainPendingExitResolvers()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------- MotionValue-in-target subscriptions ----------
|
|
502
|
+
// Walk `next` (the FULL winner set) rather than `changes` (the diff) and
|
|
503
|
+
// subscribe a per-key animate callback to each MV's `change` event. This
|
|
504
|
+
// loop runs on EVERY effect iteration that gets past the bailOnNoTarget
|
|
505
|
+
// guard above, including iterations that produced no diff.
|
|
506
|
+
//
|
|
507
|
+
// Bug fix: previously this loop lived inside the "has changes" branch.
|
|
508
|
+
// Sibling effects in createGestures (notably the inView wiring's
|
|
509
|
+
// setActive call after IntersectionObserver's first emission) can
|
|
510
|
+
// invalidate the winners memo without changing any actual value. On
|
|
511
|
+
// those re-runs, the iteration-scoped `onCleanup` from the prior run
|
|
512
|
+
// unsubscribed the MV listeners and we never reattached, dropping all
|
|
513
|
+
// future MV.set() → animate plumbing. Walking `next` here keeps the
|
|
514
|
+
// subscriptions in lockstep with the effect's lifetime.
|
|
515
|
+
for (const key in next) {
|
|
516
|
+
const entry = next[key]
|
|
517
|
+
if (!entry) continue
|
|
518
|
+
if (isMotionValue(entry.value)) {
|
|
519
|
+
const targetMV = entry.value as MotionValue<unknown>
|
|
520
|
+
onCleanup(
|
|
521
|
+
targetMV.on("change", (v) => {
|
|
522
|
+
// Stage 3: route through the registry the same way the main
|
|
523
|
+
// dispatch does, so a style MV the user also wrote into
|
|
524
|
+
// `animate` doesn't bypass the writer's transform composition.
|
|
525
|
+
const fallback =
|
|
526
|
+
initialTarget && key in (initialTarget as Record<string, unknown>)
|
|
527
|
+
? (initialTarget as Record<string, unknown>)[key]
|
|
528
|
+
: getMotionDefault(key)
|
|
529
|
+
const routedMV = getValueForAnimate?.(key, fallback)
|
|
530
|
+
if (routedMV && routedMV !== targetMV) {
|
|
531
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's animate overload soup; runtime correct
|
|
532
|
+
animate(routedMV as any, v as any, buildAnimateOptions() as any)
|
|
533
|
+
} else {
|
|
534
|
+
// biome-ignore lint/suspicious/noExplicitAny: same as above
|
|
535
|
+
animate(el, { [key]: v } as any, buildAnimateOptions())
|
|
536
|
+
}
|
|
537
|
+
}),
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
// Owner-disposal cleanup: stop any in-flight animation.
|
|
544
|
+
onCleanup(() => prevControls?.stop())
|
|
545
|
+
|
|
546
|
+
function setActive(state: GestureStateName, isActive: boolean): void {
|
|
547
|
+
setActiveStore(state, isActive)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Phase 3 — Presence integration. Returns a Promise that resolves when the
|
|
552
|
+
* NEXT exit-driven animate dispatched by the diff effect completes, OR
|
|
553
|
+
* immediately if no exit target is configured (nothing to wait for).
|
|
554
|
+
*
|
|
555
|
+
* The typical caller is `createMotion`'s presence-registered `runExit`:
|
|
556
|
+
* it flips `setActive("exit", true)` then awaits this. The diff effect
|
|
557
|
+
* runs in the next microtask, dispatches the exit animation, and on its
|
|
558
|
+
* completion drains the pending resolvers.
|
|
559
|
+
*
|
|
560
|
+
* Multiple concurrent waiters are supported — they all resolve from the
|
|
561
|
+
* same animation's completion.
|
|
562
|
+
*
|
|
563
|
+
* Edge case: if the user reactively removes `opts.exit` AFTER this call
|
|
564
|
+
* but before the effect runs, the resolver will still be drained the
|
|
565
|
+
* next time exit drives a dispatch (or by the "no-animate but exit-
|
|
566
|
+
* driven" branch in the effect).
|
|
567
|
+
*/
|
|
568
|
+
function onceExitComplete(): Promise<void> {
|
|
569
|
+
const exitTarget = untrack(() => stateTargets().exit)
|
|
570
|
+
if (exitTarget === null) return Promise.resolve()
|
|
571
|
+
return new Promise<void>((resolve) => {
|
|
572
|
+
pendingExitResolvers.push(resolve)
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return { setActive, onceExitComplete }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
// Helpers
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Q4 — a state is considered active if EITHER its own flag is set OR the
|
|
585
|
+
* parent's VariantContext carries a label for it (the parent's gesture is
|
|
586
|
+
* active and propagating). The parent slots are themselves active-gated in
|
|
587
|
+
* `useMotion`'s `myVariantCtx`, so a defined return value here means the
|
|
588
|
+
* parent's gesture really is firing right now.
|
|
589
|
+
*
|
|
590
|
+
* `animate` and `exit` are special — their inheritance happens through the
|
|
591
|
+
* normal label-resolution path in `resolveTarget`, not through the active
|
|
592
|
+
* flag. We treat `animate` as always-active (matches motion's
|
|
593
|
+
* createTypeState(true)). `exit` is driven by the Presence context; the
|
|
594
|
+
* flag-based check is fine.
|
|
595
|
+
*/
|
|
596
|
+
function isStateActive(
|
|
597
|
+
state: GestureStateName,
|
|
598
|
+
active: ActiveStore,
|
|
599
|
+
parent: VariantContextValue,
|
|
600
|
+
): boolean {
|
|
601
|
+
if (active[state]) return true
|
|
602
|
+
switch (state) {
|
|
603
|
+
case "whileHover":
|
|
604
|
+
return parent.hover?.() !== undefined
|
|
605
|
+
case "whilePress":
|
|
606
|
+
return parent.press?.() !== undefined
|
|
607
|
+
case "whileFocus":
|
|
608
|
+
return parent.focus?.() !== undefined
|
|
609
|
+
case "whileInView":
|
|
610
|
+
return parent.inView?.() !== undefined
|
|
611
|
+
// Drag inheritance through context isn't wired in Phase 1's
|
|
612
|
+
// VariantContextValue (no `drag` slot). Commit 6 will revisit if needed.
|
|
613
|
+
case "whileDrag":
|
|
614
|
+
return false
|
|
615
|
+
case "animate":
|
|
616
|
+
case "exit":
|
|
617
|
+
return false
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Convert a winners map into the flat value snapshot used by `lastApplied`. */
|
|
622
|
+
function snapshotValues(winners: Record<string, WinnerEntry>): Record<string, unknown> {
|
|
623
|
+
const out: Record<string, unknown> = {}
|
|
624
|
+
for (const key in winners) {
|
|
625
|
+
const entry = winners[key]
|
|
626
|
+
if (entry) out[key] = entry.value
|
|
627
|
+
}
|
|
628
|
+
return out
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Phase 1's splitTarget: separate MotionValue refs in a target from plain
|
|
633
|
+
* values. Motion-vanilla `animate(el, target)` doesn't subscribe to MV refs
|
|
634
|
+
* passed in target — we handle that bridge ourselves.
|
|
635
|
+
*/
|
|
636
|
+
function splitTarget(target: Record<string, unknown>): {
|
|
637
|
+
plain: Record<string, unknown>
|
|
638
|
+
motionValues: Array<{ key: string; mv: MotionValue<unknown> }>
|
|
639
|
+
} {
|
|
640
|
+
const plain: Record<string, unknown> = {}
|
|
641
|
+
const motionValues: Array<{ key: string; mv: MotionValue<unknown> }> = []
|
|
642
|
+
for (const key in target) {
|
|
643
|
+
const value = target[key]
|
|
644
|
+
if (value === undefined || value === null) {
|
|
645
|
+
plain[key] = value
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
if (isMotionValue(value)) {
|
|
649
|
+
motionValues.push({ key, mv: value as MotionValue<unknown> })
|
|
650
|
+
plain[key] = (value as MotionValue<unknown>).get()
|
|
651
|
+
} else if (typeof value === "function") {
|
|
652
|
+
plain[key] = (value as () => unknown)()
|
|
653
|
+
} else if (Array.isArray(value)) {
|
|
654
|
+
plain[key] = value.map((v) => {
|
|
655
|
+
if (isMotionValue(v)) return (v as MotionValue<unknown>).get()
|
|
656
|
+
if (typeof v === "function") return (v as () => unknown)()
|
|
657
|
+
return v
|
|
658
|
+
})
|
|
659
|
+
} else {
|
|
660
|
+
plain[key] = value
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return { plain, motionValues }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Return the highest-priority active state that contributed any key in the
|
|
668
|
+
* current winners map. Used to identify the "driver" for onAnimationComplete
|
|
669
|
+
* (which receives the AnimateValue that drove the animation).
|
|
670
|
+
*
|
|
671
|
+
* `animate` is the fallback when no gesture is contributing — matches Phase 1's
|
|
672
|
+
* effectiveAnimateValue semantic.
|
|
673
|
+
*/
|
|
674
|
+
function highestActiveDriverState(winners: Record<string, WinnerEntry>): GestureStateName {
|
|
675
|
+
// Walk PRIORITY_HIGH_TO_LOW and find the first state name that appears.
|
|
676
|
+
for (const stateName of PRIORITY_HIGH_TO_LOW) {
|
|
677
|
+
for (const key in winners) {
|
|
678
|
+
const entry = winners[key]
|
|
679
|
+
if (entry && entry.stateName === stateName) return stateName
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return "animate"
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Look up the AnimateValue (Target | string | string[]) that corresponds to a
|
|
687
|
+
* given state — for onAnimationComplete's argument.
|
|
688
|
+
*/
|
|
689
|
+
function animateValueForState(
|
|
690
|
+
state: GestureStateName,
|
|
691
|
+
opts: MotionOptions,
|
|
692
|
+
parentVariantCtx: VariantContextValue,
|
|
693
|
+
): AnimateValue | undefined {
|
|
694
|
+
switch (state) {
|
|
695
|
+
case "animate":
|
|
696
|
+
return opts.animate ?? parentVariantCtx.animate?.()
|
|
697
|
+
case "whileHover":
|
|
698
|
+
return opts.hover ?? parentVariantCtx.hover?.()
|
|
699
|
+
case "whilePress":
|
|
700
|
+
return opts.press ?? parentVariantCtx.press?.()
|
|
701
|
+
case "whileFocus":
|
|
702
|
+
return opts.focus ?? parentVariantCtx.focus?.()
|
|
703
|
+
case "whileInView":
|
|
704
|
+
return opts.inView ?? parentVariantCtx.inView?.()
|
|
705
|
+
case "exit":
|
|
706
|
+
return opts.exit ?? parentVariantCtx.exit?.()
|
|
707
|
+
case "whileDrag":
|
|
708
|
+
return opts.whileDrag
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Combine N AnimationPlaybackControls into a single Thenable+stoppable handle.
|
|
714
|
+
*
|
|
715
|
+
* Used by the Stage 3 bridge when an animate dispatch fans out across per-MV
|
|
716
|
+
* `animate(mv, value, opts)` calls (one per routed key) plus an optional
|
|
717
|
+
* single `animate(el, target, opts)` for keys still on the WAA path. The
|
|
718
|
+
* gesture state machine treats `prevControls` as one handle: subsequent diff
|
|
719
|
+
* runs call `.stop()` on it to cancel the in-flight animation, and the exit
|
|
720
|
+
* drain awaits `.then(...)` to settle Presence's `onceExitComplete()` waiters.
|
|
721
|
+
* Aggregating lets both code paths stay uniform whether bridging fired one
|
|
722
|
+
* underlying motion call or six.
|
|
723
|
+
*
|
|
724
|
+
* The other AnimationPlaybackControls methods (pause/play/cancel/complete)
|
|
725
|
+
* fan out unchanged. `time`/`speed`/`duration` aren't aggregated — they're
|
|
726
|
+
* read-rare in our codebase and a meaningful aggregate isn't well-defined
|
|
727
|
+
* across heterogeneous animations.
|
|
728
|
+
*/
|
|
729
|
+
function aggregateControls(
|
|
730
|
+
controls: readonly AnimationPlaybackControls[],
|
|
731
|
+
): AnimationPlaybackControls {
|
|
732
|
+
// Cache the settle promise so multiple `.then` consumers don't each spawn a
|
|
733
|
+
// fresh Promise.all over the same controls. motion's AnimationPlaybackControls
|
|
734
|
+
// is thenable at runtime (the public type omits `.then`, hence the casts).
|
|
735
|
+
let settled: Promise<unknown[]> | null = null
|
|
736
|
+
const settle = (): Promise<unknown[]> => {
|
|
737
|
+
if (!settled) {
|
|
738
|
+
settled = Promise.all(controls.map((c) => c as unknown as PromiseLike<unknown>))
|
|
739
|
+
}
|
|
740
|
+
return settled
|
|
741
|
+
}
|
|
742
|
+
const forAll = (fn: (c: AnimationPlaybackControls) => void): void => {
|
|
743
|
+
for (const c of controls) fn(c)
|
|
744
|
+
}
|
|
745
|
+
const handle: Record<string, unknown> = {
|
|
746
|
+
stop: () => {
|
|
747
|
+
forAll((c) => c.stop())
|
|
748
|
+
},
|
|
749
|
+
pause: () => {
|
|
750
|
+
forAll((c) => c.pause())
|
|
751
|
+
},
|
|
752
|
+
play: () => {
|
|
753
|
+
forAll((c) => c.play())
|
|
754
|
+
},
|
|
755
|
+
cancel: () => {
|
|
756
|
+
forAll((c) => c.cancel())
|
|
757
|
+
},
|
|
758
|
+
complete: () => {
|
|
759
|
+
forAll((c) => c.complete())
|
|
760
|
+
},
|
|
761
|
+
speed: 1,
|
|
762
|
+
time: 0,
|
|
763
|
+
duration: controls.reduce(
|
|
764
|
+
(acc, c) => Math.max(acc, (c as { duration?: number }).duration ?? 0),
|
|
765
|
+
0,
|
|
766
|
+
),
|
|
767
|
+
// biome-ignore lint/suspicious/noThenProperty: structurally mirroring motion's AnimationPlaybackControls, which is intentionally thenable.
|
|
768
|
+
then: (onFulfilled?: unknown, onRejected?: unknown) =>
|
|
769
|
+
settle().then(onFulfilled as never, onRejected as never),
|
|
770
|
+
}
|
|
771
|
+
return handle as unknown as AnimationPlaybackControls
|
|
772
|
+
}
|