@humanspeak/svelte-motion 0.3.5 → 0.4.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.
@@ -1,4 +1,4 @@
1
- import { pwLog, pwWarn } from './log';
1
+ import { isPlaywrightEnv, pwLog, pwWarn } from './log';
2
2
  /**
3
3
  * Drag utilities
4
4
  *
@@ -16,7 +16,7 @@ import { pwLog, pwWarn } from './log';
16
16
  * - For nested drags, set `propagation` as needed to avoid parent-child contention.
17
17
  */
18
18
  import { isDomElement } from './dom';
19
- import { applyConstraints as applyFloatConstraints } from './dragMath';
19
+ import { applyConstraints as applyFloatConstraints, parseMatrixTranslate } from './dragMath';
20
20
  import { deriveBoundaryPhysics } from './dragParams';
21
21
  import { computeHoverBaseline, splitHoverDefinition } from './hover';
22
22
  import { createInertiaToBoundary } from './inertia';
@@ -87,6 +87,53 @@ export const applyElastic = (value, min, max, elastic) => {
87
87
  };
88
88
  /** Prefer high-resolution time in browser; fall back for SSR/tests. */
89
89
  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
90
+ /** Sample windows for release-velocity inference (matches motion-dom values). */
91
+ const MAX_VELOCITY_DELTA_MS = 30;
92
+ const MIN_VELOCITY_INTERVAL_MS = 5;
93
+ /**
94
+ * Compute the release velocity for momentum from a pointer-history window.
95
+ *
96
+ * Mirrors motion-dom: walks back from the newest sample, including only
97
+ * samples within `MAX_VELOCITY_DELTA_MS` (30 ms) of newest, then divides
98
+ * the displacement by the elapsed time. Returns 0 if the newest sample is
99
+ * stale, the window has fewer than two samples, or the oldest-newest span
100
+ * is shorter than `MIN_VELOCITY_INTERVAL_MS` (5 ms — sub-frame).
101
+ *
102
+ * @param history Recent pointer samples ordered oldest → newest. Each
103
+ * sample is `{ x, y, t }` where `t` is `performance.now()` ms.
104
+ * @param nowMs Current `performance.now()` ms — used to discard a stale
105
+ * newest sample (finger lifted after a pause).
106
+ * @returns Inferred release velocity in pixels per second on each axis.
107
+ * @example
108
+ * const v = computeReleaseVelocity(
109
+ * [{ x: 0, y: 0, t: 1000 }, { x: 20, y: 0, t: 1020 }],
110
+ * 1020
111
+ * )
112
+ * // v ≈ { x: 1000, y: 0 } — 20 px over 20 ms → 1000 px/s
113
+ */
114
+ const computeReleaseVelocity = (history, nowMs) => {
115
+ if (history.length < 2)
116
+ return { x: 0, y: 0 };
117
+ const newest = history[history.length - 1];
118
+ if (nowMs - newest.t > MAX_VELOCITY_DELTA_MS)
119
+ return { x: 0, y: 0 };
120
+ let oldestIdx = history.length - 1;
121
+ for (let i = history.length - 2; i >= 0; i--) {
122
+ if (newest.t - history[i].t > MAX_VELOCITY_DELTA_MS)
123
+ break;
124
+ oldestIdx = i;
125
+ }
126
+ if (oldestIdx === history.length - 1)
127
+ return { x: 0, y: 0 };
128
+ const oldest = history[oldestIdx];
129
+ const dtMs = newest.t - oldest.t;
130
+ if (dtMs < MIN_VELOCITY_INTERVAL_MS)
131
+ return { x: 0, y: 0 };
132
+ return {
133
+ x: ((newest.x - oldest.x) / dtMs) * 1000,
134
+ y: ((newest.y - oldest.y) / dtMs) * 1000
135
+ };
136
+ };
90
137
  /**
91
138
  * Attach a drag gesture to an element.
92
139
  *
@@ -184,25 +231,21 @@ export const attachDrag = (el, opts) => {
184
231
  applied.x = x;
185
232
  if ('y' in payload)
186
233
  applied.y = y;
187
- // Verify it's actually applied; if not, retry once (Playwright visibility only)
188
- const cs = getComputedStyle(el);
189
- let actualTransform = cs.transform;
190
- if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
191
- pwWarn('⚠️ setXY transform missing; retrying write', {
192
- x,
193
- y,
194
- transform: actualTransform
195
- });
196
- animate(el, ('x' in payload || 'y' in payload ? payload : { x, y }), {
197
- duration: 0
198
- });
199
- actualTransform = getComputedStyle(el).transform;
234
+ // Playwright-only sanity check: confirm the transform actually
235
+ // landed on the element and retry once if not. Forces a style
236
+ // recalc via getComputedStyle, so we gate it behind the same
237
+ // playwright env flag pwLog uses so it never fires in prod.
238
+ if (isPlaywrightEnv()) {
239
+ let actualTransform = getComputedStyle(el).transform;
200
240
  if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
201
- pwWarn('⚠️ setXY second attempt still missing transform', {
202
- x,
203
- y,
204
- transform: actualTransform
205
- });
241
+ pwWarn('⚠️ setXY transform missing; retrying write', { x, y });
242
+ animate(el, ('x' in payload || 'y' in payload
243
+ ? payload
244
+ : { x, y }), { duration: 0 });
245
+ actualTransform = getComputedStyle(el).transform;
246
+ if (actualTransform === 'none' || !actualTransform.includes('matrix')) {
247
+ pwWarn('⚠️ setXY second attempt still missing transform', { x, y });
248
+ }
206
249
  }
207
250
  }
208
251
  };
@@ -404,22 +447,28 @@ export const attachDrag = (el, opts) => {
404
447
  });
405
448
  if (!dragging)
406
449
  return;
407
- finishDrag(e);
450
+ // Pointer was preempted (gesture-nav, palm rejection, scroll
451
+ // takeover). User did not release intentionally — skip the
452
+ // inertia/momentum path and force a no-momentum settle so the
453
+ // card clamps back into constraints without flinging.
454
+ finishDrag(e, true);
408
455
  };
409
456
  /**
410
457
  * Finish a drag:
411
458
  * - If momentum is enabled, decay towards a clamped target with exponential easing
412
459
  * - Otherwise, animate back to a clamped position (or origin), then sync `applied`
413
460
  */
