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,412 @@
1
+ import { mergeRefs } from "@solid-primitives/refs"
2
+ import { isMotionValue, type MotionValue } from "motion"
3
+ import { type Accessor, type Component, type JSX, mergeProps, onMount, untrack } from "solid-js"
4
+ import { createStore } from "solid-js/store"
5
+ import { usePresenceContext } from "./presence-context"
6
+ import { asVariantLabels, createMotion, resolveTarget } from "./primitives/createMotion"
7
+ import type { GestureStateName } from "./primitives/gesture-state"
8
+ import { snapshotValue, TRANSFORM_KEYS, targetToStyle } from "./style"
9
+ import type {
10
+ ElementProps,
11
+ MotionElement,
12
+ MotionMergedProps,
13
+ MotionOptions,
14
+ MotionStyle,
15
+ Target,
16
+ Transition,
17
+ UseMotionResult,
18
+ VariantContextValue,
19
+ Variants,
20
+ } from "./types"
21
+ import { isControllingVariants, useVariantContext, VariantContext } from "./variants"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // useMotion — the canonical public API. Returns a getter that merges user
25
+ // props with motion's (style, ref, hydration marker) and a Provider for
26
+ // opt-in variant context propagation (Q4 sub-3 Option B).
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Wire motion to an element via a getter function.
31
+ *
32
+ * ```tsx
33
+ * const motion = useMotion({
34
+ * initial: { opacity: 0, y: 20 },
35
+ * animate: { opacity: 1, y: 0 },
36
+ * transition: { duration: 0.6 },
37
+ * })
38
+ *
39
+ * <div {...motion({ class: "card" })}>Hello</div>
40
+ * ```
41
+ *
42
+ * **Reactive form**: pass a function to track signals.
43
+ * ```tsx
44
+ * useMotion(() => ({ animate: { x: x() } }))
45
+ * ```
46
+ *
47
+ * **Variant context propagation**: `useMotion` only *consumes* the parent
48
+ * variant context. To propagate to descendants, wrap them in `motion.Provider`:
49
+ * ```tsx
50
+ * const m = useMotion({ animate: "visible", variants })
51
+ * <div {...m()}>
52
+ * <m.Provider>
53
+ * <ChildMotion />
54
+ * </m.Provider>
55
+ * </div>
56
+ * ```
57
+ *
58
+ * For the common "JSX wrapper does propagation automatically" pattern, use
59
+ * `<motion.div>` (Phase 4).
60
+ */
61
+ export function useMotion(opts: MotionOptions | (() => MotionOptions)): UseMotionResult {
62
+ const getOpts: () => MotionOptions = typeof opts === "function" ? opts : () => opts
63
+
64
+ // ---------- Parent context (with controlling-variants shadowing) ----------
65
+ // Mirrors motion-dom's isControllingVariants check: when THIS node has any
66
+ // variant *label* prop (initial/animate/hover/press/focus/inView/exit as a
67
+ // string), it opts OUT of inheriting from its parent. The wrapped context's
68
+ // slots return undefined while controlling, so the state machine and the
69
+ // initial-target resolver see no inherited values to fall back on.
70
+ //
71
+ // The wrap is reactive — if opts toggle in/out of controlling state, the
72
+ // slots automatically flip between actual-parent and undefined.
73
+ const actualParentCtx: VariantContextValue = useVariantContext()
74
+ const isControlling = (): boolean => isControllingVariants(getOpts())
75
+ const parentVariantCtx: VariantContextValue = {
76
+ variants: () => (isControlling() ? undefined : actualParentCtx.variants?.()),
77
+ initial: () => (isControlling() ? undefined : actualParentCtx.initial?.()),
78
+ animate: () => (isControlling() ? undefined : actualParentCtx.animate?.()),
79
+ hover: () => (isControlling() ? undefined : actualParentCtx.hover?.()),
80
+ press: () => (isControlling() ? undefined : actualParentCtx.press?.()),
81
+ focus: () => (isControlling() ? undefined : actualParentCtx.focus?.()),
82
+ inView: () => (isControlling() ? undefined : actualParentCtx.inView?.()),
83
+ exit: () => (isControlling() ? undefined : actualParentCtx.exit?.()),
84
+ custom: () => (isControlling() ? undefined : actualParentCtx.custom?.()),
85
+ transition: () => (isControlling() ? undefined : actualParentCtx.transition?.()),
86
+ }
87
+
88
+ // ---------- Compute the SSR-emittable initial target ----------
89
+ // untrack so reading getOpts() during render doesn't subscribe a Solid
90
+ // computation; the createMotion effect inside motionRef owns reactivity.
91
+ //
92
+ // We also peek at the surrounding `<Presence>` (if any). When `initial`
93
+ // is propagated as `false`, the descendant should mount painted at the
94
+ // animate target — not the initial — because we WANT the visual end state
95
+ // to match a normal post-animation appearance, just without the animation.
96
+ // computeInitialTarget reads `presence.initial` once at construction; the
97
+ // signal flips to true on a microtask, but by then the SSR style has
98
+ // been computed and merged into the JSX props.
99
+ //
100
+ // Stage 4 split: this returns the RAW resolved Target rather than the
101
+ // composed CSS. The style getter below composes initialTarget + style MV
102
+ // snapshots together so SSR HTML and client first paint both reflect the
103
+ // MVs the user supplied. Without this split, SSR HTML carries only the
104
+ // initial target and the MV value lands only after the client's ref fires
105
+ // — producing a brief paint discontinuity.
106
+ const presenceCtx = usePresenceContext()
107
+ const initialOpts = untrack(getOpts)
108
+ const initialTarget = computeInitialTarget(initialOpts, parentVariantCtx, presenceCtx.initial)
109
+
110
+ // ---------- Active gesture flags (Q4) ----------
111
+ // Lifted from inside the state machine so myVariantCtx below can gate its
112
+ // gesture label slots on these flags. createMotion (via the ref) threads
113
+ // this same store into the state machine so both sides share state.
114
+ const activeStore = createStore<Record<GestureStateName, boolean>>({
115
+ animate: true,
116
+ whileInView: false,
117
+ whileHover: false,
118
+ whilePress: false,
119
+ whileFocus: false,
120
+ whileDrag: false,
121
+ exit: false,
122
+ })
123
+ const [active] = activeStore
124
+
125
+ // ---------- MV-in-style scrape (Stage 2) ----------
126
+ // Walked once on the first m() call (see `getProps` below) and threaded
127
+ // into createMotion via `styleMotionValues`. The contract (locked in the
128
+ // grill): MV references in `style` are STATIC — captured once, not
129
+ // re-scanned on subsequent m() calls. Users who want a reactive MV swap
130
+ // can't do `style: { scale: cond() ? mvA : mvB }`; they animate the MV's
131
+ // value instead.
132
+ //
133
+ // Why capture in m() and not in motionRef: m()'s call is the only point
134
+ // where the user's `style` prop is observable from useMotion's body.
135
+ // motionRef fires later (after JSX evaluates), at which time we no
136
+ // longer have a handle on userProps.
137
+ // Stage 4.5: lazy-allocate both maps. Most elements have no MV-in-style
138
+ // and no static transform shortcut, so allocating these eagerly per
139
+ // `useMotion` call was pure waste. The `??=` in `captureStyleEntries`
140
+ // creates them only on first add; downstream consumers handle the
141
+ // `undefined` case via optional chaining.
142
+ let styleMotionValues: Map<string, MotionValue<unknown>> | undefined
143
+ let styleStaticTransforms: Map<string, number | string> | undefined
144
+ let styleCaptured = false
145
+
146
+ // ---------- Build the motion ref ----------
147
+ // Pass the shadowed parent context to createMotion so its state machine
148
+ // and initial-target resolver consume the same controlling-aware view.
149
+ //
150
+ // Stage 4: `initialAppliedBySSR` is now true when EITHER we emitted an
151
+ // initial target into the SSR style OR at least one style MV's snapshot
152
+ // landed in the SSR HTML via the style getter below. createMotion uses
153
+ // this flag to skip its own applyStaticStyle pass — without the
154
+ // styleMotionValues branch it would re-apply only the initialTarget half
155
+ // and clobber the MV-snapshot half that's already in the inline style.
156
+ const motionRef = (el: MotionElement) => {
157
+ createMotion(el, getOpts, {
158
+ initialAppliedBySSR:
159
+ initialTarget !== null ||
160
+ styleMotionValues !== undefined ||
161
+ styleStaticTransforms !== undefined,
162
+ activeStore,
163
+ parentContext: parentVariantCtx,
164
+ styleMotionValues,
165
+ styleStaticTransforms,
166
+ })
167
+ }
168
+
169
+ // ---------- The getter that merges user props with motion's ----------
170
+ // Built on Solid's `mergeProps` rather than an eager object spread. The
171
+ // returned value is a reactive proxy: reads against any property defer to
172
+ // its source, so reactive non-motion props the user spreads through `m()`
173
+ // (e.g. `<div {...m({ class: signal() ? "on" : "off" })}>`) keep their
174
+ // reactivity through to the rendered element. The previous spread-based
175
+ // implementation snapshotted userProps at call time, which broke this
176
+ // path for the Phase 4 motion proxy.
177
+ //
178
+ // `initialStyle` is included in the `style` getter ONLY before the
179
+ // first render completes. After onMount fires, motion's WAA owns the
180
+ // animated properties on the element — if we kept layering initialStyle
181
+ // into m()'s reactive output, Solid's style fn (which re-applies every
182
+ // tracked key via setProperty on each render — its first-loop deletes
183
+ // every prev entry, so the second loop's `v !== prev[s]` is always true)
184
+ // would re-write the static initial values back into the inline style on
185
+ // every reactive prop change, clobbering whatever WAA committed. Server-
186
+ // side, onMount never fires, so renderedOnce stays false and initialStyle
187
+ // always reaches the SSR HTML for first-paint correctness. Client first
188
+ // render runs BEFORE the onMount microtask, so initialStyle is also in
189
+ // the JSX for hydration consistency with the SSR HTML.
190
+ //
191
+ // `ref` is computed once and snapshotted — refs are conventionally
192
+ // callbacks set once per mount; re-running mergeRefs on each read is
193
+ // wasted work.
194
+ let renderedOnce = false
195
+ onMount(() => {
196
+ renderedOnce = true
197
+ })
198
+
199
+ /**
200
+ * Walk `style` once and pull `MotionValue` refs into `styleMotionValues`.
201
+ * Idempotent across re-renders — Stage 2's contract is "MV refs in style
202
+ * are captured on first call and never re-scraped." Subsequent m()
203
+ * invocations that pass a different style with new MVs won't pick them
204
+ * up; that pattern wasn't in scope for v0.1.
205
+ *
206
+ * The read is `untrack`ed because m() is typically called from inside a
207
+ * JSX spread, which Solid evaluates within a tracked owner. Without
208
+ * untrack we'd subscribe to whatever signals the user's `style` object
209
+ * references and re-fire this useMotion's owner-level effects on every
210
+ * change.
211
+ */
212
+ const captureStyleEntries = (style: unknown): void => {
213
+ if (styleCaptured) return
214
+ styleCaptured = true
215
+ if (!style || typeof style !== "object") return
216
+ for (const key in style) {
217
+ const value = (style as Record<string, unknown>)[key]
218
+ if (isMotionValue(value)) {
219
+ if (!styleMotionValues) styleMotionValues = new Map()
220
+ styleMotionValues.set(key, value as MotionValue<unknown>)
221
+ } else if (TRANSFORM_KEYS.has(key)) {
222
+ // Static transform shortcut. Stage 4 lands these in the registry as
223
+ // transients so the writer composes them with style MVs and initial
224
+ // transforms into one transform string. Reduce MV/accessor/array
225
+ // wrappers to a leaf — though by this branch we already know it's
226
+ // not an MV. The reduction also rejects boolean/object junk values.
227
+ const snap = snapshotValue(value)
228
+ if (snap !== undefined) {
229
+ if (!styleStaticTransforms) styleStaticTransforms = new Map()
230
+ styleStaticTransforms.set(key, snap)
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Produce a style object with MV-valued keys (and transform-shortcut keys —
238
+ * see below) removed. Solid's style binding would otherwise either write the
239
+ * MotionValue instance as a literal (coercing it via String() to
240
+ * "[object Object]") for MV-valued entries, or apply transform shortcuts
241
+ * directly as bogus CSS properties for static-shortcut entries. createMotion
242
+ * handles both via the registry-write path; we strip them here so the
243
+ * Solid-bound `cleaned` style only contains regular CSS keys.
244
+ */
245
+ const stripStyleEntriesOwnedByRegistry = (style: MotionStyle | undefined): JSX.CSSProperties => {
246
+ if (!style) return {}
247
+ const out: Record<string, unknown> = {}
248
+ for (const key in style) {
249
+ if (styleMotionValues?.has(key)) continue
250
+ if (TRANSFORM_KEYS.has(key)) continue
251
+ out[key] = (style as Record<string, unknown>)[key]
252
+ }
253
+ return out as JSX.CSSProperties
254
+ }
255
+
256
+ /**
257
+ * Stage 4 — compose the first-paint inline style from:
258
+ * 1. `initialTarget` (resolved via the priority chain at construction)
259
+ * 2. MotionValue snapshots from `style: { key: mv }`
260
+ * 3. Static transform shortcuts in `style: { x: 10, scale: 0.5 }`
261
+ *
262
+ * Style entries (2, 3) override `initialTarget` (1) on the same key because
263
+ * `style` is the runtime source-of-truth for those keys. Returns the composed
264
+ * `JSX.CSSProperties` or null when nothing applies (no initial + no style
265
+ * registry contributions).
266
+ *
267
+ * Called only before `onMount` flips `renderedOnce`. After mount, the
268
+ * registry's writer (in createMotion) owns el.style directly and this
269
+ * function isn't consulted.
270
+ */
271
+ const composeFirstPaintStyle = (userStyle: MotionStyle | undefined): JSX.CSSProperties | null => {
272
+ const merged: Record<string, unknown> = {}
273
+ let hasAny = false
274
+ if (initialTarget) {
275
+ Object.assign(merged, initialTarget)
276
+ hasAny = true
277
+ }
278
+ // MV snapshots from style override initialTarget for the same key.
279
+ if (styleMotionValues) {
280
+ for (const [key, mv] of styleMotionValues) {
281
+ merged[key] = mv.get()
282
+ hasAny = true
283
+ }
284
+ }
285
+ // Static transform shortcuts in style (NOT captured as MVs) override too.
286
+ if (userStyle) {
287
+ for (const key in userStyle) {
288
+ if (styleMotionValues?.has(key)) continue
289
+ if (!TRANSFORM_KEYS.has(key)) continue
290
+ const v = (userStyle as Record<string, unknown>)[key]
291
+ if (typeof v === "number" || typeof v === "string") {
292
+ merged[key] = v
293
+ hasAny = true
294
+ }
295
+ }
296
+ }
297
+ return hasAny ? targetToStyle(merged as Target) : null
298
+ }
299
+
300
+ function getProps<P extends ElementProps>(userProps?: P): MotionMergedProps<P> {
301
+ untrack(() => captureStyleEntries(userProps?.style))
302
+ // Decide marker presence at call time. Determining this from
303
+ // `getProps` (rather than recomputing per style-getter read) keeps it
304
+ // a stable attribute key on the mergeProps source object.
305
+ const wroteFirstPaintStyle =
306
+ initialTarget !== null ||
307
+ styleMotionValues !== undefined ||
308
+ styleStaticTransforms !== undefined
309
+ return mergeProps(userProps ?? {}, {
310
+ get style() {
311
+ const cleaned = stripStyleEntriesOwnedByRegistry(userProps?.style)
312
+ if (renderedOnce) return cleaned
313
+ const composed = composeFirstPaintStyle(userProps?.style)
314
+ return composed ? { ...cleaned, ...composed } : cleaned
315
+ },
316
+ ref: mergeRefs(userProps?.ref, motionRef),
317
+ ...(wroteFirstPaintStyle ? { "data-motion-hydrated": "" } : {}),
318
+ }) as MotionMergedProps<P>
319
+ }
320
+
321
+ // ---------- Provider for opt-in variant context propagation ----------
322
+ // Accessors recompute on each call so the provided context tracks the live
323
+ // options (variant name changes propagate to descendants).
324
+ //
325
+ // Q4 — gesture slots (hover, press, focus, inView) are ACTIVE-GATED:
326
+ // they return the label only when the corresponding gesture flag is true.
327
+ // When inactive, they return undefined — so descendants' priority chains
328
+ // correctly skip the inherited entry. The non-gesture slots (animate,
329
+ // initial, exit, custom, transition, variants) propagate unconditionally.
330
+ const myVariantCtx: VariantContextValue = {
331
+ variants: () => getOpts().variants,
332
+ // `initial: false` is a parent-only opt-out — don't propagate it. Only
333
+ // variant names (string / string[]) propagate to descendants.
334
+ initial: () => {
335
+ const v = getOpts().initial
336
+ return v === false ? undefined : asVariantLabels(v)
337
+ },
338
+ animate: () => asVariantLabels(getOpts().animate),
339
+ hover: () => (active.whileHover ? asVariantLabels(getOpts().hover) : undefined),
340
+ press: () => (active.whilePress ? asVariantLabels(getOpts().press) : undefined),
341
+ focus: () => (active.whileFocus ? asVariantLabels(getOpts().focus) : undefined),
342
+ inView: () => (active.whileInView ? asVariantLabels(getOpts().inView) : undefined),
343
+ exit: () => asVariantLabels(getOpts().exit),
344
+ custom: () => getOpts().custom,
345
+ transition: () => getOpts().transition,
346
+ }
347
+
348
+ const Provider: Component<{ children: JSX.Element }> = (props) => (
349
+ <VariantContext.Provider value={myVariantCtx}>{props.children}</VariantContext.Provider>
350
+ )
351
+
352
+ // Attach Provider to the callable function. Object.assign merges types
353
+ // cleanly for callable-with-properties — TS infers the intersection.
354
+ return Object.assign(getProps, { Provider })
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Helpers
359
+ // ---------------------------------------------------------------------------
360
+
361
+ function computeInitialTarget(
362
+ opts: MotionOptions,
363
+ parentVariantCtx: VariantContextValue,
364
+ presenceInitial?: Accessor<boolean>,
365
+ ): Target | null {
366
+ // `<Presence initial={false}>` propagates "skip the enter animation" down
367
+ // to every motion descendant. The intent is "render at the animate target"
368
+ // — NOT "render at the initial target with no animation" (the latter would
369
+ // leave the element looking like it failed to mount). So when the surrounding
370
+ // Presence says "suppress", we resolve the animate target as the initial
371
+ // instead of walking the initial chain. The state machine separately skips
372
+ // the first-mount animate dispatch via the same `suppressFirstMount` path.
373
+ if (presenceInitial?.() === false) {
374
+ const animateValue = opts.animate !== undefined ? opts.animate : parentVariantCtx.animate?.()
375
+ if (animateValue === undefined) return null
376
+ return resolveTarget(
377
+ animateValue,
378
+ opts.variants as Variants | undefined,
379
+ undefined,
380
+ opts.custom ?? parentVariantCtx.custom?.(),
381
+ )
382
+ }
383
+
384
+ if (opts.initial === false) return null
385
+
386
+ // Priority chain for the initial-state target:
387
+ // own.initial > parent.initial > own.animate > parent.animate
388
+ // Each level is consulted only if the previous is undefined. This matches
389
+ // motion/react's variant-context behavior — children without their own
390
+ // initial/animate props inherit from the ancestor motion element.
391
+ const inheritedInitial = parentVariantCtx.initial?.()
392
+ const inheritedAnimate = parentVariantCtx.animate?.()
393
+ const effective =
394
+ opts.initial !== undefined
395
+ ? opts.initial
396
+ : inheritedInitial !== undefined
397
+ ? inheritedInitial
398
+ : opts.animate !== undefined
399
+ ? opts.animate
400
+ : inheritedAnimate
401
+ if (effective === undefined) return null
402
+
403
+ return resolveTarget(
404
+ effective,
405
+ opts.variants as Variants | undefined,
406
+ undefined, // priority chain already consumed parent's labels
407
+ opts.custom ?? parentVariantCtx.custom?.(),
408
+ )
409
+ }
410
+
411
+ // Re-export Transition for downstream consumers that destructure from useMotion's module.
412
+ export type { Transition }
@@ -0,0 +1,134 @@
1
+ import { type Context, createContext, useContext } from "solid-js"
2
+ import type {
3
+ AnimateValue,
4
+ MotionOptions,
5
+ Target,
6
+ VariantContextValue,
7
+ VariantLabels,
8
+ Variants,
9
+ } from "./types"
10
+
11
+ /**
12
+ * Empty default — descendants without an enclosing motion wrapper get a
13
+ * cleanly-typed "no propagation" context.
14
+ */
15
+ const emptyVariantContext: VariantContextValue = {}
16
+
17
+ /**
18
+ * Solid context propagating variant state from a motion ancestor to its
19
+ * descendants. Only the wrapper components (`<motion.div>`, `motion(...)`)
20
+ * provide a value. Bare `useMotion` consumers can opt in via the `Provider`
21
+ * returned alongside the getter.
22
+ */
23
+ export const VariantContext: Context<VariantContextValue> =
24
+ createContext<VariantContextValue>(emptyVariantContext)
25
+
26
+ export function useVariantContext(): VariantContextValue {
27
+ return useContext(VariantContext)
28
+ }
29
+
30
+ /**
31
+ * Resolve a variant label (or array of labels) against a variants map and the
32
+ * current `custom` value. Returns a {@link Target} object, or `null` if nothing
33
+ * resolves.
34
+ *
35
+ * Resolution rules locked in Phase 1 Q4:
36
+ *
37
+ * - Child's own `variants` always wins for a given name (sub-1A).
38
+ * - No cascade: if a child has no `variants` of its own, parent's are NOT
39
+ * consulted (sub-1B / Pattern X). Callers pass `variants = undefined` in
40
+ * that case and this returns `null`.
41
+ * - String + array forms both supported; array variants merge in order
42
+ * (later wins on conflicting keys).
43
+ * - Function variants are invoked with `custom`; the value can be any type.
44
+ *
45
+ * @example
46
+ * const variants = { visible: { opacity: 1 }, hidden: { opacity: 0 } }
47
+ * resolveVariant("visible", variants, undefined)
48
+ * // { opacity: 1 }
49
+ *
50
+ * resolveVariant(["visible", "highlighted"], variants, undefined)
51
+ * // merged in order, last variant's keys override
52
+ *
53
+ * resolveVariant("visible", { visible: (i: number) => ({ x: i * 10 }) }, 3)
54
+ * // { x: 30 }
55
+ */
56
+ export function resolveVariant(
57
+ names: VariantLabels | undefined,
58
+ variants: Variants | undefined,
59
+ custom: unknown,
60
+ ): Target | null {
61
+ if (!names || !variants) return null
62
+
63
+ const list = Array.isArray(names) ? names : [names]
64
+ let merged: Target | null = null
65
+
66
+ for (const name of list) {
67
+ const variant = variants[name]
68
+ if (!variant) continue
69
+ const resolved: Target = typeof variant === "function" ? variant(custom) : variant
70
+ // Object.assign sidesteps TS's "spread types may only be created from object
71
+ // types" error caused by Target's index signature including `undefined`.
72
+ merged = merged ? Object.assign({}, merged, resolved) : Object.assign({}, resolved)
73
+ }
74
+
75
+ return merged
76
+ }
77
+
78
+ /**
79
+ * Determine the effective variant name for a given motion state. If the caller
80
+ * provided an explicit value (string, array, or Target object), that wins.
81
+ * Otherwise the parent context's value (per gesture/state) is used as a fall-
82
+ * back.
83
+ *
84
+ * Returns the explicit value as-is when it's a Target object (used by callers
85
+ * to detect "explicit target — skip variant lookup entirely").
86
+ */
87
+ export function effectiveLabels(
88
+ own: VariantLabels | Target | undefined,
89
+ parent: VariantLabels | undefined,
90
+ ): VariantLabels | Target | undefined {
91
+ if (own !== undefined) return own
92
+ return parent
93
+ }
94
+
95
+ /**
96
+ * Determine whether an `AnimateValue` is a variant *label* (string or array
97
+ * of strings) — as opposed to a `Target` object or `false` / `undefined`.
98
+ *
99
+ * Mirrors motion-dom's `isVariantLabel`. Used by `isControllingVariants`
100
+ * to decide whether a prop opts the node into the "controlling" role.
101
+ */
102
+ function isVariantLabelValue(v: AnimateValue | false | undefined): boolean {
103
+ if (typeof v === "string") return true
104
+ if (Array.isArray(v)) return true
105
+ return false
106
+ }
107
+
108
+ /**
109
+ * A motion node is "controlling variants" when any of its variant slots
110
+ * (`initial`, `animate`, `hover`, `press`, `focus`, `inView`, `exit`) carries
111
+ * a variant *label* (string or array of strings).
112
+ *
113
+ * Mirrors motion-dom's same-named check. A controlling node opts OUT of
114
+ * inheriting its parent's variant cascade — it provides its own. Descendants
115
+ * with no controlling props of their own DO inherit from the nearest
116
+ * controlling ancestor.
117
+ *
118
+ * Behavior is binary (any single controlling-slot label flips the node into
119
+ * controlling mode); not per-slot.
120
+ *
121
+ * Object-shaped values (`animate: \{ x: 100 \}`) do NOT make a node
122
+ * controlling — they're treated as targets, not as variant references.
123
+ */
124
+ export function isControllingVariants(opts: MotionOptions): boolean {
125
+ return (
126
+ isVariantLabelValue(opts.initial) ||
127
+ isVariantLabelValue(opts.animate) ||
128
+ isVariantLabelValue(opts.hover) ||
129
+ isVariantLabelValue(opts.press) ||
130
+ isVariantLabelValue(opts.focus) ||
131
+ isVariantLabelValue(opts.inView) ||
132
+ isVariantLabelValue(opts.exit)
133
+ )
134
+ }