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.
Files changed (49) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2627 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/default-values.d.ts +6 -0
  8. package/dist/src/index.d.ts +16 -0
  9. package/dist/src/motion-config.d.ts +14 -0
  10. package/dist/src/motion-proxy.d.ts +103 -0
  11. package/dist/src/presence-context.d.ts +4 -0
  12. package/dist/src/presence.d.ts +95 -0
  13. package/dist/src/primitives/createDrag.d.ts +16 -0
  14. package/dist/src/primitives/createDragControls.d.ts +30 -0
  15. package/dist/src/primitives/createGestures.d.ts +8 -0
  16. package/dist/src/primitives/createInView.d.ts +51 -0
  17. package/dist/src/primitives/createMotion.d.ts +82 -0
  18. package/dist/src/primitives/createPan.d.ts +83 -0
  19. package/dist/src/primitives/createScroll.d.ts +40 -0
  20. package/dist/src/primitives/gesture-state.d.ts +108 -0
  21. package/dist/src/primitives/motion-value.d.ts +111 -0
  22. package/dist/src/primitives/value-registry.d.ts +34 -0
  23. package/dist/src/reduced-motion.d.ts +29 -0
  24. package/dist/src/style.d.ts +79 -0
  25. package/dist/src/types.d.ts +374 -0
  26. package/dist/src/use-motion.d.ts +35 -0
  27. package/dist/src/variants.d.ts +64 -0
  28. package/package.json +78 -0
  29. package/src/default-values.ts +52 -0
  30. package/src/index.ts +60 -0
  31. package/src/motion-config.tsx +37 -0
  32. package/src/motion-proxy.tsx +377 -0
  33. package/src/presence-context.ts +32 -0
  34. package/src/presence.tsx +466 -0
  35. package/src/primitives/createDrag.ts +670 -0
  36. package/src/primitives/createDragControls.ts +101 -0
  37. package/src/primitives/createGestures.ts +145 -0
  38. package/src/primitives/createInView.ts +124 -0
  39. package/src/primitives/createMotion.ts +638 -0
  40. package/src/primitives/createPan.ts +338 -0
  41. package/src/primitives/createScroll.ts +101 -0
  42. package/src/primitives/gesture-state.ts +772 -0
  43. package/src/primitives/motion-value.ts +328 -0
  44. package/src/primitives/value-registry.ts +114 -0
  45. package/src/reduced-motion.ts +51 -0
  46. package/src/style.ts +266 -0
  47. package/src/types.ts +538 -0
  48. package/src/use-motion.tsx +412 -0
  49. 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 }