414
- const finishDrag = (e) => {
461
+ const finishDrag = (e, cancelled = false) => {
415
462
  dragging = false;
463
+ velocity = computeReleaseVelocity(history, now());
416
464
  pwLog('[drag] finish', {
417
465
  el: EL_ID,
418
466
  lastPoint,
419
467
  startPoint,
420
468
  origin,
421
469
  applied,
422
- momentum
470
+ momentum,
471
+ velocity
423
472
  });
424
473
  try {
425
474
  if ('releasePointerCapture' in el && typeof e.pointerId === 'number')
@@ -434,8 +483,10 @@ export const attachDrag = (el, opts) => {
434
483
  window.removeEventListener('pointermove', onPointerMove);
435
484
  window.removeEventListener('pointerup', onPointerUp);
436
485
  window.removeEventListener('pointercancel', onPointerCancel);
437
- // Momentum/inertia with boundary handoff: inertia until crossing, then spring to boundary
438
- if (momentum) {
486
+ // Momentum/inertia with boundary handoff: inertia until crossing, then spring to boundary.
487
+ // Pointer-cancel forces a no-momentum settle (clamp into constraints, no fling) since the
488
+ // gesture was preempted rather than intentionally released.
489
+ if (momentum && !cancelled) {
439
490
  pwLog('🚀 STARTING MOMENTUM', {
440
491
  velocityX: velocity.x,
441
492
  velocityY: velocity.y,
@@ -454,6 +505,18 @@ export const attachDrag = (el, opts) => {
454
505
  ...(applyX ? { x: 0 } : {}),
455
506
  ...(applyY ? { y: 0 } : {})
456
507
  }, (mergedTransition ?? {}));
508
+ // Cancel hook so re-grab / controls.stop() interrupts the snap.
509
+ stopInertia = () => {
510
+ pwLog('❌ snapToOrigin cancelled');
511
+ controls.stop?.();
512
+ // Sync applied to wherever the snap reached so the next drag origin is correct.
513
+ const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
514
+ if (applyX)
515
+ applied.x = tx;
516
+ if (applyY)
517
+ applied.y = ty;
518
+ stopInertia = null;
519
+ };
457
520
  Promise.resolve(controls.finished)
458
521
  .then(() => {
459
522
  // Sync internal applied transform so next drag uses the correct origin
@@ -465,26 +528,31 @@ export const attachDrag = (el, opts) => {
465
528
  el: EL_ID,
466
529
  applied
467
530
  });
531
+ if (stopInertia)
532
+ stopInertia = null;
468
533
  })
469
534
  .catch(() => { })
470
535
  .finally(() => opts.callbacks?.onTransitionEnd?.());
471
536
  return;
472
537
  }
473
- // If no constraints, disable boundary springs by setting finite but very wide bounds
538
+ // Boundary min/max anchor to `constraintsBase` (the absolute
539
+ // pixel-constraint origin) so the inertia handoff snaps to the
540
+ // same edge pointermove's elastic clamping uses — not to a
541
+ // per-drag-shifted `origin` that would drift across drags.
474
542
  const noConstraints = !constraints;
475
543
  const huge = 1e6;
476
544
  const minX = noConstraints
477
545
  ? applied.x - huge
478
- : origin.x + (constraints?.left ?? -Infinity);
546
+ : constraintsBase.x + (constraints?.left ?? -Infinity);
479
547
  const maxX = noConstraints
480
548
  ? applied.x + huge
481
- : origin.x + (constraints?.right ?? Infinity);
549
+ : constraintsBase.x + (constraints?.right ?? Infinity);
482
550
  const minY = noConstraints
483
551
  ? applied.y - huge
484
- : origin.y + (constraints?.top ?? -Infinity);
552
+ : constraintsBase.y + (constraints?.top ?? -Infinity);
485
553
  const maxY = noConstraints
486
554
  ? applied.y + huge
487
- : origin.y + (constraints?.bottom ?? Infinity);
555
+ : constraintsBase.y + (constraints?.bottom ?? Infinity);
488
556
  const { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping } = deriveBoundaryPhysics(elastic, opts.transition);
489
557
  pwLog('⚙️ boundary-physics', {
490
558
  timeConstantMs,
@@ -500,6 +568,10 @@ export const attachDrag = (el, opts) => {
500
568
  // Respect direction lock on release: only animate the locked axis
501
569
  const applyX = (axis === true || axis === 'x') && lockAxis !== 'y';
502
570
  const applyY = (axis === true || axis === 'y') && lockAxis !== 'x';
571
+ // Element-ref constraints can resize / reflow during inertia.
572
+ // Pixel constraints never change once set. We re-resolve only
573
+ // for element-ref each frame in the rAF loop below.
574
+ const isElementRefConstraint = isDomElement(opts.constraints);
503
575
  const stepX = applyX
504
576
  ? createInertiaToBoundary({ value: applied.x, velocity: velocity.x }, { min: minX, max: maxX }, { timeConstantMs, restDelta, restSpeed, bounceStiffness, bounceDamping })
505
577
  : null;
@@ -519,9 +591,27 @@ export const attachDrag = (el, opts) => {
519
591
  // Use the precomputed step functions (built once per release)
520
592
  const rx = stepX ? stepX(t) : { value: applied.x, done: true };
521
593
  const ry = stepY ? stepY(t) : { value: applied.y, done: true };
522
- // Preserve non-animated axis exactly; don't write y during x-only drags, or vice versa
523
- const nextX = rx.value;
524
- const nextY = (axis === true || axis === 'y') && lockAxis !== 'x' ? ry.value : applied.y;
594
+ // Element-ref constraints may have resized / reflowed since
595
+ // the steppers were built. Re-resolve and clamp the output
596
+ // so the card never lands outside the now-current container
597
+ // even if its boundary moved mid-spring. Pixel constraints
598
+ // don't move so we skip the work.
599
+ let nextX = rx.value;
600
+ let nextY = (axis === true || axis === 'y') && lockAxis !== 'x' ? ry.value : applied.y;
601
+ if (isElementRefConstraint) {
602
+ const freshConstraints = resolveConstraints(el, opts.constraints);
603
+ if (freshConstraints) {
604
+ constraintsBase = { x: applied.x, y: applied.y };
605
+ const freshMinX = constraintsBase.x + (freshConstraints.left ?? -Infinity);
606
+ const freshMaxX = constraintsBase.x + (freshConstraints.right ?? Infinity);
607
+ const freshMinY = constraintsBase.y + (freshConstraints.top ?? -Infinity);
608
+ const freshMaxY = constraintsBase.y + (freshConstraints.bottom ?? Infinity);
609
+ if (applyX)
610
+ nextX = Math.max(freshMinX, Math.min(freshMaxX, nextX));
611
+ if (applyY)
612
+ nextY = Math.max(freshMinY, Math.min(freshMaxY, nextY));
613
+ }
614
+ }
525
615
  setXY(nextX, nextY);
526
616
  if (frameCount <= 3 || frameCount % 10 === 0) {
527
617
  pwLog(`🔄 FRAME ${frameCount}`, {
@@ -537,15 +627,19 @@ export const attachDrag = (el, opts) => {
537
627
  if ((rx.done || !stepX) && (ry.done || !stepY)) {
538
628
  pwLog('✅ REST REACHED', {
539
629
  frameCount,
540
- finalX: rx.value,
541
- finalY: ry.value,
630
+ finalX: nextX,
631
+ finalY: nextY,
542
632
  timeConstantMs,
543
633
  restDelta,
544
634
  restSpeed
545
635
  });
546
- // Ensure applied is synced to final values (setXY should have done this, but be explicit)
547
- const finalX = stepX ? rx.value : applied.x;
548
- const finalY = stepY ? ry.value : applied.y;
636
+ // Sync `applied` from the post-clamp frame values
637
+ // (nextX/nextY), not the raw stepper output. When
638
+ // element-ref constraints clamped this frame, raw
639
+ // rx.value sits outside the visible bounds and would
640
+ // desync the next-drag origin from the rendered transform.
641
+ const finalX = stepX ? nextX : applied.x;
642
+ const finalY = stepY ? nextY : applied.y;
549
643
  if (axis === true || axis === 'x')
550
644
  applied.x = finalX;
551
645
  if (axis === true || axis === 'y')
@@ -560,14 +654,12 @@ export const attachDrag = (el, opts) => {
560
654
  stopInertia = () => {
561
655
  pwLog('❌ MOMENTUM CANCELLED');
562
656
  running = false;
563
- // Sync applied to the current visual position so the next drag starts correctly
564
- // Use the last computed values from the steppers if available
565
- const finalX = stepX ? stepX(now() - startTs).value : applied.x;
566
- const finalY = stepY ? stepY(now() - startTs).value : applied.y;
567
- if (axis === true || axis === 'x')
568
- applied.x = finalX;
569
- if (axis === true || axis === 'y')
570
- applied.y = finalY;
657
+ // `applied` is already in sync with the last frame rendered
658
+ // by the rAF loop (setXY updates it on every frame). We
659
+ // intentionally do NOT call stepX/stepY again here
660
+ // they're stateful (mutate lastT/springX/springV) and an
661
+ // extra call advances them past the visible state, leaving
662
+ // applied slightly out of sync with what the user sees.
571
663
  pwLog('[drag] inertia cancelled → sync applied', {
572
664
  el: EL_ID,
573
665
  applied: { x: applied.x, y: applied.y }
@@ -624,6 +716,17 @@ export const attachDrag = (el, opts) => {
624
716
  const settleTransition = elastic === 0 ? { duration: 0 } : (mergedTransition ?? {});
625
717
  // Animate with the merged transition so the settle feels consistent with other motion
626
718
  const controls = animate(el, { ...(applyX ? { x } : {}), ...(applyY ? { y } : {}) }, settleTransition);
719
+ // Cancel hook so re-grab interrupts the settle animation cleanly.
720
+ stopInertia = () => {
721
+ pwLog('❌ settle (no momentum) cancelled');
722
+ controls.stop?.();
723
+ const { tx, ty } = parseMatrixTranslate(getComputedStyle(el).transform);
724
+ if (applyX)
725
+ applied.x = tx;
726
+ if (applyY)
727
+ applied.y = ty;
728
+ stopInertia = null;
729
+ };
627
730
  // Fire transition end once settled
628
731
  Promise.resolve(controls.finished)
629
732
  .then(() => {
@@ -636,6 +739,8 @@ export const attachDrag = (el, opts) => {
636
739
  el: EL_ID,
637
740
  applied
638
741
  });
742
+ if (stopInertia)
743
+ stopInertia = null;
639
744
  })
640
745
  .catch(() => { })
641
746
  .finally(() => opts.callbacks?.onTransitionEnd?.());
@@ -643,10 +748,12 @@ export const attachDrag = (el, opts) => {
643
748
  opts.callbacks?.onEnd?.(e, computeInfo());
644
749
  endWhileDrag();
645
750
  };
646
- // Wire dragControls
751
+ // Wire dragControls. The cancelInertia thunk reads the *current*
752
+ // stopInertia at call time so the latest in-flight animation is
753
+ // targeted (stopInertia is re-assigned per finishDrag).
647
754
  if (opts.controls) {
648
755
  const internal = opts.controls;
649
- internal._bind?.(el, beginDrag);
756
+ internal._bind?.(el, beginDrag, () => stopInertia?.());
650
757
  pwLog('[drag] controls bound', { el: EL_ID });
651
758
  }
652
759
  el.addEventListener('pointerdown', onPointerDown);
@@ -34,3 +34,21 @@ export type ConstraintElastic = {
34
34
  * ```
35
35
  */
36
36
  export declare const applyConstraints: (point: number, range: ConstraintRange, elastic?: ConstraintElastic) => number;
37
+ /**
38
+ * Parse a CSS `matrix(a, b, c, d, tx, ty)` string into translate components.
39
+ *
40
+ * Used to read the rendered translate after Motion writes inline transforms
41
+ * during drag-cancel hooks.
42
+ *
43
+ * @param transform The computed `transform` style — typically `'none'` or
44
+ * `'matrix(1, 0, 0, 1, 10, 20)'`. `matrix3d(...)` is not supported.
45
+ * @returns The 2D translate components `{ tx, ty }`. Defaults to
46
+ * `{ tx: 0, ty: 0 }` for `'none'` or any non-matching input.
47
+ * @example
48
+ * parseMatrixTranslate('matrix(1, 0, 0, 1, 10, 20)') // → { tx: 10, ty: 20 }
49
+ * parseMatrixTranslate('none') // → { tx: 0, ty: 0 }
50
+ */
51
+ export declare const parseMatrixTranslate: (transform: string) => {
52
+ tx: number;
53
+ ty: number;
54
+ };
@@ -42,3 +42,24 @@ export const applyConstraints = (point, range, elastic) => {
42
42
  }
43
43
  return point;
44
44
  };
45
+ /**
46
+ * Parse a CSS `matrix(a, b, c, d, tx, ty)` string into translate components.
47
+ *
48
+ * Used to read the rendered translate after Motion writes inline transforms
49
+ * during drag-cancel hooks.
50
+ *
51
+ * @param transform The computed `transform` style — typically `'none'` or
52
+ * `'matrix(1, 0, 0, 1, 10, 20)'`. `matrix3d(...)` is not supported.
53
+ * @returns The 2D translate components `{ tx, ty }`. Defaults to
54
+ * `{ tx: 0, ty: 0 }` for `'none'` or any non-matching input.
55
+ * @example
56
+ * parseMatrixTranslate('matrix(1, 0, 0, 1, 10, 20)') // → { tx: 10, ty: 20 }
57
+ * parseMatrixTranslate('none') // → { tx: 0, ty: 0 }
58
+ */
59
+ export const parseMatrixTranslate = (transform) => {
60
+ const m = transform.match(/matrix\(([^)]+)\)/);
61
+ if (!m)
62
+ return { tx: 0, ty: 0 };
63
+ const parts = m[1].split(',').map((s) => Number.parseFloat(s.trim()));
64
+ return { tx: parts[4] ?? 0, ty: parts[5] ?? 0 };
65
+ };
@@ -80,12 +80,13 @@ export const createInertiaToBoundary = (initial, bounds, opts) => {
80
80
  let springX = x;
81
81
  let springV = 0;
82
82
  let boundaryTarget = null;
83
- // If starting OOB, skip inertia
83
+ // Starting OOB: skip inertia and engage the spring. Drop the
84
+ // away-from-boundary velocity component so the spring's first frames
85
+ // don't continue moving the value outward.
84
86
  if (x < min || x > max) {
85
87
  mode = 'spring';
86
- // Set spring start at current value and carry current velocity
87
88
  springX = x;
88
- springV = v;
89
+ springV = x < min ? Math.max(0, v) : Math.min(0, v);
89
90
  boundaryTarget = x < min ? min : max;
90
91
  }
91
92
  // Precompute first crossing (if any) from initial state
package/dist/vite.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Parser } from 'acorn';
1
2
  /**
2
3
  * Tag-to-component name mapping. Each key is the lowercase HTML/SVG tag,
3
4
  * and the value is the PascalCase component filename (without .svelte).
@@ -318,16 +319,18 @@ export const svelteMotionOptimize = () => ({
318
319
  for (const [tag, localName] of tagToLocal) {
319
320
  const openRe = new RegExp(`<motion\\.${escapeRegExp(tag)}(?=[\\s/>])`, 'g');
320
321
  const closeRe = new RegExp(`</motion\\.${escapeRegExp(tag)}\\s*>`, 'g');
321
- const scriptRe = new RegExp(`\\bmotion\\.${escapeRegExp(tag)}\\b`, 'g');
322
322
  transformed = transformed.replace(openRe, `<${localName}`);
323
323
  transformed = transformed.replace(closeRe, `</${localName}>`);
324
- // Also replace script-block references (e.g., const Component = motion.div)
325
- // But only outside of the import statement we already handled
326
- const importEndIdx = transformed.indexOf(localName) + localName.length;
327
- const beforeImport = transformed.slice(0, importEndIdx);
328
- const afterImport = transformed.slice(importEndIdx);
329
- transformed = beforeImport + afterImport.replace(scriptRe, localName);
330
324
  }
325
+ // Rewrite `motion.TAG` JS references (e.g. `const Component = motion.div`)
326
+ // inside <script> blocks only. A naive regex over the script body would
327
+ // also clobber the same substring in string literals (`"motion.div"`)
328
+ // and comments (`// motion.div`). Parse the script as JS instead and
329
+ // only rewrite real `motion.<tag>` MemberExpressions. For scripts that
330
+ // fail to parse as plain JS (e.g. `<script lang="ts">`), fall back to a
331
+ // string/comment-aware lexer that achieves the same correctness without
332
+ // needing a TS parser.
333
+ transformed = transformed.replace(/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/g, (_full, open, content, close) => open + rewriteMotionRefsInScript(content, tagToLocal) + close);
331
334
  return {
332
335
  code: transformed,
333
336
  map: null
@@ -341,3 +344,169 @@ export const svelteMotionOptimize = () => ({
341
344
  * @returns The escaped string safe for use in a RegExp.
342
345
  */
343
346
  const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
347
+ /**
348
+ * Rewrite `motion.<tag>` member-expression references inside a `<script>`
349
+ * body to the matching `SvelteMotionTag` local. Preserves string literals
350
+ * and comments — they look like `motion.div` to a regex but must not be
351
+ * rewritten.
352
+ *
353
+ * Strategy: parse the body as JS with acorn and splice only real
354
+ * MemberExpression matches. If parsing fails (TypeScript, JSX, etc.) fall
355
+ * back to a string/comment-aware lexer that skips literals and comments.
356
+ *
357
+ * @param content - Raw script body (between `<script ...>` and `</script>`).
358
+ * @param tagToLocal - Map of lowercase tag → local component identifier.
359
+ * @returns The rewritten body, ready to splice back into the source.
360
+ */
361
+ const rewriteMotionRefsInScript = (content, tagToLocal) => {
362
+ try {
363
+ return rewriteViaAst(content, tagToLocal);
364
+ }
365
+ catch {
366
+ return rewriteViaLexer(content, tagToLocal);
367
+ }
368
+ };
369
+ const isIdentifier = (n) => !!n && n.type === 'Identifier';
370
+ const isMemberExpression = (n) => !!n && n.type === 'MemberExpression';
371
+ /**
372
+ * Walk an acorn AST and collect every `motion.<tag>` MemberExpression range
373
+ * we should rewrite. Splice from end to start so earlier indices stay valid.
374
+ */
375
+ const rewriteViaAst = (content, tagToLocal) => {
376
+ const ast = Parser.parse(content, {
377
+ ecmaVersion: 'latest',
378
+ sourceType: 'module',
379
+ allowImportExportEverywhere: true,
380
+ allowReturnOutsideFunction: true,
381
+ allowAwaitOutsideFunction: true,
382
+ allowHashBang: true
383
+ });
384
+ const edits = [];
385
+ const visit = (node) => {
386
+ if (!node || typeof node !== 'object' || typeof node.type !== 'string')
387
+ return;
388
+ if (isMemberExpression(node) &&
389
+ !node.computed &&
390
+ isIdentifier(node.object) &&
391
+ node.object.name === 'motion' &&
392
+ isIdentifier(node.property)) {
393
+ const localName = tagToLocal.get(node.property.name);
394
+ if (localName) {
395
+ edits.push({ start: node.start, end: node.end, replacement: localName });
396
+ return;
397
+ }
398
+ }
399
+ for (const key of Object.keys(node)) {
400
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
401
+ continue;
402
+ const value = node[key];
403
+ if (Array.isArray(value))
404
+ value.forEach((v) => visit(v));
405
+ else if (value && typeof value === 'object')
406
+ visit(value);
407
+ }
408
+ };
409
+ visit(ast);
410
+ if (edits.length === 0)
411
+ return content;
412
+ edits.sort((a, b) => b.start - a.start);
413
+ let out = content;
414
+ for (const edit of edits) {
415
+ out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
416
+ }
417
+ return out;
418
+ };
419
+ /**
420
+ * Fallback for scripts acorn can't parse (TS, JSX). Walks the source
421
+ * character-by-character, skipping string literals (`'`, `"`, backtick incl.
422
+ * `${…}` substitutions) and line/block comments, then applies a `motion.<tag>`
423
+ * regex to the remaining "code" regions. Less precise than AST but covers
424
+ * the same correctness contract for literal/comment preservation.
425
+ */
426
+ const rewriteViaLexer = (content, tagToLocal) => {
427
+ const len = content.length;
428
+ const out = [];
429
+ let i = 0;
430
+ const isIdStart = (ch) => /[A-Za-z_$]/.test(ch);
431
+ const isIdPart = (ch) => /[A-Za-z0-9_$-]/.test(ch);
432
+ while (i < len) {
433
+ const ch = content[i];
434
+ const next = content[i + 1];
435
+ // Line comment
436
+ if (ch === '/' && next === '/') {
437
+ const end = content.indexOf('\n', i);
438
+ const stop = end === -1 ? len : end;
439
+ out.push(content.slice(i, stop));
440
+ i = stop;
441
+ continue;
442
+ }
443
+ // Block comment
444
+ if (ch === '/' && next === '*') {
445
+ const end = content.indexOf('*/', i + 2);
446
+ const stop = end === -1 ? len : end + 2;
447
+ out.push(content.slice(i, stop));
448
+ i = stop;
449
+ continue;
450
+ }
451
+ // String literals (single/double)
452
+ if (ch === '"' || ch === "'") {
453
+ const quote = ch;
454
+ let j = i + 1;
455
+ while (j < len) {
456
+ if (content[j] === '\\') {
457
+ j += 2;
458
+ continue;
459
+ }
460
+ if (content[j] === quote) {
461
+ j++;
462
+ break;
463
+ }
464
+ j++;
465
+ }
466
+ out.push(content.slice(i, j));
467
+ i = j;
468
+ continue;
469
+ }
470
+ // Template literal — naive: skip to matching backtick, no `${…}` parsing
471
+ // is needed for our use case (we only need to NOT rewrite the literal
472
+ // text; substitutions still look like code but `motion.<tag>` inside
473
+ // a template substitution is vanishingly rare and acorn would normally
474
+ // handle it).
475
+ if (ch === '`') {
476
+ let j = i + 1;
477
+ while (j < len) {
478
+ if (content[j] === '\\') {
479
+ j += 2;
480
+ continue;
481
+ }
482
+ if (content[j] === '`') {
483
+ j++;
484
+ break;
485
+ }
486
+ j++;
487
+ }
488
+ out.push(content.slice(i, j));
489
+ i = j;
490
+ continue;
491
+ }
492
+ // Possible `motion.<tag>` identifier — require word boundary on left
493
+ if ((i === 0 || !isIdPart(content[i - 1])) &&
494
+ isIdStart(ch) &&
495
+ content.slice(i, i + 7) === 'motion.') {
496
+ let j = i + 7;
497
+ const tagStart = j;
498
+ while (j < len && isIdPart(content[j]))
499
+ j++;
500
+ const tag = content.slice(tagStart, j);
501
+ const localName = tagToLocal.get(tag);
502
+ if (localName && (j === len || !isIdPart(content[j]))) {
503
+ out.push(localName);
504
+ i = j;
505
+ continue;
506
+ }
507
+ }
508
+ out.push(ch);
509
+ i++;
510
+ }
511
+ return out.join('');
512
+ };
package/package.json CHANGED
@@ -1,29 +1,52 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.3.5",
4
- "description": "A Framer Motion-compatible animation library for Svelte 5 that provides smooth, hardware-accelerated animations. Features include spring physics, custom easing, and fluid transitions. Built on top of the motion library, it offers a simple API for creating complex animations with minimal code. Perfect for interactive UIs, micro-interactions, and engaging user experiences.",
3
+ "version": "0.4.0",
4
+ "description": "Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values. The drop-in Framer Motion alternative for Svelte and SvelteKit.",
5
5
  "keywords": [
6
6
  "svelte",
7
+ "svelte-5",
8
+ "svelte5",
9
+ "sveltekit",
10
+ "svelte-motion",
11
+ "motion-svelte",
12
+ "svelte-animation",
13
+ "svelte-animations",
14
+ "svelte-animation-library",
7
15
  "animation",
16
+ "animations",
8
17
  "motion",
9
- "transitions",
10
- "spring-physics",
11
- "ui-animation",
12
- "svelte5",
13
- "hardware-accelerated",
14
- "micro-interactions",
15
- "performance",
16
18
  "framer-motion",
17
19
  "framer-motion-svelte",
20
+ "framer-motion-for-svelte",
21
+ "framer-motion-alternative",
22
+ "transitions",
18
23
  "gestures",
19
- "exit-animation",
20
- "animate-presence",
21
- "layout-animation",
22
24
  "drag",
25
+ "drag-and-drop",
26
+ "hover",
27
+ "tap",
28
+ "while-in-view",
23
29
  "variants",
24
- "sveltekit",
30
+ "animate-presence",
31
+ "exit-animation",
32
+ "layout-animation",
33
+ "shared-layout",
34
+ "flip-animation",
35
+ "spring",
36
+ "spring-physics",
37
+ "scroll-animation",
38
+ "scroll-linked",
39
+ "use-spring",
40
+ "use-scroll",
41
+ "use-animate",
42
+ "use-transform",
43
+ "use-in-view",
44
+ "use-presence",
45
+ "motion-values",
46
+ "ssr",
25
47
  "typescript",
26
- "svelte-animation"
48
+ "ui-animation",
49
+ "micro-interactions"
27
50
  ],
28
51
  "homepage": "https://motion.svelte.page",
29
52
  "bugs": {
@@ -71,59 +94,60 @@
71
94
  }
72
95
  },
73
96
  "dependencies": {
97
+ "acorn": "^8.16.0",
74
98
  "motion": "^12.38.0",
75
99
  "motion-dom": "^12.38.0"
76
100
  },
77
101
  "devDependencies": {
78
102
  "@changesets/cli": "^2.31.0",
79
- "@eslint/compat": "^2.0.5",
103
+ "@eslint/compat": "^2.1.0",
80
104
  "@eslint/js": "^10.0.1",
81
- "@playwright/test": "^1.59.1",
105
+ "@playwright/test": "^1.60.0",
82
106
  "@sveltejs/adapter-auto": "^7.0.1",
83
- "@sveltejs/kit": "^2.58.0",
107
+ "@sveltejs/kit": "^2.60.1",
84
108
  "@sveltejs/package": "^2.5.7",
85
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
109
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
86
110
  "@tailwindcss/aspect-ratio": "^0.4.2",
87
111
  "@tailwindcss/container-queries": "^0.1.1",
88
112
  "@tailwindcss/forms": "^0.5.11",
89
113
  "@tailwindcss/typography": "^0.5.19",
90
- "@tailwindcss/vite": "^4.2.4",
114
+ "@tailwindcss/vite": "^4.3.0",
91
115
  "@testing-library/jest-dom": "^6.9.1",
92
116
  "@testing-library/svelte": "^5.3.1",
93
- "@types/node": "^25.6.0",
94
- "@vitest/coverage-v8": "^4.1.5",
95
- "eslint": "^10.2.1",
117
+ "@types/node": "^25.8.0",
118
+ "@vitest/coverage-v8": "^4.1.6",
119
+ "eslint": "^10.4.0",
96
120
  "eslint-config-prettier": "10.1.8",
97
121
  "eslint-plugin-import": "2.32.0",
98
122
  "eslint-plugin-svelte": "3.17.1",
99
123
  "eslint-plugin-unused-imports": "4.4.1",
100
124
  "esm-env": "^1.2.2",
101
- "globals": "^17.5.0",
125
+ "globals": "^17.6.0",
102
126
  "html-tags": "^5.1.0",
103
127
  "html-void-elements": "^3.0.0",
104
128
  "husky": "^9.1.7",
105
- "jsdom": "^29.0.2",
129
+ "jsdom": "^29.1.1",
106
130
  "mprocs": "^0.9.2",
107
131
  "prettier": "^3.8.3",
108
132
  "prettier-plugin-organize-imports": "^4.3.0",
109
133
  "prettier-plugin-sort-json": "^4.2.0",
110
- "prettier-plugin-svelte": "^3.5.1",
111
- "prettier-plugin-tailwindcss": "^0.7.3",
112
- "publint": "^0.3.18",
134
+ "prettier-plugin-svelte": "^3.5.2",
135
+ "prettier-plugin-tailwindcss": "^0.8.0",
136
+ "publint": "^0.3.21",
113
137
  "runed": "0.37.1",
114
- "svelte": "^5.55.5",
115
- "svelte-check": "^4.4.6",
138
+ "svelte": "5.55.5",
139
+ "svelte-check": "^4.4.8",
116
140
  "svg-tags": "^1.0.0",
117
- "tailwind-merge": "^3.5.0",
141
+ "tailwind-merge": "^3.6.0",
118
142
  "tailwind-variants": "^3.2.2",
119
- "tailwindcss": "^4.2.4",
143
+ "tailwindcss": "^4.3.0",
120
144
  "tailwindcss-animate": "^1.0.7",
121
- "tsx": "^4.21.0",
145
+ "tsx": "^4.22.0",
122
146
  "typescript": "^6.0.3",
123
- "typescript-eslint": "^8.59.0",
124
- "vite": "^8.0.10",
147
+ "typescript-eslint": "^8.59.3",
148
+ "vite": "^8.0.13",
125
149
  "vite-tsconfig-paths": "^6.1.1",
126
- "vitest": "^4.1.5"
150
+ "vitest": "^4.1.6"
127
151
  },
128
152
  "peerDependencies": {
129
153
  "svelte": "^5.0.0"
@@ -134,17 +158,6 @@
134
158
  "publishConfig": {
135
159
  "access": "public"
136
160
  },
137
- "tags": [
138
- "svelte",
139
- "animation",
140
- "motion",
141
- "transitions",
142
- "spring-physics",
143
- "performance",
144
- "ui-animation",
145
- "micro-interactions",
146
- "svelte5"
147
- ],
148
161
  "scripts": {
149
162
  "build": "vite build && npm run package",
150
163
  "cf-typegen": "pnpm --filter docs cf-typegen",