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,37 @@
1
+ import { type Context, createContext, createMemo, type JSX, useContext } from "solid-js"
2
+ import type { MotionConfigContextValue, MotionConfigProps } from "./types"
3
+
4
+ /**
5
+ * Default context — no reduced motion override, no inherited transition, no
6
+ * CSP nonce. Descendants without a `<MotionConfig>` ancestor get this.
7
+ */
8
+ const defaultMotionConfig: MotionConfigContextValue = {
9
+ reducedMotion: () => "never",
10
+ transition: () => undefined,
11
+ nonce: () => undefined,
12
+ }
13
+
14
+ export const MotionConfigContext: Context<MotionConfigContextValue> =
15
+ createContext<MotionConfigContextValue>(defaultMotionConfig)
16
+
17
+ export function useMotionConfig(): MotionConfigContextValue {
18
+ return useContext(MotionConfigContext)
19
+ }
20
+
21
+ /**
22
+ * Provider that flows reduced-motion mode, default transition, and CSP nonce
23
+ * to every descendant motion element.
24
+ *
25
+ * @example
26
+ * <MotionConfig reducedMotion="user" transition={{ duration: 0.4 }}>
27
+ * <App />
28
+ * </MotionConfig>
29
+ */
30
+ export function MotionConfig(props: MotionConfigProps): JSX.Element {
31
+ const value: MotionConfigContextValue = {
32
+ reducedMotion: createMemo(() => props.reducedMotion ?? "never"),
33
+ transition: createMemo(() => props.transition),
34
+ nonce: createMemo(() => props.nonce),
35
+ }
36
+ return <MotionConfigContext.Provider value={value}>{props.children}</MotionConfigContext.Provider>
37
+ }
@@ -0,0 +1,377 @@
1
+ import { mergeRefs } from "@solid-primitives/refs"
2
+ import { type Component, type JSX, mergeProps, onMount, splitProps } from "solid-js"
3
+ import { Dynamic } from "solid-js/web"
4
+ import type { ElementProps, MotionElement, MotionOptions, MotionStyle } from "./types"
5
+ import { useMotion } from "./use-motion"
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // motion proxy — Phase 4.
9
+ //
10
+ // `motion` is a Proxy whose property accesses return cached, memoized
11
+ // tag-components: `motion.div`, `motion.svg`, `motion.path`, etc. Each
12
+ // tag-component:
13
+ //
14
+ // 1. Splits incoming props into motion options (fed to useMotion) and
15
+ // element attributes (forwarded to the underlying element reactively).
16
+ // 2. Wires useMotion in its function form so reactive options propagate
17
+ // through to the gesture state machine.
18
+ // 3. Wraps the rendered element in `m.Provider` UNCONDITIONALLY so a
19
+ // variant cascade reaches every motion descendant — the canonical
20
+ // motion-react ergonomic (B1 in ADR 0004).
21
+ // 4. Renders the actual element through <Dynamic>, which transparently
22
+ // handles SVG-vs-HTML namespace resolution.
23
+ //
24
+ // The HOC entry point (`motion.create`) lands in the follow-up commit. This
25
+ // commit ships only the indexable surface for tag-components.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * The exhaustive list of keys that `motion.X` (and `motion.create`,
30
+ * landing next) route to `useMotion` rather than the underlying element.
31
+ * Anything not in this array falls through to `rest` and is spread onto
32
+ * the DOM element via Solid's reactive spread — keeping things like
33
+ * `class`, `onClick`, dynamic style, etc. reactive end-to-end.
34
+ *
35
+ * The `satisfies` clause ensures every entry IS a valid `MotionOptions`
36
+ * key (typos fail to compile). The `_ensureExhaustive` constant below
37
+ * asserts the converse — every `MotionOptions` key appears in this
38
+ * array — so adding a new option to types.ts without registering here
39
+ * fails to compile with the missing key surfaced in the error.
40
+ */
41
+ /**
42
+ * Union of every `MotionOptions` key the proxy splits off from element
43
+ * attributes. Hardcoded as a literal union — NOT derived from
44
+ * `typeof MOTION_OPT_KEYS[number]` — so JSR's "slow types" rule can
45
+ * resolve the public type without inferring it from a const.
46
+ *
47
+ * Two compile-time exhaustiveness directions guarantee consistency
48
+ * between this type and the runtime `MOTION_OPT_KEYS` array:
49
+ *
50
+ * 1. `_MissingMotionOptKeys` (below) verifies the union covers every
51
+ * key in `keyof MotionOptions`.
52
+ * 2. The `satisfies` clause on `MOTION_OPT_KEYS` verifies every array
53
+ * entry IS a `MotionOptKey` (typo-proof at the runtime layer).
54
+ *
55
+ * If `MotionOptions` grows a new key, both `_MissingMotionOptKeys`
56
+ * AND `splitProps` at runtime break — the type check surfaces the
57
+ * specific missing key by name.
58
+ */
59
+ export type MotionOptKey =
60
+ // Variant slots
61
+ | "initial"
62
+ | "animate"
63
+ | "exit"
64
+ // Gesture targets
65
+ | "hover"
66
+ | "press"
67
+ | "focus"
68
+ | "inView"
69
+ | "inViewOptions"
70
+ // Drag config
71
+ | "drag"
72
+ | "dragConstraints"
73
+ | "dragElastic"
74
+ | "dragMomentum"
75
+ | "dragTransition"
76
+ | "dragSnapToOrigin"
77
+ | "dragControls"
78
+ | "whileDrag"
79
+ // Pan
80
+ | "panThreshold"
81
+ // Variants + transition
82
+ | "variants"
83
+ | "custom"
84
+ | "transition"
85
+ // Animation lifecycle
86
+ | "onAnimationStart"
87
+ | "onAnimationComplete"
88
+ | "onAnimationCancel"
89
+ | "onUpdate"
90
+ // Gesture lifecycle
91
+ | "onHoverStart"
92
+ | "onHoverEnd"
93
+ | "onPressStart"
94
+ | "onPress"
95
+ | "onPressCancel"
96
+ | "onFocus"
97
+ | "onBlur"
98
+ | "onPanStart"
99
+ | "onPan"
100
+ | "onPanEnd"
101
+ | "onViewportEnter"
102
+ | "onViewportLeave"
103
+ // Drag lifecycle
104
+ | "onDragStart"
105
+ | "onDrag"
106
+ | "onDragEnd"
107
+ | "onDragTransitionEnd"
108
+
109
+ /**
110
+ * Frozen list of `MotionOptKey`s — fed to `splitProps` at every
111
+ * tag-component render to separate motion options from element
112
+ * attributes. The `satisfies` clause checks every entry against
113
+ * `MotionOptKey` at compile time so typos / drift between the union
114
+ * and the array surface as errors.
115
+ */
116
+ export const MOTION_OPT_KEYS: readonly MotionOptKey[] = [
117
+ // Variant slots
118
+ "initial",
119
+ "animate",
120
+ "exit",
121
+ // Gesture targets
122
+ "hover",
123
+ "press",
124
+ "focus",
125
+ "inView",
126
+ "inViewOptions",
127
+ // Drag config
128
+ "drag",
129
+ "dragConstraints",
130
+ "dragElastic",
131
+ "dragMomentum",
132
+ "dragTransition",
133
+ "dragSnapToOrigin",
134
+ "dragControls",
135
+ "whileDrag",
136
+ // Pan
137
+ "panThreshold",
138
+ // Variants + transition
139
+ "variants",
140
+ "custom",
141
+ "transition",
142
+ // Animation lifecycle
143
+ "onAnimationStart",
144
+ "onAnimationComplete",
145
+ "onAnimationCancel",
146
+ "onUpdate",
147
+ // Gesture lifecycle
148
+ "onHoverStart",
149
+ "onHoverEnd",
150
+ "onPressStart",
151
+ "onPress",
152
+ "onPressCancel",
153
+ "onFocus",
154
+ "onBlur",
155
+ "onPanStart",
156
+ "onPan",
157
+ "onPanEnd",
158
+ "onViewportEnter",
159
+ "onViewportLeave",
160
+ // Drag lifecycle
161
+ "onDragStart",
162
+ "onDrag",
163
+ "onDragEnd",
164
+ "onDragTransitionEnd",
165
+ ] as const satisfies readonly MotionOptKey[]
166
+
167
+ // Compile-time exhaustiveness check (union → keys direction). If a new
168
+ // MotionOptions key is added without being registered in MotionOptKey,
169
+ // TypeScript surfaces the missing key by name here.
170
+ type _MissingMotionOptKeys = Exclude<keyof MotionOptions, MotionOptKey>
171
+ // Variable prefixed with `_` so biome's noUnusedVariables exempts it —
172
+ // the const exists purely so TypeScript evaluates its type and surfaces
173
+ // the missing key when the constraint fails.
174
+ const _ensureExhaustive: [_MissingMotionOptKeys] extends [never]
175
+ ? true
176
+ : { _missing: _MissingMotionOptKeys } = true
177
+
178
+ /**
179
+ * The shape of the `motion` proxy: every HTML/SVG intrinsic element name
180
+ * maps to a typed `Component` whose props are that element's native
181
+ * attribute set intersected with {@link MotionOptions}.
182
+ *
183
+ * The intersected `{ create: ... }` member adds the HOC entry point. The
184
+ * intersection's explicit `create` field wins over the mapped type's
185
+ * lookup (and there's no HTML/SVG tag named `create`), so `motion.create`
186
+ * is unambiguously typed as the HOC.
187
+ */
188
+ export type Motion = {
189
+ // Override each intrinsic element's `style` to accept `MotionStyle` (which
190
+ // adds transform shortcuts + MotionValue variants on top of standard CSS).
191
+ // Without this override, `<motion.div style={{ scale: mv }} />` wouldn't
192
+ // typecheck — the intrinsic `style: JSX.CSSProperties` doesn't know about
193
+ // motion's transform-shortcut keys or MV values.
194
+ [Tag in keyof JSX.IntrinsicElements]: Component<
195
+ Omit<JSX.IntrinsicElements[Tag], "style"> & { style?: MotionStyle } & MotionOptions
196
+ >
197
+ } & {
198
+ /**
199
+ * Wrap a custom Component with motion's behavior. The wrapped Component
200
+ * must forward props (specifically `ref` and `style`) to a single DOM
201
+ * element root — either by spreading `{...props}` on its root or by
202
+ * explicitly setting `ref={props.ref}` and `style={props.style}`. Solid
203
+ * doesn't have `forwardRef`; the contract is enforced by convention and
204
+ * a dev-mode runtime warning if motion's ref never reaches the DOM.
205
+ *
206
+ * @example
207
+ * ```tsx
208
+ * function MyCard(props) {
209
+ * return <div {...props}>{props.children}</div>
210
+ * }
211
+ * const Animated = motion.create(MyCard)
212
+ * <Animated animate={{ x: 100 }} hover={{ scale: 1.05 }} class="card" />
213
+ * ```
214
+ */
215
+ // biome-ignore lint/suspicious/noExplicitAny: Solid's Component<P> requires P extends Record<string, any>
216
+ create: <P extends Record<string, any>>(Component: Component<P>) => Component<P & MotionOptions>
217
+ }
218
+
219
+ // Module-level cache. `motion.div` returns the SAME component instance
220
+ // across reads, which (a) lets Solid's reconciler skip redundant work and
221
+ // (b) keeps component identity stable for HMR + dev tooling.
222
+ // Solid's `Component<P>` is constrained to `P extends Record<string, any>`,
223
+ // so we use `any` here. The Motion type narrows the per-tag prop shape at
224
+ // the call site (motion.div has DivProps & MotionOptions); this storage
225
+ // uniformity is purely internal.
226
+ // biome-ignore lint/suspicious/noExplicitAny: Component<P> requires P extends Record<string, any>
227
+ type AnyComponent = Component<any>
228
+
229
+ const tagComponentCache = new Map<string, AnyComponent>()
230
+
231
+ // WeakSet of every component the proxy has manufactured (tag-components AND
232
+ // HOC-wrapped components). Used for the dev-mode `motion.create(motion.X)`
233
+ // double-wrap warning. WeakSet so HMR-replaced components don't pin their
234
+ // predecessors alive.
235
+ const motionComponents = new WeakSet<object>()
236
+
237
+ function makeMotionTag(tag: string): AnyComponent {
238
+ const cached = tagComponentCache.get(tag)
239
+ if (cached) return cached
240
+
241
+ const Tag: Component<ElementProps & MotionOptions> = (props) => {
242
+ const [motionOpts, rest] = splitProps(props, MOTION_OPT_KEYS)
243
+ const m = useMotion(() => motionOpts)
244
+ return (
245
+ <m.Provider>
246
+ <Dynamic component={tag} {...m(rest as ElementProps)} />
247
+ </m.Provider>
248
+ )
249
+ }
250
+ const stored = Tag as AnyComponent
251
+ tagComponentCache.set(tag, stored)
252
+ motionComponents.add(stored)
253
+ return stored
254
+ }
255
+
256
+ /**
257
+ * `motion.create(Component)` — wraps a custom Component with motion's
258
+ * behavior. The wrapped Component must forward props to a single DOM
259
+ * element root; the contract is documented in the {@link Motion.create}
260
+ * JSDoc above and enforced at runtime (in dev mode) by detecting whether
261
+ * motion's ref ever reaches the DOM after mount.
262
+ */
263
+ // biome-ignore lint/suspicious/noExplicitAny: matches Motion.create's generic constraint
264
+ function motionCreate<P extends Record<string, any>>(
265
+ Component: Component<P>,
266
+ ): Component<P & MotionOptions> {
267
+ // Dev-mode `motion.create(motion.X)` warning. Double-wrapping puts two
268
+ // motion state machines on the SAME root element — both register with
269
+ // Presence, both dispatch animate() writes, and the resulting writes
270
+ // race. Users almost always meant to compose options at one layer.
271
+ if (
272
+ process.env.NODE_ENV !== "production" &&
273
+ motionComponents.has(Component as unknown as object)
274
+ ) {
275
+ console.warn(
276
+ "[solidjs-motion] motion.create(motion.X) double-wraps the same element " +
277
+ "with two motion state machines. Compose options on a single layer instead.",
278
+ )
279
+ }
280
+
281
+ const Wrapped: Component<P & MotionOptions> = (props) => {
282
+ const [motionOpts, rest] = splitProps(
283
+ props as unknown as Record<string, unknown>,
284
+ MOTION_OPT_KEYS,
285
+ )
286
+ const m = useMotion(() => motionOpts as MotionOptions)
287
+
288
+ // Dev-mode wrap-validity check (Q7 in the design grill / future ADR
289
+ // 0004): the wrapped Component must forward props.ref to a DOM
290
+ // element so motion's animations and exit registration can actually
291
+ // wire up. We can't enforce this at the type level in Solid (refs
292
+ // are conventionally optional, indistinguishable from "missing"), so
293
+ // we detect at runtime by riding a sentinel through the user-ref
294
+ // slot. `m()`'s internal mergeRefs combines this sentinel with
295
+ // motion's own ref — both fire together when the wrapped Component
296
+ // forwards props.ref to a DOM element. If neither fires after the
297
+ // mount cycle, the wrap is broken.
298
+ //
299
+ // The sentinel-merged ref is computed eagerly (not as a getter) so
300
+ // Solid's spread equality check doesn't churn — refs are stable
301
+ // callbacks set once per mount.
302
+ let refFired = false
303
+ const detector = (_el: MotionElement) => {
304
+ refFired = true
305
+ }
306
+ const userRef = (rest as { ref?: ((el: MotionElement) => void) | MotionElement }).ref
307
+ const mergedUserRef =
308
+ process.env.NODE_ENV !== "production"
309
+ ? mergeRefs(userRef as ((el: MotionElement) => void) | undefined, detector)
310
+ : userRef
311
+ const restWithDetector =
312
+ process.env.NODE_ENV !== "production" ? mergeProps(rest, { ref: mergedUserRef }) : rest
313
+
314
+ if (process.env.NODE_ENV !== "production") {
315
+ onMount(() => {
316
+ // Defer one microtask so any synchronous-but-deep ref chain has
317
+ // had a chance to fire. Solid's createRenderEffect runs refs
318
+ // during the synchronous mount, but the Component might wrap
319
+ // its DOM root in another <Show>-like deferral.
320
+ queueMicrotask(() => {
321
+ if (!refFired) {
322
+ console.warn(
323
+ "[solidjs-motion] motion.create wrapped a Component whose root " +
324
+ "didn't receive motion's ref. The wrapped Component must " +
325
+ "either spread {...props} on a single DOM element OR " +
326
+ "explicitly forward `props.ref` to its root. Motion's " +
327
+ "animations and exit registration won't run until this " +
328
+ "is fixed.",
329
+ )
330
+ }
331
+ })
332
+ })
333
+ }
334
+
335
+ return (
336
+ <m.Provider>
337
+ <Component {...(m(restWithDetector as ElementProps) as unknown as P)} />
338
+ </m.Provider>
339
+ )
340
+ }
341
+ motionComponents.add(Wrapped)
342
+ return Wrapped
343
+ }
344
+
345
+ /**
346
+ * `motion` — the indexable proxy. Every property access returns a cached
347
+ * motion-aware component for the given HTML/SVG tag. The reserved
348
+ * `motion.create` key returns the HOC entry point.
349
+ *
350
+ * @example HTML element
351
+ * ```tsx
352
+ * <motion.div animate={{ x: 100 }} hover={{ scale: 1.05 }}>
353
+ * draggable card
354
+ * </motion.div>
355
+ * ```
356
+ *
357
+ * @example SVG element (handled transparently via <Dynamic>)
358
+ * ```tsx
359
+ * <motion.path d="M0 0 L100 100" animate={{ pathLength: 1 }} />
360
+ * ```
361
+ *
362
+ * @example Wrapping a custom Component via the HOC
363
+ * ```tsx
364
+ * const Animated = motion.create(MyCard)
365
+ * <Animated animate={{ scale: 1.05 }} class="my-card" />
366
+ * ```
367
+ *
368
+ * Non-string keys (Symbols, well-known properties) return `undefined` so
369
+ * debugging tools and `typeof` checks see a sane shape.
370
+ */
371
+ export const motion: Motion = new Proxy({} as Motion, {
372
+ get(_target, key) {
373
+ if (typeof key !== "string") return undefined
374
+ if (key === "create") return motionCreate
375
+ return makeMotionTag(key) as never
376
+ },
377
+ }) as Motion
@@ -0,0 +1,32 @@
1
+ import { type Context, createContext, useContext } from "solid-js"
2
+ import type { PresenceContextValue } from "./types"
3
+
4
+ /**
5
+ * No-op default. `useMotion` consumers wire their `exit` targets through this
6
+ * context, but without an enclosing `<Presence>` or `useAnimatePresence()`
7
+ * the registration is silently dropped. The real implementations live in
8
+ * `presence.tsx` (Phase 3).
9
+ *
10
+ * The shape is the inverted one (see types.ts JSDoc): children register a
11
+ * `runExit` callable; Presence dispatches via `beforeUnmount`. The no-op
12
+ * accepts the registration and resolves `beforeUnmount` immediately so
13
+ * motion children outside a `<Presence>` unmount instantly without trying
14
+ * to animate.
15
+ */
16
+ const noopPresenceContext: PresenceContextValue = {
17
+ register: () => {},
18
+ unregister: () => {},
19
+ beforeUnmount: () => Promise.resolve(),
20
+ // `registerEnter` / `beforeMount` / `initial` intentionally LEFT UNDEFINED.
21
+ // createMotion detects "in a real Presence" by `presence.initial !== undefined`
22
+ // (the no-op leaves it undefined). Without that signal it animates first-
23
+ // mount immediately — outside a Presence the element is always already in
24
+ // the DOM by the time createMotion runs, so no defer is needed.
25
+ }
26
+
27
+ export const PresenceContext: Context<PresenceContextValue> =
28
+ createContext<PresenceContextValue>(noopPresenceContext)
29
+
30
+ export function usePresenceContext(): PresenceContextValue {
31
+ return useContext(PresenceContext)
32
+ }