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,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 }
|
package/src/variants.ts
ADDED
|
@@ -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
|
+
}
|