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,338 @@
1
+ import { isPrimaryPointer, time } from "motion-dom"
2
+ import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js"
3
+ import type { MotionValueAccessor, PanInfo } from "../types"
4
+ import { createMotionValue } from "./motion-value"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // createPan — standalone pan-session primitive (Q11/c).
8
+ //
9
+ // Phase 2 Commit 5 (Q11/D3): pointer-session machinery that createDrag uses
10
+ // as its underlying event source. Drag IS a pan that owns the element's
11
+ // transform; pan on its own is callback-only (no `whilePan` state).
12
+ //
13
+ // Return shape — a SEMANTIC split between animate-able numeric values
14
+ // (MotionValueAccessors) and non-animate-able state (a plain Accessor):
15
+ //
16
+ // - `point.x/y`, `delta.x/y`, `offset.x/y`, `velocity.x/y` → each is a
17
+ // {@link MotionValueAccessor}<number>. Calling them (`pan.point.x()`) is
18
+ // Solid-tracked; the full MotionValue surface (`.get`, `.set`, `.on`,
19
+ // `.getVelocity`) is available; they compose directly with `animate()`,
20
+ // `createTransform`, `useMotion` targets, and JSX reactivity.
21
+ // - `isPanning` → a plain `Accessor<boolean>`. Booleans aren't animate-able,
22
+ // so wrapping in an MV would only add weight.
23
+ //
24
+ // Why MotionValues for the numeric fields? Composability. Users can pipe
25
+ // `pan.point.x` straight into `createTransform`, `animate()`, or use it as
26
+ // a target in `useMotion({ animate: { x: pan.point.x } })` — same surface
27
+ // Phase 1 established for every animate-able value in the library.
28
+ //
29
+ // The session:
30
+ // pointerdown → reset per-session MVs to start point / zeros, attach
31
+ // window listeners
32
+ // pointermove(s) → update MVs every move (Option X — pre-threshold too,
33
+ // so consumers can render threshold-progress or
34
+ // early-detect fast pans); once cumulative offset
35
+ // crosses `threshold`, isPanning flips true and
36
+ // onPanStart fires; subsequent moves fire onPan
37
+ // pointerup → flip isPanning false; if pan happened, fire onPanEnd;
38
+ // point/delta/offset/velocity MVs RETAINED (useful for
39
+ // snap-to-end animations)
40
+ // pointercancel → same as pointerup, but the user's gesture was aborted
41
+ //
42
+ // Velocity tracking (Q15a): sliding window of pointer samples, 200ms wide.
43
+ // Velocity = (latest.point − oldest.point) / Δt × 1000 (px/sec). Uses
44
+ // motion-dom's `time.now()` so timestamps stay frame-synchronous with the
45
+ // rest of motion's pipeline.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Sliding-window width for velocity computation (Q15a). */
49
+ const VELOCITY_WINDOW_MS = 200
50
+ /** Default movement threshold before onPanStart fires (Q11a, matches motion). */
51
+ const DEFAULT_THRESHOLD = 3
52
+
53
+ type Point = { x: number; y: number }
54
+
55
+ export type CreatePanOptions = {
56
+ /** Fires once after pointer movement crosses the threshold. */
57
+ onPanStart?: (event: PointerEvent, info: PanInfo) => void
58
+ /** Fires on every pointermove after onPanStart, until pointerup/cancel. */
59
+ onPan?: (event: PointerEvent, info: PanInfo) => void
60
+ /**
61
+ * Fires on pointerup OR pointercancel after onPanStart has fired.
62
+ * If the pointer was released before the threshold was crossed, onPanEnd
63
+ * is NOT fired (no pan ever happened).
64
+ */
65
+ onPanEnd?: (event: PointerEvent, info: PanInfo) => void
66
+ /**
67
+ * Minimum cumulative offset (in px) before onPanStart fires. Distinguishes
68
+ * pan from click. Default: 3px (motion's default).
69
+ */
70
+ threshold?: number
71
+ }
72
+
73
+ /** Per-axis pair of {@link MotionValueAccessor}s — `pan.point`, `pan.delta`, etc. */
74
+ export type PanAxisPair = {
75
+ x: MotionValueAccessor<number>
76
+ y: MotionValueAccessor<number>
77
+ }
78
+
79
+ /**
80
+ * Returned by {@link createPan}. `isPanning` is a plain Accessor (booleans
81
+ * aren't animate-able). The four numeric pairs are MotionValueAccessors,
82
+ * each composable with `animate()`, `createTransform`, and `useMotion`.
83
+ */
84
+ export type CreatePanResult = {
85
+ isPanning: Accessor<boolean>
86
+ point: PanAxisPair
87
+ delta: PanAxisPair
88
+ offset: PanAxisPair
89
+ velocity: PanAxisPair
90
+ }
91
+
92
+ /**
93
+ * Observe pointer-driven pan gestures on an element.
94
+ *
95
+ * Returns `{ isPanning, point, delta, offset, velocity }`:
96
+ *
97
+ * - `pan.isPanning()` — Solid Accessor; `true` between onPanStart and onPanEnd.
98
+ * - `pan.point.x`, `pan.point.y` — current pointer position in client coords.
99
+ * Each is a {@link MotionValueAccessor}: call `pan.point.x()` for a tracked
100
+ * read, `pan.point.x.get()` for an untracked snapshot, and pass it directly
101
+ * to `animate()`, `createTransform`, or `useMotion` targets.
102
+ * - `pan.delta.x/y` — delta since last pointermove.
103
+ * - `pan.offset.x/y` — cumulative offset since the current pointerdown.
104
+ * - `pan.velocity.x/y` — sliding-window velocity in px/sec.
105
+ *
106
+ * Fields update from `pointerdown` forward (including pre-threshold moves)
107
+ * — gate reads on `pan.isPanning()` if you only care about real pans.
108
+ *
109
+ * The `options` argument accepts either a static object or a function form
110
+ * (matching `useMotion`'s convention). The function form is read INSIDE
111
+ * each pointer-event handler, so reactive option changes apply on the next
112
+ * relevant event without re-attaching listeners.
113
+ *
114
+ * @example Static options
115
+ * const pan = createPan(el, {
116
+ * onPanStart: (e, info) => console.log("start", info.point),
117
+ * threshold: 3,
118
+ * })
119
+ *
120
+ * @example Reactive options (function form — signals tracked)
121
+ * const [threshold, setThreshold] = createSignal(3)
122
+ * const pan = createPan(el, () => ({
123
+ * threshold: threshold(),
124
+ * onPanStart: (e, info) => console.log(info),
125
+ * }))
126
+ *
127
+ * @example Composing pan.point.x with createTransform
128
+ * const pan = createPan(el)
129
+ * const rotation = createTransform(pan.point.x, [0, 300], [0, 90])
130
+ * <div ref={setEl} style={{ transform: `rotate(${rotation()}deg)` }} />
131
+ *
132
+ * @example Reading reactively in JSX
133
+ * const pan = createPan(el)
134
+ * <Show when={pan.isPanning()}>
135
+ * Position: {pan.point.x()}, {pan.point.y()}
136
+ * </Show>
137
+ */
138
+ export function createPan(
139
+ ref: () => HTMLElement | null | undefined,
140
+ options: CreatePanOptions | (() => CreatePanOptions) = {},
141
+ ): CreatePanResult {
142
+ // Normalize to a function form. All option reads inside event handlers
143
+ // call this so the latest reactive values are seen on each event.
144
+ const getOpts: () => CreatePanOptions = typeof options === "function" ? options : () => options
145
+
146
+ // ---- State surface ----
147
+ // isPanning is a plain signal — booleans aren't animate-able, so a full
148
+ // MotionValue would be dead weight.
149
+ const [isPanning, setIsPanning] = createSignal(false)
150
+ // Eight MVs for the four numeric pairs. Each becomes a callable hybrid via
151
+ // createMotionValue: invokable as a tracked Accessor AND has the full
152
+ // MotionValue surface so consumers can pipe them into `animate()`,
153
+ // `createTransform`, or `useMotion` targets.
154
+ const pointX = createMotionValue(0)
155
+ const pointY = createMotionValue(0)
156
+ const deltaX = createMotionValue(0)
157
+ const deltaY = createMotionValue(0)
158
+ const offsetX = createMotionValue(0)
159
+ const offsetY = createMotionValue(0)
160
+ const velocityX = createMotionValue(0)
161
+ const velocityY = createMotionValue(0)
162
+
163
+ // createEffect — Solid-idiomatic for side-effect setup (DOM listeners).
164
+ // First iteration runs in the next microtask, which is harmless here: a
165
+ // freshly-mounted element can't receive pointer events between the ref
166
+ // callback firing and the next microtask. Re-runs (ref changes) carry the
167
+ // same harmless delay.
168
+ createEffect(() => {
169
+ const el = ref()
170
+ if (!el) return
171
+
172
+ // NOTE: threshold and callbacks are read INSIDE the event handlers via
173
+ // getOpts(), not captured here. That way reactive opts changes apply on
174
+ // the next relevant event without re-attaching listeners (which would
175
+ // require this effect to depend on getOpts and re-run on opt changes).
176
+
177
+ // Session state — reset on each pointerdown. Scoped per effect iteration;
178
+ // cleanup below reaches all listeners regardless of phase.
179
+ let startPoint: Point | null = null
180
+ let lastPoint: Point | null = null
181
+ let pointerId: number | null = null
182
+ let panning = false
183
+ let samples: Array<{ t: number; point: Point }> = []
184
+
185
+ function pointOf(event: PointerEvent): Point {
186
+ return { x: event.clientX, y: event.clientY }
187
+ }
188
+
189
+ function computeVelocity(): Point {
190
+ if (samples.length < 2) return { x: 0, y: 0 }
191
+ // biome-ignore lint/style/noNonNullAssertion: length >= 2 guarantees both indices exist
192
+ const first = samples[0]!
193
+ // biome-ignore lint/style/noNonNullAssertion: length >= 2 guarantees both indices exist
194
+ const last = samples[samples.length - 1]!
195
+ const dt = last.t - first.t
196
+ if (dt <= 0) return { x: 0, y: 0 }
197
+ return {
198
+ x: ((last.point.x - first.point.x) / dt) * 1000,
199
+ y: ((last.point.y - first.point.y) / dt) * 1000,
200
+ }
201
+ }
202
+
203
+ function buildInfo(event: PointerEvent): PanInfo {
204
+ const point = pointOf(event)
205
+ const delta = lastPoint
206
+ ? { x: point.x - lastPoint.x, y: point.y - lastPoint.y }
207
+ : { x: 0, y: 0 }
208
+ const offset = startPoint
209
+ ? { x: point.x - startPoint.x, y: point.y - startPoint.y }
210
+ : { x: 0, y: 0 }
211
+ const velocity = computeVelocity()
212
+ return { point, delta, offset, velocity }
213
+ }
214
+
215
+ /** Push a freshly-computed info snapshot into the MVs. Each `.set` fires
216
+ * the MV's change subscription, which the callable-hybrid bridge
217
+ * forwards to Solid; consumers reading e.g. only `pan.velocity.x()` only
218
+ * re-run when velocity.x actually changes — pre-existing MotionValue
219
+ * granularity, not Store path-tracking. */
220
+ function writeInfo(info: PanInfo): void {
221
+ pointX.set(info.point.x)
222
+ pointY.set(info.point.y)
223
+ deltaX.set(info.delta.x)
224
+ deltaY.set(info.delta.y)
225
+ offsetX.set(info.offset.x)
226
+ offsetY.set(info.offset.y)
227
+ velocityX.set(info.velocity.x)
228
+ velocityY.set(info.velocity.y)
229
+ }
230
+
231
+ function onPointerDown(event: PointerEvent): void {
232
+ // motion-dom's isPrimaryPointer filters secondary buttons (mouse) and
233
+ // secondary touch points. Same gating Q13c established for press.
234
+ if (!isPrimaryPointer(event)) return
235
+
236
+ startPoint = pointOf(event)
237
+ lastPoint = startPoint
238
+ pointerId = event.pointerId
239
+ panning = false
240
+ samples = [{ t: time.now(), point: startPoint }]
241
+
242
+ // Reset per-session fields. Point goes to start; delta/offset/velocity
243
+ // zero. isPanning false (threshold not crossed yet).
244
+ setIsPanning(false)
245
+ pointX.set(startPoint.x)
246
+ pointY.set(startPoint.y)
247
+ deltaX.set(0)
248
+ deltaY.set(0)
249
+ offsetX.set(0)
250
+ offsetY.set(0)
251
+ velocityX.set(0)
252
+ velocityY.set(0)
253
+
254
+ // Listen on window so events keep firing even when the pointer leaves
255
+ // the element (e.g., during a fast drag). Mirrors motion-dom's press.
256
+ window.addEventListener("pointermove", onPointerMove)
257
+ window.addEventListener("pointerup", onPointerEnd)
258
+ window.addEventListener("pointercancel", onPointerEnd)
259
+ }
260
+
261
+ function onPointerMove(event: PointerEvent): void {
262
+ // Multi-touch / unrelated pointers ignored.
263
+ if (event.pointerId !== pointerId) return
264
+
265
+ const point = pointOf(event)
266
+ const now = time.now()
267
+
268
+ // Append sample, drop everything outside the 200ms window.
269
+ samples.push({ t: now, point })
270
+ const cutoff = now - VELOCITY_WINDOW_MS
271
+ while (samples.length > 1 && (samples[0]?.t ?? 0) < cutoff) {
272
+ samples.shift()
273
+ }
274
+
275
+ const info = buildInfo(event)
276
+ // Option X — info updates on EVERY move, including pre-threshold.
277
+ // Consumers gate on `isPanning()` for "real pan" semantics.
278
+ writeInfo(info)
279
+
280
+ if (!panning) {
281
+ // Threshold gate: pan hasn't started yet. Read threshold fresh from
282
+ // getOpts() so reactive changes apply (a session in progress sticks
283
+ // with the threshold it saw when this branch first crossed).
284
+ const threshold = getOpts().threshold ?? DEFAULT_THRESHOLD
285
+ const distance = Math.hypot(info.offset.x, info.offset.y)
286
+ if (distance >= threshold) {
287
+ panning = true
288
+ setIsPanning(true)
289
+ getOpts().onPanStart?.(event, info)
290
+ }
291
+ } else {
292
+ getOpts().onPan?.(event, info)
293
+ }
294
+ lastPoint = point
295
+ }
296
+
297
+ function onPointerEnd(event: PointerEvent): void {
298
+ if (event.pointerId !== pointerId) return
299
+ // onPanEnd only fires if onPanStart fired — pan-cancelled-before-start
300
+ // (mere clicks) shouldn't emit lifecycle callbacks.
301
+ if (panning) {
302
+ getOpts().onPanEnd?.(event, buildInfo(event))
303
+ }
304
+ panning = false
305
+ // Flip isPanning. Point/delta/offset/velocity MVs are RETAINED
306
+ // (option Q5/3) so consumers can read the final state for
307
+ // snap-to-end animations. Next pointerdown will reset them.
308
+ setIsPanning(false)
309
+ startPoint = null
310
+ lastPoint = null
311
+ pointerId = null
312
+ samples = []
313
+ window.removeEventListener("pointermove", onPointerMove)
314
+ window.removeEventListener("pointerup", onPointerEnd)
315
+ window.removeEventListener("pointercancel", onPointerEnd)
316
+ }
317
+
318
+ el.addEventListener("pointerdown", onPointerDown)
319
+
320
+ // Iteration-scoped cleanup: fires when the ref changes (effect re-runs)
321
+ // AND when the owner disposes. Removes all listeners regardless of
322
+ // whether a session was in progress.
323
+ onCleanup(() => {
324
+ el.removeEventListener("pointerdown", onPointerDown)
325
+ window.removeEventListener("pointermove", onPointerMove)
326
+ window.removeEventListener("pointerup", onPointerEnd)
327
+ window.removeEventListener("pointercancel", onPointerEnd)
328
+ })
329
+ })
330
+
331
+ return {
332
+ isPanning,
333
+ point: { x: pointX, y: pointY },
334
+ delta: { x: deltaX, y: deltaY },
335
+ offset: { x: offsetX, y: offsetY },
336
+ velocity: { x: velocityX, y: velocityY },
337
+ }
338
+ }
@@ -0,0 +1,101 @@
1
+ import { scroll as motionScroll } from "motion"
2
+ import { createEffect, onCleanup } from "solid-js"
3
+ import type { MotionValueAccessor } from "../types"
4
+ import { createMotionValue } from "./motion-value"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Scroll progress info — motion's `OnScrollWithInfo` callback receives this
8
+ // shape per scroll tick. Sticking to the small subset we wire into our
9
+ // MotionValues to keep our public types narrow.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ type ScrollAxisInfo = {
13
+ current: number
14
+ progress: number
15
+ }
16
+
17
+ type MotionScrollInfo = {
18
+ x: ScrollAxisInfo
19
+ y: ScrollAxisInfo
20
+ }
21
+
22
+ // biome-ignore lint/suspicious/noExplicitAny: motion's offset format is internal; we re-expose it as opaque
23
+ type ScrollOffset = any[]
24
+
25
+ export type CreateScrollOptions = {
26
+ /** Accessor returning the scroll container element. Defaults to window. */
27
+ container?: () => Element | null
28
+ /** Accessor returning the scroll target. Defaults to the container itself. */
29
+ target?: () => Element | null
30
+ /** Primary scroll axis (both axes are still populated regardless). */
31
+ axis?: "x" | "y"
32
+ /** Intersection offsets controlling when progress reaches 0/1. */
33
+ offset?: ScrollOffset
34
+ }
35
+
36
+ export type CreateScrollResult = {
37
+ /** Current scroll-x position in px. Callable: `scrollX()` for reactive read. */
38
+ scrollX: MotionValueAccessor<number>
39
+ /** Current scroll-y position in px. Callable: `scrollY()` for reactive read. */
40
+ scrollY: MotionValueAccessor<number>
41
+ /** Normalized scroll-x progress in `[0, 1]` (or `[0, n]` for multi-offset). */
42
+ scrollXProgress: MotionValueAccessor<number>
43
+ /** Normalized scroll-y progress in `[0, 1]`. */
44
+ scrollYProgress: MotionValueAccessor<number>
45
+ }
46
+
47
+ /**
48
+ * Bind four {@link MotionValueAccessor}s to a scroll source. Mirrors
49
+ * motion/react's `useScroll`; defaults to the window when no container is
50
+ * supplied. Each returned value is callable as a Solid Accessor AND has the
51
+ * full MotionValue surface, so it composes with `useMotion`'s target,
52
+ * `animate()`, `createTransform`, and direct JSX reactivity.
53
+ *
54
+ * @example
55
+ * const { scrollY, scrollYProgress } = createScroll()
56
+ * const opacity = createTransform(scrollYProgress, [0, 1], [1, 0])
57
+ *
58
+ * @example
59
+ * const [el, setEl] = createSignal<HTMLElement>()
60
+ * const { scrollY } = createScroll({ container: el })
61
+ * <div ref={setEl} style={{ overflow: "auto" }}>...</div>
62
+ */
63
+ export function createScroll(options?: CreateScrollOptions): CreateScrollResult {
64
+ const scrollX = createMotionValue(0)
65
+ const scrollY = createMotionValue(0)
66
+ const scrollXProgress = createMotionValue(0)
67
+ const scrollYProgress = createMotionValue(0)
68
+
69
+ // motion's scroll callback signature has two forms (OnScrollProgress and
70
+ // OnScrollWithInfo). With two parameters, info is passed.
71
+ const handler = (_progress: number, info?: MotionScrollInfo) => {
72
+ if (!info) return
73
+ scrollX.set(info.x.current)
74
+ scrollY.set(info.y.current)
75
+ scrollXProgress.set(info.x.progress)
76
+ scrollYProgress.set(info.y.progress)
77
+ }
78
+
79
+ // createEffect — Solid-idiomatic for side-effect setup (attaching the
80
+ // motion scroll subscription). First iteration runs in the next
81
+ // microtask, which is harmless: scroll events can't fire before the
82
+ // microtask flushes after mount. Accessors inside the body (container,
83
+ // target) are tracked — re-init happens when refs change.
84
+ //
85
+ // Each iteration's onCleanup is scoped to that iteration — Solid fires
86
+ // it when the effect re-runs (tearing down the previous subscription)
87
+ // and again when the outer owner disposes (tearing down the final one).
88
+ createEffect(() => {
89
+ const container = options?.container?.() ?? undefined
90
+ const target = options?.target?.() ?? undefined
91
+ const cleanup = motionScroll(handler, {
92
+ container: container as HTMLElement | undefined,
93
+ target: target as HTMLElement | undefined,
94
+ axis: options?.axis,
95
+ offset: options?.offset,
96
+ } as Parameters<typeof motionScroll>[1])
97
+ onCleanup(cleanup)
98
+ })
99
+
100
+ return { scrollX, scrollY, scrollXProgress, scrollYProgress }
101
+ }