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,638 @@
|
|
|
1
|
+
import { animate, type MotionValue } from "motion"
|
|
2
|
+
import { createSignal, onCleanup, untrack } from "solid-js"
|
|
3
|
+
import { useMotionConfig } from "../motion-config"
|
|
4
|
+
import { usePresenceContext } from "../presence-context"
|
|
5
|
+
import { createReducedMotion, shouldReduceMotion } from "../reduced-motion"
|
|
6
|
+
import {
|
|
7
|
+
formatProperty,
|
|
8
|
+
pickTransformFormatter,
|
|
9
|
+
snapshotValue,
|
|
10
|
+
TRANSFORM_KEYS,
|
|
11
|
+
targetToStyle,
|
|
12
|
+
} from "../style"
|
|
13
|
+
import type {
|
|
14
|
+
AnimateValue,
|
|
15
|
+
MotionElement,
|
|
16
|
+
MotionOptions,
|
|
17
|
+
Target,
|
|
18
|
+
Transition,
|
|
19
|
+
VariantContextValue,
|
|
20
|
+
VariantLabels,
|
|
21
|
+
Variants,
|
|
22
|
+
} from "../types"
|
|
23
|
+
import { effectiveLabels, resolveVariant, useVariantContext } from "../variants"
|
|
24
|
+
import { createDrag } from "./createDrag"
|
|
25
|
+
import { createGestures } from "./createGestures"
|
|
26
|
+
import { type ActiveStoreTuple, createGestureStateMachine } from "./gesture-state"
|
|
27
|
+
import { createValueRegistry, type ValueRegistry } from "./value-registry"
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detect whether an animate-value is a variant name (string or string[]) vs.
|
|
35
|
+
* an explicit target object. Returns the labels or undefined.
|
|
36
|
+
*/
|
|
37
|
+
export function asVariantLabels(value: AnimateValue | undefined): VariantLabels | undefined {
|
|
38
|
+
if (value === undefined) return undefined
|
|
39
|
+
if (typeof value === "string") return value
|
|
40
|
+
if (Array.isArray(value)) return value
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a per-state animate value into a {@link Target}. Implements the
|
|
46
|
+
* Q4 sub-2 priority table:
|
|
47
|
+
*
|
|
48
|
+
* - explicit Target object → use as-is (parent context ignored)
|
|
49
|
+
* - variant name → look up in own variants only (no cascade)
|
|
50
|
+
* - undefined → fall back to parent context's variant name, then look up in
|
|
51
|
+
* own variants
|
|
52
|
+
*/
|
|
53
|
+
export function resolveTarget(
|
|
54
|
+
ownValue: AnimateValue | undefined,
|
|
55
|
+
ownVariants: Variants | undefined,
|
|
56
|
+
parentLabel: VariantLabels | undefined,
|
|
57
|
+
custom: unknown,
|
|
58
|
+
): Target | null {
|
|
59
|
+
// Explicit Target object — variant lookup is skipped entirely.
|
|
60
|
+
if (ownValue !== undefined && typeof ownValue !== "string" && !Array.isArray(ownValue)) {
|
|
61
|
+
return ownValue as Target
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const labels = effectiveLabels(ownValue, parentLabel)
|
|
65
|
+
if (labels === undefined) return null
|
|
66
|
+
// After the explicit-object check above, `labels` is VariantLabels or a
|
|
67
|
+
// Target that came from `parentLabel` slot — but parent context only
|
|
68
|
+
// propagates labels, never Targets, so it's safe to treat as labels here.
|
|
69
|
+
return resolveVariant(labels as VariantLabels, ownVariants, custom)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Merge transition specs in priority order: MotionConfig default <
|
|
74
|
+
* user's `transition` < per-target `transition`. When reduced motion is
|
|
75
|
+
* active, returns `{ duration: 0 }` and drops everything else (Q11 sub-4).
|
|
76
|
+
*/
|
|
77
|
+
export function mergeTransition(
|
|
78
|
+
configDefault: Transition | undefined,
|
|
79
|
+
ownTransition: Transition | undefined,
|
|
80
|
+
perTargetTransition: Transition | undefined,
|
|
81
|
+
reduced: boolean,
|
|
82
|
+
): Transition {
|
|
83
|
+
if (reduced) return { duration: 0 } as Transition
|
|
84
|
+
return {
|
|
85
|
+
...(configDefault ?? {}),
|
|
86
|
+
...(ownTransition ?? {}),
|
|
87
|
+
...(perTargetTransition ?? {}),
|
|
88
|
+
} as Transition
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Apply a static target to an element's inline style before paint. Used on
|
|
93
|
+
* mount when no SSR style was emitted. The ref callback fires before the
|
|
94
|
+
* browser yields, so this avoids a frame of flicker.
|
|
95
|
+
*/
|
|
96
|
+
function applyStaticStyle(el: MotionElement, target: Target): void {
|
|
97
|
+
const style = targetToStyle(target)
|
|
98
|
+
for (const key in style) {
|
|
99
|
+
const value = (style as Record<string, string | number | undefined>)[key]
|
|
100
|
+
if (value === undefined) continue
|
|
101
|
+
if (key.startsWith("--")) {
|
|
102
|
+
el.style.setProperty(key, String(value))
|
|
103
|
+
} else {
|
|
104
|
+
// ElementCSSInlineStyle.style is indexable for camelCase property names.
|
|
105
|
+
// Available on both HTMLElement and SVGElement (Phase 4 SVG support).
|
|
106
|
+
;(el.style as unknown as Record<string, string | number>)[key] = value
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// createMotion — the imperative primitive that drives one element's motion
|
|
113
|
+
// state. useMotion wraps this; <motion.*> and motion() will wrap it too.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export type CreateMotionConfig = {
|
|
117
|
+
/**
|
|
118
|
+
* When true, `createMotion` skips applying the initial style — the server
|
|
119
|
+
* already emitted it inline, and re-applying on hydration would shift the
|
|
120
|
+
* paint. Detected via the `data-motion-hydrated` marker in useMotion.
|
|
121
|
+
*/
|
|
122
|
+
initialAppliedBySSR?: boolean
|
|
123
|
+
/**
|
|
124
|
+
* Q4 — useMotion lifts the gesture-state active store one level up so its
|
|
125
|
+
* `myVariantCtx` can read the same flags it propagates to descendants.
|
|
126
|
+
* When omitted (standalone createMotion use), the state machine creates
|
|
127
|
+
* its own internal store.
|
|
128
|
+
*/
|
|
129
|
+
activeStore?: ActiveStoreTuple
|
|
130
|
+
/**
|
|
131
|
+
* Q4 follow-up — useMotion passes a shadowed parent context here so the
|
|
132
|
+
* controlling-variants check is applied uniformly. When omitted, createMotion
|
|
133
|
+
* falls back to `useVariantContext()` directly (the standalone path).
|
|
134
|
+
*/
|
|
135
|
+
parentContext?: VariantContextValue
|
|
136
|
+
/**
|
|
137
|
+
* MV-in-style Stage 2 — useMotion scrapes `MotionValue`-valued keys out of
|
|
138
|
+
* the user's `style` prop and passes them here. createMotion registers
|
|
139
|
+
* each as an external (user-owned) entry in the value registry and
|
|
140
|
+
* subscribes a writer that re-composes `el.style` from the registry
|
|
141
|
+
* snapshot on every change.
|
|
142
|
+
*/
|
|
143
|
+
styleMotionValues?: Map<string, MotionValue<unknown>>
|
|
144
|
+
/**
|
|
145
|
+
* MV-in-style Stage 4 — static transform-shortcut entries in the user's
|
|
146
|
+
* `style` (e.g., `style={{ x: 10, scale: mv }}`'s `x: 10`). useMotion scrapes
|
|
147
|
+
* these alongside MVs so createMotion can seed transient registry entries
|
|
148
|
+
* for them. Without this, the writer would drop the static keys on every
|
|
149
|
+
* recompose since they wouldn't appear in the registry.
|
|
150
|
+
*
|
|
151
|
+
* Map values are the resolved leaf (number or string) — no MVs, no
|
|
152
|
+
* keyframe arrays, no accessor functions. useMotion runs the
|
|
153
|
+
* `snapshotValue` reduction before passing them in.
|
|
154
|
+
*/
|
|
155
|
+
styleStaticTransforms?: Map<string, number | string>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The imperative primitive: bind an element to a reactive motion-options
|
|
160
|
+
* source. Caller is responsible for keeping the element alive (refs in a
|
|
161
|
+
* component, drag controls, etc.).
|
|
162
|
+
*
|
|
163
|
+
* Phase 1 scope: animate + initial + transition + lifecycle hooks +
|
|
164
|
+
* reduced-motion override + presence registration. Phase 2 layers gesture
|
|
165
|
+
* states (hover/press/focus/inView) and drag on top.
|
|
166
|
+
*/
|
|
167
|
+
export function createMotion(
|
|
168
|
+
el: MotionElement,
|
|
169
|
+
getOpts: () => MotionOptions,
|
|
170
|
+
config?: CreateMotionConfig,
|
|
171
|
+
): void {
|
|
172
|
+
// Q4 follow-up: useMotion passes a shadowed (controlling-aware) context;
|
|
173
|
+
// standalone callers fall back to the live VariantContext.
|
|
174
|
+
const parentVariantCtx: VariantContextValue = config?.parentContext ?? useVariantContext()
|
|
175
|
+
const presence = usePresenceContext()
|
|
176
|
+
const motionConfig = useMotionConfig()
|
|
177
|
+
const systemReducedMotion = createReducedMotion()
|
|
178
|
+
|
|
179
|
+
// ---------- Per-element value registry (lazy — Stage 4.5) ----------
|
|
180
|
+
// Most elements never need a registry: they have no MV in style, no static
|
|
181
|
+
// transform shortcut in style, and no Stage 3 transient is ever required
|
|
182
|
+
// because the WAA dispatch path stays active throughout. For those elements
|
|
183
|
+
// (the bulk of typical motion usage), allocating an empty registry was pure
|
|
184
|
+
// waste. We now create it on first use via `ensureRegistry()`.
|
|
185
|
+
//
|
|
186
|
+
// No `onCleanup(() => valueRegistry?.dispose())` — `dispose()` was just
|
|
187
|
+
// `transient.clear() + values.clear()`. When createMotion's closure dies,
|
|
188
|
+
// GC reclaims the Map+Set + their MV references; user-provided MVs survive
|
|
189
|
+
// because they have other references; transient MVs that no longer have
|
|
190
|
+
// any subscriber will GC naturally. Calling dispose() explicitly bought
|
|
191
|
+
// nothing.
|
|
192
|
+
let valueRegistry: ValueRegistry | undefined
|
|
193
|
+
const ensureRegistry = (): ValueRegistry => {
|
|
194
|
+
if (!valueRegistry) valueRegistry = createValueRegistry()
|
|
195
|
+
return valueRegistry
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Snapshot once at construction. Subsequent reactivity goes through
|
|
199
|
+
// the createEffect below, so we don't subscribe in the body of this fn.
|
|
200
|
+
const initialOpts = untrack(getOpts)
|
|
201
|
+
|
|
202
|
+
// ---------- Resolve the initial target (used for BOTH static-style write AND
|
|
203
|
+
// the state machine's removed-key fallback chain, Q7) ----------
|
|
204
|
+
// Priority chain (matches use-motion's computeInitialStyle):
|
|
205
|
+
// own.initial > parent.initial > own.animate > parent.animate
|
|
206
|
+
//
|
|
207
|
+
// We resolve this regardless of `initialAppliedBySSR` because the state
|
|
208
|
+
// machine needs it even when SSR already wrote the inline style — the user's
|
|
209
|
+
// explicit `initial` value is part of their intent and should anchor the
|
|
210
|
+
// removed-key fallback regardless of who painted it.
|
|
211
|
+
let capturedInitialTarget: Target | null = null
|
|
212
|
+
if (initialOpts.initial !== false) {
|
|
213
|
+
const inheritedInitial = parentVariantCtx.initial?.()
|
|
214
|
+
const inheritedAnimate = parentVariantCtx.animate?.()
|
|
215
|
+
const effective =
|
|
216
|
+
initialOpts.initial !== undefined
|
|
217
|
+
? initialOpts.initial
|
|
218
|
+
: inheritedInitial !== undefined
|
|
219
|
+
? inheritedInitial
|
|
220
|
+
: initialOpts.animate !== undefined
|
|
221
|
+
? initialOpts.animate
|
|
222
|
+
: inheritedAnimate
|
|
223
|
+
if (effective !== undefined) {
|
|
224
|
+
capturedInitialTarget = resolveTarget(
|
|
225
|
+
effective,
|
|
226
|
+
initialOpts.variants,
|
|
227
|
+
undefined, // priority chain already consumed parent's labels
|
|
228
|
+
initialOpts.custom ?? parentVariantCtx.custom?.(),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- Apply the initial style pre-paint, unless SSR already did it ----------
|
|
234
|
+
// An enclosing `<Presence initial={false}>` ALSO suppresses the static
|
|
235
|
+
// style application — the descendant should mount at the animate target,
|
|
236
|
+
// not the initial. Done via the same path as the state machine's
|
|
237
|
+
// `suppressFirstMount` flag below; consistent semantic across both.
|
|
238
|
+
const suppressFirstMount = untrack(() => presence.initial?.()) === false
|
|
239
|
+
if (!config?.initialAppliedBySSR && !suppressFirstMount && capturedInitialTarget) {
|
|
240
|
+
applyStaticStyle(el, capturedInitialTarget)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------- MV-in-style: register external MVs + the registry writer ----------
|
|
244
|
+
// Stage 2/3 of the MV-in-style work. useMotion has scraped `MotionValue` refs
|
|
245
|
+
// out of `style` (e.g., `<motion.div style={{ scale: mv }}>`) and handed
|
|
246
|
+
// them to us in `config.styleMotionValues`. We register each as an external
|
|
247
|
+
// entry in the registry and subscribe a single writer that rebuilds the
|
|
248
|
+
// inline style from a fresh registry snapshot via `applyStaticStyle`.
|
|
249
|
+
//
|
|
250
|
+
// The writer is also subscribed to transient MVs Stage 3's animate bridge
|
|
251
|
+
// creates on demand. Composition cost on each MV change is bounded by
|
|
252
|
+
// registry size — for the common `style: { scale: mv }` case it's a single
|
|
253
|
+
// transform-shortcut walk and one `el.style.transform =` write
|
|
254
|
+
// (bench 04 / bench 08 — ~600 ns per subscriber).
|
|
255
|
+
//
|
|
256
|
+
// Bridge activation rule (Stage 3): the state machine routes animate-target
|
|
257
|
+
// dispatches through the registry ONLY when at least one external MV has
|
|
258
|
+
// been registered. Without a style MV, bridging is inactive and the state
|
|
259
|
+
// machine falls back to the existing `animate(el, target, opts)` WAA path.
|
|
260
|
+
// This keeps the 293 baseline tests on their original code path — their
|
|
261
|
+
// `animateSpy.mock.calls[*][1]` assertions still see a target object.
|
|
262
|
+
// ---------- Specialized writer (Stage 4.5b) ----------
|
|
263
|
+
// `writeFromRegistry` is the subscription target: every MV in the registry
|
|
264
|
+
// is hooked to it via `mv.on("change", writeFromRegistry)`. Internally it
|
|
265
|
+
// dispatches to a `writer` closure that's COMPILED based on the registry's
|
|
266
|
+
// current shape:
|
|
267
|
+
//
|
|
268
|
+
// - 0 entries → noop (the registry is empty; nothing to paint)
|
|
269
|
+
// - 1 entry → SPECIALIZED closure that captures (key, mv) in scope.
|
|
270
|
+
// Per-call cost is just mv.get() → snapshotValue → format →
|
|
271
|
+
// DOM write. No iterator allocations, no TRANSFORM_KEYS
|
|
272
|
+
// lookup, no branching on key shape — the shape is baked
|
|
273
|
+
// into the closure choice.
|
|
274
|
+
// - 2+ ents → `multiKeyWriter`, which walks the registry, composes a
|
|
275
|
+
// Target, and runs the full applyStaticStyle path.
|
|
276
|
+
//
|
|
277
|
+
// The closure is recompiled by `refreshWriter()` whenever the registry's
|
|
278
|
+
// size changes (called from every registration site: Stage 2 styleMV
|
|
279
|
+
// loop, Stage 4 initial-target walk, Stage 4 static-transforms walk, and
|
|
280
|
+
// Stage 3 `getValueForAnimate`'s transient-creation branch).
|
|
281
|
+
//
|
|
282
|
+
// Why this matters at scale: Sierpinski at depth 8 has 6,561 dots × one
|
|
283
|
+
// style MV each = 6,561 calls per scale.set, × 60 Hz = 393k calls/sec.
|
|
284
|
+
// The previous fast path allocated 3 IteratorResults per call → 1.2M
|
|
285
|
+
// allocations/sec of GC pressure. The specialized closure has zero per-
|
|
286
|
+
// call allocations.
|
|
287
|
+
const noop = (): void => {}
|
|
288
|
+
let writer: () => void = noop
|
|
289
|
+
const writeFromRegistry = (): void => writer()
|
|
290
|
+
|
|
291
|
+
const multiKeyWriter = (): void => {
|
|
292
|
+
if (!valueRegistry) return
|
|
293
|
+
const target: Record<string, unknown> = {}
|
|
294
|
+
for (const [k, mv] of valueRegistry.entries()) {
|
|
295
|
+
target[k] = mv.get()
|
|
296
|
+
}
|
|
297
|
+
if (Object.keys(target).length === 0) return
|
|
298
|
+
applyStaticStyle(el, target as Target)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const compileSingleKeyWriter = (): (() => void) => {
|
|
302
|
+
// Safe: caller guarantees size === 1.
|
|
303
|
+
const [key, mv] = (valueRegistry as ValueRegistry).entries().next().value as [
|
|
304
|
+
string,
|
|
305
|
+
MotionValue<unknown>,
|
|
306
|
+
]
|
|
307
|
+
if (TRANSFORM_KEYS.has(key)) {
|
|
308
|
+
// Pre-pick the formatter so the per-call hot path skips the
|
|
309
|
+
// transform-key switch entirely. Captured in the closure scope; the
|
|
310
|
+
// switch happens ONCE per element at compile time, never per write.
|
|
311
|
+
const formatter = pickTransformFormatter(key)
|
|
312
|
+
if (formatter !== undefined) {
|
|
313
|
+
return () => {
|
|
314
|
+
const v = snapshotValue(mv.get())
|
|
315
|
+
if (v !== undefined) el.style.transform = formatter(v)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (key.startsWith("--")) {
|
|
320
|
+
return () => {
|
|
321
|
+
const v = snapshotValue(mv.get())
|
|
322
|
+
if (v !== undefined) el.style.setProperty(key, String(v))
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return () => {
|
|
326
|
+
const v = snapshotValue(mv.get())
|
|
327
|
+
if (v === undefined) return
|
|
328
|
+
const formatted = formatProperty(key, v)
|
|
329
|
+
;(el.style as unknown as Record<string, string | number>)[key] = formatted
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const refreshWriter = (): void => {
|
|
334
|
+
const size = valueRegistry?.size ?? 0
|
|
335
|
+
if (size === 0) {
|
|
336
|
+
writer = noop
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
if (size === 1) {
|
|
340
|
+
writer = compileSingleKeyWriter()
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
writer = multiKeyWriter
|
|
344
|
+
}
|
|
345
|
+
// Bridge activates whenever the user supplied ANY registry-owned style
|
|
346
|
+
// entry — an MV in style OR a static transform shortcut. The transform
|
|
347
|
+
// string then becomes the registry-writer's exclusive responsibility,
|
|
348
|
+
// composing from the union of (initial transforms, style MVs, static
|
|
349
|
+
// style transforms, animate-target transients). With NO registry-owned
|
|
350
|
+
// entries, transforms stay on the pre-existing WAA dispatch path.
|
|
351
|
+
let bridgeActive = false
|
|
352
|
+
if (config?.styleMotionValues && config.styleMotionValues.size > 0) {
|
|
353
|
+
const registry = ensureRegistry()
|
|
354
|
+
for (const [key, mv] of config.styleMotionValues) {
|
|
355
|
+
registry.setExternal(key, mv)
|
|
356
|
+
onCleanup(mv.on("change", writeFromRegistry))
|
|
357
|
+
}
|
|
358
|
+
bridgeActive = true
|
|
359
|
+
}
|
|
360
|
+
if (config?.styleStaticTransforms && config.styleStaticTransforms.size > 0) {
|
|
361
|
+
bridgeActive = true
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------- Stage 4: register initial + static-style transforms as transients ----------
|
|
365
|
+
// When bridging is active, ALL transform-shortcut keys need to flow through
|
|
366
|
+
// the registry so the writer composes the full transform string. Animate
|
|
367
|
+
// targets get routed via Stage 3's bridge (transients created on demand);
|
|
368
|
+
// style MVs get registered as external in the Stage 2 block above. This block
|
|
369
|
+
// closes the remaining two gaps:
|
|
370
|
+
//
|
|
371
|
+
// (a) Initial transform values (from own.initial / parent.initial /
|
|
372
|
+
// own.animate / parent.animate priority chain). Without these,
|
|
373
|
+
// initial.y=20 + style.scale=mv would compose only `scale(<v>)` —
|
|
374
|
+
// initial.y would be lost when the writer fires.
|
|
375
|
+
//
|
|
376
|
+
// (b) Static transform shortcuts in the user's `style` prop (e.g.,
|
|
377
|
+
// `style={{ x: 10, scale: mv }}`'s `x: 10`). These don't enter via
|
|
378
|
+
// Stage 2's MV scrape; useMotion forwards them in
|
|
379
|
+
// `styleStaticTransforms`. Same fate as (a) if not registered:
|
|
380
|
+
// composeFirstPaintStyle gets them onto the SSR HTML, but the writer
|
|
381
|
+
// wouldn't know about them on subsequent recomposes.
|
|
382
|
+
//
|
|
383
|
+
// Both (a) and (b) seed transients in the registry; the bridge function above
|
|
384
|
+
// returns them on subsequent animate dispatches, and the writer composes
|
|
385
|
+
// them with style MVs into one transform string. Non-transform initial
|
|
386
|
+
// values (opacity, etc.) stay on applyStaticStyle's one-shot path — the
|
|
387
|
+
// writer doesn't touch keys it doesn't own.
|
|
388
|
+
//
|
|
389
|
+
// We snapshot the raw value with `snapshotValue` because initial targets can
|
|
390
|
+
// carry keyframe arrays, MotionValues, or accessor functions; the transient
|
|
391
|
+
// needs a concrete leaf to start from. styleStaticTransforms is already
|
|
392
|
+
// pre-snapshotted by useMotion.
|
|
393
|
+
if (bridgeActive && capturedInitialTarget) {
|
|
394
|
+
const registry = ensureRegistry()
|
|
395
|
+
for (const key in capturedInitialTarget) {
|
|
396
|
+
if (key === "transition") continue
|
|
397
|
+
if (!TRANSFORM_KEYS.has(key)) continue
|
|
398
|
+
if (registry.has(key)) continue
|
|
399
|
+
const raw = (capturedInitialTarget as Record<string, unknown>)[key]
|
|
400
|
+
const snapshot = snapshotValue(raw)
|
|
401
|
+
if (snapshot === undefined) continue
|
|
402
|
+
const mv = registry.getOrCreateTransient(key, snapshot)
|
|
403
|
+
onCleanup(mv.on("change", writeFromRegistry))
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (bridgeActive && config?.styleStaticTransforms) {
|
|
407
|
+
const registry = ensureRegistry()
|
|
408
|
+
for (const [key, value] of config.styleStaticTransforms) {
|
|
409
|
+
// Static style entries WIN over initial on key collision — style is
|
|
410
|
+
// the runtime source of truth for any key it specifies. Replace any
|
|
411
|
+
// transient just seeded from initialTarget with this value's transient.
|
|
412
|
+
const existing = registry.get(key)
|
|
413
|
+
if (existing) {
|
|
414
|
+
existing.set(value)
|
|
415
|
+
} else {
|
|
416
|
+
const mv = registry.getOrCreateTransient(key, value)
|
|
417
|
+
onCleanup(mv.on("change", writeFromRegistry))
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Initial paint from the registry — composes initial transforms + style MVs
|
|
423
|
+
// + static-style transforms into a single transform string. Skipped when
|
|
424
|
+
// nothing is registered.
|
|
425
|
+
if (bridgeActive) {
|
|
426
|
+
// All initial registrations complete; compile the specialized writer
|
|
427
|
+
// based on the final registry size before firing the first paint.
|
|
428
|
+
// Subsequent transient additions (via getValueForAnimate) call
|
|
429
|
+
// refreshWriter themselves.
|
|
430
|
+
refreshWriter()
|
|
431
|
+
writeFromRegistry()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------- Stage 3 bridge function — animate target → registered MV ----------
|
|
435
|
+
// Returns the MV the state machine should animate for `key`:
|
|
436
|
+
// • external (user-provided) MV in registry → return it; animate's tween
|
|
437
|
+
// drives this MV directly, and our writer composes the transform.
|
|
438
|
+
// • registry doesn't have an MV but key is a transform shortcut → create
|
|
439
|
+
// a transient MV initialized to `fallback`, subscribe the writer, return
|
|
440
|
+
// the new MV.
|
|
441
|
+
// • non-transform key with no external MV → return undefined; state machine
|
|
442
|
+
// falls back to WAA.
|
|
443
|
+
// Inactive when no external MV exists — preserves the existing dispatch
|
|
444
|
+
// shape end-to-end for non-MV-in-style users.
|
|
445
|
+
const getValueForAnimate = (key: string, fallback: unknown): MotionValue<unknown> | undefined => {
|
|
446
|
+
if (!bridgeActive) return undefined
|
|
447
|
+
// `bridgeActive=true` implies the registry was ensured by one of the
|
|
448
|
+
// registration blocks above, but TypeScript can't see that correlation.
|
|
449
|
+
// Re-resolve through `ensureRegistry()` — idempotent and free if already
|
|
450
|
+
// created.
|
|
451
|
+
const registry = ensureRegistry()
|
|
452
|
+
const existing = registry.get(key)
|
|
453
|
+
if (existing) return existing
|
|
454
|
+
if (!TRANSFORM_KEYS.has(key)) return undefined
|
|
455
|
+
const mv = registry.getOrCreateTransient(key, fallback)
|
|
456
|
+
onCleanup(mv.on("change", writeFromRegistry))
|
|
457
|
+
// Registry size just grew — recompile the writer so the next paint uses
|
|
458
|
+
// the multi-key path that composes all transforms together. (Transitions
|
|
459
|
+
// 1→2 swap from specialized single-key to multiKeyWriter; 2+→N+1 stays
|
|
460
|
+
// on multiKeyWriter, refreshWriter is then idempotent.)
|
|
461
|
+
refreshWriter()
|
|
462
|
+
return mv
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------- Presence-aware enter-readiness gate ----------
|
|
466
|
+
// We detect "inside a real <Presence>" by the absence of `registerEnter` on
|
|
467
|
+
// the no-op default context. When we ARE inside one, the element may be
|
|
468
|
+
// off-DOM at the moment the state machine first iterates (the new child
|
|
469
|
+
// during a mode="wait" swap is created before the old child's exit
|
|
470
|
+
// settles, and even the initial child is briefly created off-DOM during
|
|
471
|
+
// appear). Dispatching motion's `animate()` then would run the animation
|
|
472
|
+
// on a disconnected element and silently fail commitStyles — the element
|
|
473
|
+
// would paint at its `initial` target when it finally enters the DOM.
|
|
474
|
+
//
|
|
475
|
+
// Solution: start `enterReady = false` while in a Presence, register a
|
|
476
|
+
// `runEnter` callable that flips it true, and let Presence call it from
|
|
477
|
+
// its `onEnter` / `onChange.added` hook (when transition-group has
|
|
478
|
+
// synchronously inserted the element via `setReturned`). Outside a
|
|
479
|
+
// Presence we leave `enterReady` undefined; the state machine treats
|
|
480
|
+
// absence as ready=true and the existing eager-first-iteration behavior
|
|
481
|
+
// is unchanged.
|
|
482
|
+
const inPresence = presence.registerEnter !== undefined
|
|
483
|
+
const [enterReady, setEnterReady] = createSignal(!inPresence)
|
|
484
|
+
if (inPresence && presence.registerEnter) {
|
|
485
|
+
presence.registerEnter(el, () => setEnterReady(true))
|
|
486
|
+
// Fallback: transition-group's onEnter / onChange.added only fires for
|
|
487
|
+
// elements that are NEW to the source list. The initial children of a
|
|
488
|
+
// `<Presence initial={false}>` (appear=false case) are already in the
|
|
489
|
+
// signal at construction and never trigger an enter callback. We flip
|
|
490
|
+
// readiness from a microtask if the element is connected by then —
|
|
491
|
+
// Solid's synchronous render of `returned()` has run, and any element
|
|
492
|
+
// that was meant to be on screen is in the DOM. For wait-mode swaps
|
|
493
|
+
// where the new child is still off-DOM (the old one's exit is in
|
|
494
|
+
// flight), the `isConnected` check fails and we leave readiness false;
|
|
495
|
+
// Presence will fire beforeMount through onEnter when the exit settles.
|
|
496
|
+
queueMicrotask(() => {
|
|
497
|
+
if (el.isConnected) setEnterReady(true)
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------- Gesture state machine (Q3b, ADR 0002) ----------
|
|
502
|
+
// Constructed BEFORE presence registration so the registered `runExit`
|
|
503
|
+
// callable can close over `setActive` + `onceExitComplete`. Owns target
|
|
504
|
+
// resolution, priority winners, and the diff-and-animate loop. Returns
|
|
505
|
+
// `setActive` which gesture wiring uses to toggle active flags, and
|
|
506
|
+
// `onceExitComplete` which Presence awaits during unmount.
|
|
507
|
+
// (For drag's typing constraint, see the createDrag call below.)
|
|
508
|
+
const stateMachine = createGestureStateMachine({
|
|
509
|
+
el,
|
|
510
|
+
getOpts,
|
|
511
|
+
parentVariantCtx,
|
|
512
|
+
motionConfig,
|
|
513
|
+
systemReducedMotion,
|
|
514
|
+
initialTarget: capturedInitialTarget,
|
|
515
|
+
externalActiveStore: config?.activeStore,
|
|
516
|
+
suppressFirstMount,
|
|
517
|
+
enterReady,
|
|
518
|
+
getValueForAnimate,
|
|
519
|
+
})
|
|
520
|
+
const { setActive, onceExitComplete } = stateMachine
|
|
521
|
+
|
|
522
|
+
// ---------- Presence registration (Phase 3 — inverted shape) ----------
|
|
523
|
+
// Child registers a `runExit` callable that dispatches the exit animate
|
|
524
|
+
// DIRECTLY (bypassing the state-machine effect). Direct dispatch is
|
|
525
|
+
// necessary because by the time Presence's `onExit` callback fires,
|
|
526
|
+
// Solid has already disposed the surrounding owner — the state machine's
|
|
527
|
+
// diff effect is gone. `runExit` therefore captures the exit-relevant
|
|
528
|
+
// options at construction time and uses motion's `animate()` itself.
|
|
529
|
+
//
|
|
530
|
+
// We do NOT call `presence.unregister(el)` on owner cleanup — that would
|
|
531
|
+
// race ahead of Presence's onExit. Instead, Presence/hook unregisters
|
|
532
|
+
// after the exit settles. See ADR 0003 for the timing rationale.
|
|
533
|
+
// Register a runExit for this element if EITHER:
|
|
534
|
+
// (a) it has its own `exit` prop, OR
|
|
535
|
+
// (b) an ancestor's exit label cascades down via VariantContext AND this
|
|
536
|
+
// element has a `variants` map that could resolve against it.
|
|
537
|
+
//
|
|
538
|
+
// (b) is the motion-react canonical orchestration pattern: a parent shell
|
|
539
|
+
// declares `exit: "closed"` (a label), wraps children in `m.Provider`, and
|
|
540
|
+
// children are passive consumers — they have ONLY a `variants` map keyed
|
|
541
|
+
// by `"closed"` (and other labels). Without (b), the children would never
|
|
542
|
+
// register a runExit and Presence's subtree-walk wouldn't find them; the
|
|
543
|
+
// cascade would work on enter but vanish on exit.
|
|
544
|
+
//
|
|
545
|
+
// The check uses parentVariantCtx.exit?.() which (per use-motion.tsx's
|
|
546
|
+
// `myVariantCtx.exit`) returns the parent's exit prop unconditionally —
|
|
547
|
+
// not gated on the parent's active.exit flag — so this snapshot at
|
|
548
|
+
// construction sees the static cascaded label, not a transient runtime
|
|
549
|
+
// value.
|
|
550
|
+
const inheritedExitLabel = untrack(() => parentVariantCtx.exit?.())
|
|
551
|
+
const hasOwnExit = initialOpts.exit !== undefined
|
|
552
|
+
const hasCascadedExit = inheritedExitLabel !== undefined && initialOpts.variants !== undefined
|
|
553
|
+
|
|
554
|
+
if (hasOwnExit || hasCascadedExit) {
|
|
555
|
+
const runExit = async (): Promise<void> => {
|
|
556
|
+
// Re-read opts at exit time. The previous design snapshotted them at
|
|
557
|
+
// construction, which broke any pattern where `exit` is reactive — a
|
|
558
|
+
// swipe-card whose exit direction depends on which way the user just
|
|
559
|
+
// flicked, for example, would always exit using the PREVIOUS card's
|
|
560
|
+
// direction (the value that was live when THIS card mounted). We
|
|
561
|
+
// untrack the read because we don't want to subscribe anything that
|
|
562
|
+
// would still be alive after the surrounding owner has been disposed
|
|
563
|
+
// by Solid's `<Show>` / `<For>` swap. The props proxy itself survives
|
|
564
|
+
// disposal — it's just a JS object that the runExit closure keeps
|
|
565
|
+
// referenced — so reading `props.X` here returns the latest value
|
|
566
|
+
// the parent passed in.
|
|
567
|
+
const opts = untrack(getOpts)
|
|
568
|
+
// `resolveTarget` walks own.exit, then the inherited cascade. When
|
|
569
|
+
// neither produces a target there's nothing to animate — return
|
|
570
|
+
// without setActive so we don't hang on the (potentially dead)
|
|
571
|
+
// state machine's onceExitComplete.
|
|
572
|
+
const exitTarget = resolveTarget(
|
|
573
|
+
opts.exit,
|
|
574
|
+
opts.variants,
|
|
575
|
+
asVariantLabels(untrack(() => parentVariantCtx.exit?.())),
|
|
576
|
+
opts.custom ?? parentVariantCtx.custom?.(),
|
|
577
|
+
)
|
|
578
|
+
if (!exitTarget) {
|
|
579
|
+
// Resolved to null (e.g., the user passed a label that doesn't
|
|
580
|
+
// exist in variants). Cooperate with the state machine for cases
|
|
581
|
+
// where the user expects an "exit" gesture without specific keys.
|
|
582
|
+
setActive("exit", true)
|
|
583
|
+
await onceExitComplete()
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Merge transition: MotionConfig default < user.transition <
|
|
588
|
+
// exit-target.transition < reduced-motion override.
|
|
589
|
+
const reduced = shouldReduceMotion(motionConfig.reducedMotion(), systemReducedMotion())
|
|
590
|
+
const transition = mergeTransition(
|
|
591
|
+
motionConfig.transition(),
|
|
592
|
+
opts.transition,
|
|
593
|
+
exitTarget.transition,
|
|
594
|
+
reduced,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
// Strip `transition` from the target before passing to animate.
|
|
598
|
+
const animTarget: Record<string, unknown> = {}
|
|
599
|
+
for (const k in exitTarget) {
|
|
600
|
+
if (k !== "transition") {
|
|
601
|
+
animTarget[k] = (exitTarget as Record<string, unknown>)[k]
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape we can't tighten generically; the runtime call is correct.
|
|
606
|
+
const controls = animate(el, animTarget as any, transition as any)
|
|
607
|
+
// AnimationPlaybackControls is thenable at runtime (motion 12.x).
|
|
608
|
+
// The public type doesn't expose `.then` — narrow via PromiseLike.
|
|
609
|
+
await (controls as unknown as PromiseLike<unknown>)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
presence.register(el, runExit)
|
|
613
|
+
// No `onCleanup(() => presence.unregister(el))` — that would fire
|
|
614
|
+
// synchronously when Solid disposes the child's owner, BEFORE
|
|
615
|
+
// transition-group's `onExit` callback runs. Presence/hook calls
|
|
616
|
+
// `unregister` itself after the exit settles. For the no-op default
|
|
617
|
+
// context (no enclosing Presence), `register` is a silent drop —
|
|
618
|
+
// nothing to clean up.
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------- Pointer-event gestures (hover, press, focus, inView) ----------
|
|
622
|
+
// Listeners attach unconditionally on mount; the state machine no-ops when
|
|
623
|
+
// an active state has no target.
|
|
624
|
+
createGestures(el, getOpts, setActive)
|
|
625
|
+
|
|
626
|
+
// ---------- Drag + pan (Q5/C-lean + Q11/D3) ----------
|
|
627
|
+
// createDrag layers on createPan for the pointer session and writes to the
|
|
628
|
+
// element's VisualElement x/y MotionValues during drag. Drag is HTML-only
|
|
629
|
+
// for v0.1 — motion-dom's HTMLVisualElement is HTML-specific. Users who
|
|
630
|
+
// wire `drag` onto an SVG element get a no-op at construction; we could
|
|
631
|
+
// surface a dev warning here later if needed.
|
|
632
|
+
if (el instanceof HTMLElement) {
|
|
633
|
+
createDrag(el, getOpts, setActive)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Re-export for useMotion to consume the same helpers without circular deps.
|
|
638
|
+
export { applyStaticStyle }
|