solidjs-motion 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidjs-motion",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "An animation library for SolidJS — port of motion/react patterns built on the framework-agnostic motion package",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -77,6 +77,86 @@ function ensureVisualElement(el: HTMLElement): InstanceType<typeof HTMLVisualEle
77
77
  return ve as InstanceType<typeof HTMLVisualElement>
78
78
  }
79
79
 
80
+ /**
81
+ * Parse the visible translate from an element's transform — reads both
82
+ * `getComputedStyle(el).transform` (which real browsers normalize to
83
+ * matrix form) AND `el.style.transform` (which preserves the raw syntax
84
+ * motion-dom's writer emits, e.g. `translate3d(50px, 0px, 0)`). Used by
85
+ * drag's pan-start to sync the x/y MotionValues to what the user is
86
+ * actually seeing.
87
+ *
88
+ * Why both sources: motion's `animate(el, target)` interpolates style.
89
+ * transform via WAAPI but DOESN'T update the VE's MVs during the tween,
90
+ * so after `initial: {x:-300} → animate: {x:0}` the MV still holds -300.
91
+ * Reading the current transform recovers the truth. We prefer computed
92
+ * (post-animation, post-WAAPI-commit value) and fall back to inline
93
+ * (covers jsdom + cases where motion's writer wrote inline but the
94
+ * browser hasn't run a style-resolve pass yet).
95
+ *
96
+ * Supported syntaxes:
97
+ * - `"none"` / empty → {0, 0}
98
+ * - `matrix(a, b, c, d, tx, ty)`
99
+ * - `matrix3d(..., tx, ty, ...)`
100
+ * - `translateX(Npx)` / `translateY(Npx)` / `translate(tx, ty)`
101
+ * - `translate3d(tx, ty, tz)`
102
+ * - Any of the above mixed with other transform functions (regex-
103
+ * based extraction picks just the translate components).
104
+ */
105
+ function readVisibleTranslate(el: HTMLElement): { x: number; y: number } {
106
+ const fromString = (transform: string): { x: number; y: number } | null => {
107
+ if (!transform || transform === "none") return null
108
+
109
+ if (transform.startsWith("matrix3d(")) {
110
+ const values = transform.slice(9, -1).split(",")
111
+ const tx = Number.parseFloat(values[12] ?? "0")
112
+ const ty = Number.parseFloat(values[13] ?? "0")
113
+ if (!Number.isFinite(tx) && !Number.isFinite(ty)) return null
114
+ return { x: Number.isFinite(tx) ? tx : 0, y: Number.isFinite(ty) ? ty : 0 }
115
+ }
116
+ if (transform.startsWith("matrix(")) {
117
+ const values = transform.slice(7, -1).split(",")
118
+ const tx = Number.parseFloat(values[4] ?? "0")
119
+ const ty = Number.parseFloat(values[5] ?? "0")
120
+ if (!Number.isFinite(tx) && !Number.isFinite(ty)) return null
121
+ return { x: Number.isFinite(tx) ? tx : 0, y: Number.isFinite(ty) ? ty : 0 }
122
+ }
123
+
124
+ // motion-dom's writer emits the keyword form: `translate3d(...)`,
125
+ // `translateX(...)`, etc. — usually as the first segment of a
126
+ // composed transform like `translate3d(50px, 0px, 0) scale(1)`.
127
+ let x = 0
128
+ let y = 0
129
+ let found = false
130
+ const translate3d = transform.match(/translate3d\(\s*([-\d.]+)px\s*,\s*([-\d.]+)px/)
131
+ if (translate3d) {
132
+ x = Number.parseFloat(translate3d[1] ?? "0")
133
+ y = Number.parseFloat(translate3d[2] ?? "0")
134
+ found = true
135
+ } else {
136
+ const translate2d = transform.match(/translate\(\s*([-\d.]+)px\s*(?:,\s*([-\d.]+)px)?/)
137
+ if (translate2d) {
138
+ x = Number.parseFloat(translate2d[1] ?? "0")
139
+ y = Number.parseFloat(translate2d[2] ?? "0")
140
+ found = true
141
+ }
142
+ const translateX = transform.match(/translateX\(\s*([-\d.]+)px/)
143
+ if (translateX) {
144
+ x = Number.parseFloat(translateX[1] ?? "0")
145
+ found = true
146
+ }
147
+ const translateY = transform.match(/translateY\(\s*([-\d.]+)px/)
148
+ if (translateY) {
149
+ y = Number.parseFloat(translateY[1] ?? "0")
150
+ found = true
151
+ }
152
+ }
153
+ if (!found) return null
154
+ return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0 }
155
+ }
156
+
157
+ return fromString(getComputedStyle(el).transform) ?? fromString(el.style.transform) ?? { x: 0, y: 0 }
158
+ }
159
+
80
160
  /**
81
161
  * Compute the `touch-action` CSS value for an element being dragged.
82
162
  * Disabling touch-action prevents the browser from interpreting the gesture
@@ -272,7 +352,7 @@ export function createDrag(
272
352
  // Stable handler references — they close over getOpts so reactive opts
273
353
  // are read at event time. Hoisted out of the createPan call so the
274
354
  // function-form options below doesn't re-allocate them per getOpts() call.
275
- const handlePanStart = (event: PointerEvent, info: PanInfo) => {
355
+ const handlePanStart = (event: PointerEvent, info: PanInfo, mvIsAuthoritative = false) => {
276
356
  // The pan session always fires onPanStart once movement crosses the
277
357
  // threshold. Drag's enable check is here, not at the createPan-setup
278
358
  // site, so toggling `drag` off mid-life immediately stops drag
@@ -287,8 +367,44 @@ export function createDrag(
287
367
  const ve = ensureVisualElement(el)
288
368
  xMV = ve.getValue("x", 0) as MotionValue<number>
289
369
  yMV = ve.getValue("y", 0) as MotionValue<number>
290
- dragStartX = xMV.get()
291
- dragStartY = yMV.get()
370
+
371
+ // dragStart capture — two modes:
372
+ //
373
+ // (1) Default: sync the MV to the element's CURRENT visible translate
374
+ // before capturing. motion's `animate(el, target)` interpolates
375
+ // `style.transform` via WAAPI but DOESN'T update the
376
+ // visualElement's x/y MVs in lockstep, so after an entrance
377
+ // animation (e.g. `initial: {x:-300} → animate: {x:0}`) the MV
378
+ // would still hold the start value. Reading the painted transform
379
+ // recovers the truth and seeds dragStart correctly.
380
+ //
381
+ // (2) `mvIsAuthoritative=true` (e.g. dragControls.start with
382
+ // snapToCursor): the caller wrote the MV synchronously RIGHT
383
+ // before reaching us, but motion-dom's writer is frame-scheduled,
384
+ // so `el.style.transform` may not reflect that write yet. Trust
385
+ // the MV in this path — visible would be stale.
386
+ //
387
+ // Only the axis drag actually uses is touched — touching the locked
388
+ // axis would generate spurious MV writes that callers + tests notice.
389
+ const axis = getOpts().drag
390
+ if (mvIsAuthoritative) {
391
+ dragStartX = xMV.get()
392
+ dragStartY = yMV.get()
393
+ } else {
394
+ const visible = readVisibleTranslate(el)
395
+ if (axis !== "y") {
396
+ if (visible.x !== xMV.get()) xMV.set(visible.x)
397
+ dragStartX = visible.x
398
+ } else {
399
+ dragStartX = xMV.get()
400
+ }
401
+ if (axis !== "x") {
402
+ if (visible.y !== yMV.get()) yMV.set(visible.y)
403
+ dragStartY = visible.y
404
+ } else {
405
+ dragStartY = yMV.get()
406
+ }
407
+ }
292
408
 
293
409
  // Resolve constraints once per session. Reading layout rects mid-drag
294
410
  // would cost a forced reflow per pointermove; one read at drag-start
@@ -567,13 +683,18 @@ export function createDrag(
567
683
  // Fire handlePanStart with a synthesized initial PanInfo. This
568
684
  // initializes xMV/yMV/dragStartX/Y, resolves bounds, sets body styles,
569
685
  // captures pointer, activates whileDrag, and fires onDragStart.
686
+ //
687
+ // `mvIsAuthoritative=true` when the snap path just wrote the MVs
688
+ // above — handlePanStart should read FROM the MV (not from
689
+ // getComputedStyle) because motion-dom's writer is frame-scheduled
690
+ // and `el.style.transform` may not yet reflect the snap write.
570
691
  const initialInfo: PanInfo = {
571
692
  point: { x: event.clientX, y: event.clientY },
572
693
  delta: { x: 0, y: 0 },
573
694
  offset: { x: 0, y: 0 },
574
695
  velocity: { x: 0, y: 0 },
575
696
  }
576
- handlePanStart(event, initialInfo)
697
+ handlePanStart(event, initialInfo, Boolean(options.snapToCursor))
577
698
 
578
699
  // Track the session locally — these would normally live inside
579
700
  // createPan's closure. Velocity samples use motion-dom's `time.now()`
@@ -242,7 +242,8 @@ export function createGestureStateMachine(
242
242
  // (Q4 — gesture inheritance through context). The first active state that
243
243
  // defines a key claims it; lower-priority states are skipped for that key.
244
244
  //
245
- // Q5/C-lean exclusion: when `drag` is enabled, `x` and `y` are owned by
245
+ // Q5/C-lean exclusion: while drag is ACTIVE (pointer engaged), `x` and `y`
246
+ // are owned by
246
247
  // createDrag (it writes them to the VisualElement's MotionValues during
247
248
  // pointer phase). Filter them out of the winners map so motion's animate
248
249
  // (called from this effect) doesn't fight drag's writes. Other transform
@@ -257,7 +258,12 @@ export function createGestureStateMachine(
257
258
  // exit's translation reaches DOM until that happens.
258
259
  const winners = createMemo<Record<string, WinnerEntry>>(() => {
259
260
  const targets = stateTargets()
260
- const dragEnabled = Boolean(getOpts().drag)
261
+ // Drag claims x/y only while the user is ACTIVELY dragging (pointer
262
+ // engaged → `active.whileDrag === true`). When drag is merely
263
+ // configured-but-idle, initial/animate/exit and other states get
264
+ // normal access to x/y — matching motion-react. Reading from `active`
265
+ // tracks the store; the winners memo re-runs when whileDrag flips.
266
+ const dragActive = active.whileDrag
261
267
  const out: Record<string, WinnerEntry> = {}
262
268
  for (const stateName of PRIORITY_HIGH_TO_LOW) {
263
269
  if (!isStateActive(stateName, active, parentVariantCtx)) continue
@@ -268,9 +274,9 @@ export function createGestureStateMachine(
268
274
  if (key === "transition") continue
269
275
  // Higher-priority state already won this key.
270
276
  if (key in out) continue
271
- // x/y are drag-owned when drag is enabled (Q5/C-lean) — unless exit
272
- // is currently active, in which case exit's translation wins.
273
- if (!active.exit && dragEnabled && (key === "x" || key === "y")) continue
277
+ // x/y are drag-owned during active drag — unless exit is also
278
+ // active, in which case exit's translation wins.
279
+ if (!active.exit && dragActive && (key === "x" || key === "y")) continue
274
280
  out[key] = {
275
281
  value: (target as Record<string, unknown>)[key],
276
282
  transition: target.transition,
@@ -363,6 +369,11 @@ export function createGestureStateMachine(
363
369
  }
364
370
  for (const key in lastApplied) {
365
371
  if (key in next) continue
372
+ // x/y aren't "removed" when drag is active — drag is CLAIMING them.
373
+ // Falling back to initial here would dispatch animate(el, {x:-W})
374
+ // on pointerdown and snap the element back to its initial state
375
+ // before the user's first move could reach the DOM.
376
+ if (active.whileDrag && (key === "x" || key === "y")) continue
366
377
  // Removed-key fallback: own initial → motion default → null.
367
378
  const initialValue =
368
379
  initialTarget && key in (initialTarget as Record<string, unknown>)