@humanspeak/svelte-motion 0.5.4 → 0.6.1

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.
@@ -0,0 +1,311 @@
1
+ import { mergeInlineStyles } from './style';
2
+ import { resolveRestingValues } from './variants';
3
+ import { startWaapiAnimation } from 'motion';
4
+ import { mapEasingToNativeEasing, optimizedAppearDataAttribute, optimizedAppearDataId, transformProps } from 'motion-dom';
5
+ const appearStoreId = (elementId, valueName) => {
6
+ const key = transformProps.has(valueName) ? 'transform' : valueName;
7
+ return `${elementId}: ${key}`;
8
+ };
9
+ const getAppearStore = () => {
10
+ if (typeof window === 'undefined')
11
+ return undefined;
12
+ window.__SvelteMotionAppear ??= {
13
+ animations: new Map(),
14
+ complete: new Map(),
15
+ started: []
16
+ };
17
+ return window.__SvelteMotionAppear;
18
+ };
19
+ const installAppearGlobals = () => {
20
+ if (typeof window === 'undefined')
21
+ return;
22
+ const store = getAppearStore();
23
+ if (!store)
24
+ return;
25
+ window.MotionHasOptimisedAnimation ??= (elementId, valueName) => {
26
+ if (!elementId)
27
+ return false;
28
+ if (!valueName)
29
+ return store.complete.has(elementId);
30
+ return store.animations.has(appearStoreId(elementId, valueName));
31
+ };
32
+ window.MotionHandoffMarkAsComplete ??= (elementId) => {
33
+ if (store.complete.has(elementId)) {
34
+ store.complete.set(elementId, true);
35
+ }
36
+ };
37
+ window.MotionHandoffIsComplete ??= (elementId) => {
38
+ return store.complete.get(elementId) === true;
39
+ };
40
+ window.MotionCancelOptimisedAnimation ??= (elementId, valueName) => {
41
+ if (!elementId || !valueName)
42
+ return;
43
+ const animationId = appearStoreId(elementId, valueName);
44
+ const data = store.animations.get(animationId);
45
+ if (!data)
46
+ return;
47
+ data.animation.cancel();
48
+ store.animations.delete(animationId);
49
+ if (!store.animations.size) {
50
+ window.MotionCancelOptimisedAnimation = undefined;
51
+ }
52
+ };
53
+ };
54
+ const readStyleProp = (style, prop) => {
55
+ return style
56
+ .split(';')
57
+ .map((part) => part.trim())
58
+ .find((part) => part.startsWith(`${prop}:`))
59
+ ?.slice(prop.length + 1)
60
+ .trim();
61
+ };
62
+ const toNativeOptions = (transition) => {
63
+ const duration = typeof transition?.duration === 'number' ? transition.duration : 0.3;
64
+ const delay = typeof transition?.delay === 'number' ? transition.delay : 0;
65
+ const durationMs = duration * 1000;
66
+ const options = {
67
+ duration: durationMs,
68
+ delay: delay * 1000,
69
+ fill: 'both'
70
+ };
71
+ const easing = mapEasingToNativeEasing(transition?.ease, durationMs);
72
+ if (Array.isArray(easing)) {
73
+ options.easing = easing[0] ?? 'linear';
74
+ }
75
+ else if (easing) {
76
+ options.easing = easing;
77
+ }
78
+ return options;
79
+ };
80
+ /**
81
+ * Build serialisable optimized-appear animation entries from an initial and
82
+ * animate pair.
83
+ *
84
+ * @param initial Initial keyframes reflected into SSR markup.
85
+ * @param animate Target keyframes for the enter animation.
86
+ * @param transition Motion transition options.
87
+ * @returns Appear entries for WAAPI-supported opacity and transform values.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const entries = createOptimizedAppearData(
92
+ * { opacity: 0, scale: 0.8 },
93
+ * { opacity: 1, scale: 1 },
94
+ * { duration: 0.6, ease: [0.16, 1, 0.3, 1] }
95
+ * )
96
+ * ```
97
+ */
98
+ export const createOptimizedAppearData = (initial, animate, transition) => {
99
+ if (!initial || !animate)
100
+ return [];
101
+ const target = resolveRestingValues(animate);
102
+ if (!target)
103
+ return [];
104
+ const options = toNativeOptions(transition);
105
+ const entries = [];
106
+ if (initial.opacity != null && target.opacity != null) {
107
+ entries.push({
108
+ name: 'opacity',
109
+ keyframes: [
110
+ Array.isArray(initial.opacity) ? initial.opacity[0] : initial.opacity,
111
+ Array.isArray(target.opacity) ? target.opacity[0] : target.opacity
112
+ ],
113
+ options
114
+ });
115
+ }
116
+ const initialTransform = readStyleProp(mergeInlineStyles('', initial), 'transform');
117
+ const targetTransform = readStyleProp(mergeInlineStyles('', target), 'transform');
118
+ if (initialTransform && targetTransform && initialTransform !== targetTransform) {
119
+ entries.push({
120
+ name: 'transform',
121
+ keyframes: [initialTransform, targetTransform],
122
+ options
123
+ });
124
+ }
125
+ return entries;
126
+ };
127
+ /**
128
+ * Create the inline SSR bootstrap that starts appear animations before Svelte
129
+ * hydrates the component tree.
130
+ *
131
+ * @param appearId Stable optimized-appear id attached to the motion element.
132
+ * @param entries WAAPI animation entries to start.
133
+ * @returns A script tag string, or an empty string when no entries exist.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * const script = createOptimizedAppearScript('appear-1', [
138
+ * { name: 'opacity', keyframes: [0, 1], options: { duration: 300, fill: 'both' } }
139
+ * ])
140
+ * ```
141
+ */
142
+ export const createOptimizedAppearScript = (appearId, entries) => {
143
+ if (!appearId || entries.length === 0)
144
+ return '';
145
+ const payload = JSON.stringify({ id: appearId, entries }).replace(/</g, '\\u003c');
146
+ return `<script>(()=>{const p=${payload},w=window;if(w.MotionIsMounted)return;const q=String(p.id).replace(/["\\\\]/g,"\\\\$&");const e=document.querySelector('[${optimizedAppearDataAttribute}="'+q+'"]');if(!e||!e.animate)return;const s=w.__SvelteMotionAppear||(w.__SvelteMotionAppear={animations:new Map,complete:new Map,started:[]});const k=(id,n)=>id+": "+(n==="transform"?"transform":n);w.MotionHasOptimisedAnimation=w.MotionHasOptimisedAnimation||((id,n)=>id?n?s.animations.has(k(id,n)):s.complete.has(id):false);w.MotionHandoffMarkAsComplete=w.MotionHandoffMarkAsComplete||((id)=>{if(s.complete.has(id))s.complete.set(id,true)});w.MotionHandoffIsComplete=w.MotionHandoffIsComplete||((id)=>s.complete.get(id)===true);w.MotionCancelOptimisedAnimation=w.MotionCancelOptimisedAnimation||((id,n)=>{const key=k(id,n),d=s.animations.get(key);if(!d)return;d.animation.cancel();s.animations.delete(key);if(!s.animations.size)w.MotionCancelOptimisedAnimation=undefined});s.complete.set(p.id,false);for(const a of p.entries){const key=k(p.id,a.name);if(!s.readyAnimation){s.readyAnimation=e.animate({[a.name]:[a.keyframes[0],a.keyframes[0]]},{duration:1e4,easing:"linear",fill:"both"});s.animations.set(key,{animation:s.readyAnimation,startTime:null})}const start=()=>{s.readyAnimation.cancel();let t=s.startFrameTime;if(t===undefined){t=performance.now();s.startFrameTime=t}const anim=e.animate({[a.name]:a.keyframes},a.options);anim.startTime=t;s.animations.set(key,{animation:anim,startTime:t});s.started.push({id:p.id,name:a.name})};const r=s.readyAnimation;r.ready?r.ready.then(start).catch(()=>{}):start()}})();</script>`;
147
+ };
148
+ /**
149
+ * Start an optimized appear animation imperatively.
150
+ *
151
+ * Mirrors Framer Motion's `startOptimizedAppearAnimation`: if Motion has
152
+ * already mounted, this intentionally does nothing.
153
+ *
154
+ * @param element Element carrying `data-framer-appear-id`.
155
+ * @param name CSS property to animate.
156
+ * @param keyframes WAAPI keyframes for the property.
157
+ * @param options Motion animation options.
158
+ * @param onReady Optional callback receiving the started animation.
159
+ * @returns Nothing.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * const element = document.querySelector('[data-framer-appear-id]')
164
+ * if (element instanceof HTMLElement) {
165
+ * startOptimizedAppearAnimation(element, 'opacity', [0, 1], { duration: 0.3 })
166
+ * }
167
+ * ```
168
+ */
169
+ export const startOptimizedAppearAnimation = (element, name, keyframes, options, onReady) => {
170
+ if (typeof window === 'undefined' || window.MotionIsMounted)
171
+ return;
172
+ const id = element.dataset[optimizedAppearDataId];
173
+ if (!id)
174
+ return;
175
+ installAppearGlobals();
176
+ const store = getAppearStore();
177
+ if (!store)
178
+ return;
179
+ const storeId = appearStoreId(id, name);
180
+ if (!store.readyAnimation) {
181
+ store.readyAnimation = startWaapiAnimation(element, name, [keyframes[0], keyframes[0]], {
182
+ duration: 10000,
183
+ ease: 'linear'
184
+ });
185
+ store.animations.set(storeId, { animation: store.readyAnimation, startTime: null });
186
+ }
187
+ const startAnimation = () => {
188
+ store.readyAnimation?.cancel();
189
+ store.startFrameTime ??= performance.now();
190
+ const animation = startWaapiAnimation(element, name, keyframes, options);
191
+ animation.startTime = store.startFrameTime;
192
+ store.animations.set(storeId, { animation, startTime: store.startFrameTime });
193
+ store.started.push({ id, name });
194
+ onReady?.(animation);
195
+ };
196
+ store.complete.set(id, false);
197
+ const readyAnimation = store.readyAnimation;
198
+ if (readyAnimation.ready) {
199
+ readyAnimation.ready.then(startAnimation).catch(() => { });
200
+ }
201
+ else {
202
+ startAnimation();
203
+ }
204
+ };
205
+ /**
206
+ * Commit and cancel optimized appear animations for an element.
207
+ *
208
+ * @param elementId Optimized appear id.
209
+ * @returns `true` when at least one optimized animation was handed off.
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * const wasHandedOff = handoffOptimizedAppearAnimation('appear-1')
214
+ * if (wasHandedOff) {
215
+ * console.log('Animation handed off to runtime')
216
+ * }
217
+ * ```
218
+ */
219
+ export const handoffOptimizedAppearAnimation = (elementId) => {
220
+ if (!elementId || typeof window === 'undefined')
221
+ return false;
222
+ const store = getAppearStore();
223
+ if (!store)
224
+ return false;
225
+ let handedOff = false;
226
+ for (const [key, data] of [...store.animations]) {
227
+ if (!key.startsWith(`${elementId}: `))
228
+ continue;
229
+ data.animation.commitStyles?.();
230
+ data.animation.cancel();
231
+ store.animations.delete(key);
232
+ handedOff = true;
233
+ }
234
+ if (store.complete.has(elementId)) {
235
+ store.complete.set(elementId, true);
236
+ }
237
+ return handedOff;
238
+ };
239
+ /**
240
+ * Let active optimized appear animations finish before handing their final
241
+ * styles back to Svelte Motion.
242
+ *
243
+ * @param elementId Optimized appear id.
244
+ * @returns Whether at least one optimized animation was adopted.
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * const wasAdopted = await finishOptimizedAppearAnimation('appear-1')
249
+ * if (wasAdopted) {
250
+ * console.log('Animation finished and adopted')
251
+ * }
252
+ * ```
253
+ */
254
+ export const finishOptimizedAppearAnimation = async (elementId) => {
255
+ if (!elementId || typeof window === 'undefined')
256
+ return false;
257
+ const store = getAppearStore();
258
+ if (!store)
259
+ return false;
260
+ let entries = [...store.animations].filter(([key]) => key.startsWith(`${elementId}: `));
261
+ if (!entries.length)
262
+ return false;
263
+ await Promise.all(entries.map(([, data]) => data.startTime === null ? data.animation.ready?.catch(() => undefined) : undefined));
264
+ entries = [...store.animations].filter(([key]) => key.startsWith(`${elementId}: `));
265
+ await Promise.all(entries.map(([, data]) => data.animation.finished.catch(() => undefined)));
266
+ for (const [key, data] of entries) {
267
+ if (!store.animations.has(key))
268
+ continue;
269
+ data.animation.commitStyles?.();
270
+ data.animation.cancel();
271
+ store.animations.delete(key);
272
+ }
273
+ if (store.complete.has(elementId)) {
274
+ store.complete.set(elementId, true);
275
+ }
276
+ return true;
277
+ };
278
+ /**
279
+ * Check whether an optimized appear animation is active for an element.
280
+ *
281
+ * @param elementId Optimized appear id.
282
+ * @returns Whether any optimized appear animation is currently registered.
283
+ *
284
+ * @example
285
+ * ```ts
286
+ * if (hasOptimizedAppearAnimation('appear-1')) {
287
+ * console.log('Animation is active')
288
+ * }
289
+ * ```
290
+ */
291
+ export const hasOptimizedAppearAnimation = (elementId) => {
292
+ if (!elementId || typeof window === 'undefined')
293
+ return false;
294
+ return window.MotionHasOptimisedAnimation?.(elementId) ?? false;
295
+ };
296
+ /**
297
+ * Mark Motion as mounted so late optimized-appear starters no-op.
298
+ *
299
+ * @returns Nothing.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * markMotionMounted()
304
+ * ```
305
+ */
306
+ export const markMotionMounted = () => {
307
+ if (typeof window !== 'undefined') {
308
+ window.MotionIsMounted = true;
309
+ }
310
+ };
311
+ export { optimizedAppearDataAttribute };
@@ -16,10 +16,11 @@ export type AnimatePresenceContext = {
16
16
  */
17
17
  shouldAnimateEnter: (key: string) => boolean;
18
18
  /**
19
- * For mode='wait': Returns true if enters should be blocked (exits in progress).
19
+ * For mode='wait': Returns true if enters should be blocked by an exiting
20
+ * or currently-present sibling.
20
21
  * Motion elements should delay their enter animation until this returns false.
21
22
  */
22
- isEnterBlocked: () => boolean;
23
+ isEnterBlocked: (key?: string) => boolean;
23
24
  /**
24
25
  * For mode='wait': Register a callback to be invoked when enters are unblocked.
25
26
  * Returns an unsubscribe function.
@@ -31,6 +31,15 @@ const resetTransforms = (element) => {
31
31
  s.MozTransform = 'none';
32
32
  s.OTransform = 'none';
33
33
  };
34
+ const findLayoutInsertionParent = (element) => {
35
+ let before = element;
36
+ let parent = element.parentElement;
37
+ while (parent && getComputedStyle(parent).display === 'contents') {
38
+ before = parent;
39
+ parent = parent.parentElement;
40
+ }
41
+ return parent ? { parent, before } : null;
42
+ };
34
43
  /**
35
44
  * Create a new `AnimatePresence` context instance.
36
45
  *
@@ -130,10 +139,20 @@ export const createAnimatePresenceContext = (context) => {
130
139
  * }
131
140
  * ```
132
141
  */
133
- const isEnterBlocked = () => {
134
- // For wait mode, block enters only when there are actual in-flight exits
135
- const blocked = mode === 'wait' && inFlightExits > 0;
136
- pwLog('[presence] isEnterBlocked', { blocked, mode, inFlightExits });
142
+ const isEnterBlocked = (key) => {
143
+ if (mode !== 'wait') {
144
+ pwLog('[presence] isEnterBlocked', { blocked: false, mode, inFlightExits, key });
145
+ return false;
146
+ }
147
+ const hasBlockingSibling = key !== undefined && Array.from(children.keys()).some((childKey) => childKey !== key);
148
+ const blocked = inFlightExits > 0 || hasBlockingSibling;
149
+ pwLog('[presence] isEnterBlocked', {
150
+ blocked,
151
+ mode,
152
+ inFlightExits,
153
+ key,
154
+ hasBlockingSibling
155
+ });
137
156
  return blocked;
138
157
  };
139
158
  /**
@@ -286,7 +305,8 @@ export const createAnimatePresenceContext = (context) => {
286
305
  exit,
287
306
  mergedTransition,
288
307
  lastRect: initialRect,
289
- lastComputedStyle: initialStyle
308
+ lastComputedStyle: initialStyle,
309
+ layoutInsertion: findLayoutInsertionParent(element) ?? undefined
290
310
  });
291
311
  };
292
312
  /**
@@ -337,12 +357,14 @@ export const createAnimatePresenceContext = (context) => {
337
357
  }
338
358
  const rect = child.lastRect;
339
359
  const computed = child.lastComputedStyle;
340
- // For sync/wait, preserve layout by inserting a hidden placeholder.
341
- // For popLayout, we remove from layout immediately (no placeholder).
342
- const shouldPreserveLayout = mode !== 'popLayout';
360
+ // Wait mode holds the exiting layout slot while the entering child is hidden.
361
+ // Sync and popLayout must not add an extra layout participant.
362
+ const shouldPreserveLayout = mode === 'wait';
343
363
  let placeholder = null;
344
- const layoutParent = child.element.parentElement;
345
- if (shouldPreserveLayout && layoutParent) {
364
+ const liveLayoutInsertion = findLayoutInsertionParent(child.element);
365
+ const layoutInsertion = liveLayoutInsertion ??
366
+ (child.layoutInsertion?.parent.isConnected ? child.layoutInsertion : null);
367
+ if (shouldPreserveLayout && layoutInsertion) {
346
368
  placeholder = document.createElement(child.element.tagName.toLowerCase());
347
369
  placeholder.setAttribute('data-presence-placeholder', 'true');
348
370
  placeholder.style.display = computed.display === 'contents' ? 'block' : computed.display;
@@ -359,7 +381,22 @@ export const createAnimatePresenceContext = (context) => {
359
381
  if (computed.alignSelf) {
360
382
  placeholder.style.alignSelf = computed.alignSelf;
361
383
  }
362
- layoutParent.insertBefore(placeholder, child.element);
384
+ if (computed.gridColumnStart) {
385
+ placeholder.style.gridColumnStart = computed.gridColumnStart;
386
+ }
387
+ if (computed.gridColumnEnd) {
388
+ placeholder.style.gridColumnEnd = computed.gridColumnEnd;
389
+ }
390
+ if (computed.gridRowStart) {
391
+ placeholder.style.gridRowStart = computed.gridRowStart;
392
+ }
393
+ if (computed.gridRowEnd) {
394
+ placeholder.style.gridRowEnd = computed.gridRowEnd;
395
+ }
396
+ const before = layoutInsertion.before?.parentElement === layoutInsertion.parent
397
+ ? layoutInsertion.before
398
+ : null;
399
+ layoutInsertion.parent.insertBefore(placeholder, before);
363
400
  }
364
401
  // Clone original node to preserve structure/classes, then inline computed styles to freeze look
365
402
  const clone = child.element.cloneNode(true);
@@ -485,7 +522,6 @@ export const createAnimatePresenceContext = (context) => {
485
522
  // ignore
486
523
  }
487
524
  clone.remove();
488
- placeholder?.remove();
489
525
  // Log clone removal and element counts for debugging rapid toggle
490
526
  pwLog('[presence] clone REMOVED from DOM', {
491
527
  key,
@@ -514,6 +550,7 @@ export const createAnimatePresenceContext = (context) => {
514
550
  inFlightExits: inFlightExits - 1,
515
551
  clonesInDOM: document.querySelectorAll('[data-clone="true"]').length
516
552
  });
553
+ placeholder?.remove();
517
554
  finishExit();
518
555
  });
519
556
  });
@@ -78,9 +78,9 @@ export interface ProjectionNodeOptions {
78
78
  /**
79
79
  * Thunk returning the `layoutScroll` ancestor chain at measure
80
80
  * time. Used as the second argument to `measureRect`, which
81
- * shifts the returned rect by the sum of ancestor
82
- * `scrollLeft`/`scrollTop` so FLIP deltas stay correct when
83
- * scrollable ancestors scroll between two measurements.
81
+ * shifts the returned rect by the viewport scroll plus the sum of
82
+ * ancestor `scrollLeft`/`scrollTop` so FLIP deltas stay correct
83
+ * when the page or scrollable ancestors scroll between two measurements.
84
84
  *
85
85
  * Returning `[]` (or omitting the option entirely) gives
86
86
  * viewport-relative measurements — fine for the common case.
@@ -236,7 +236,7 @@ export class ProjectionNode {
236
236
  for (const { el, base } of restoreList)
237
237
  el.style.transform = base;
238
238
  // measureRect applies self's base transform + scroll-container offset.
239
- const rect = measureRect(this.element, this.getScrollContainers?.() ?? [], this.resolveBaseTransform());
239
+ const rect = measureRect(this.element, this.getScrollContainers?.() ?? [], this.resolveBaseTransform(), true);
240
240
  const box = rectToBox(rect);
241
241
  this.latestLayout = box;
242
242
  // Clone for the event: `box` aliases `this.latestLayout`, so a
@@ -69,8 +69,8 @@ export declare const isSVGElement: (element: Element) => element is SVGElement;
69
69
  *
70
70
  * Normalized behavior (React/Framer Motion parity):
71
71
  * - Ensures `pathLength="1"` is set when any path prop is present
72
- * - Maps `pathLength`/`pathSpacing` → `stroke-dasharray` (px)
73
- * - Maps `pathOffset` → `stroke-dashoffset` (negative px)
72
+ * - Maps `pathLength`/`pathSpacing` → unitless `stroke-dasharray`
73
+ * - Maps `pathOffset` → unitless negative `stroke-dashoffset`
74
74
  *
75
75
  * @param {Element} element The element being animated (must be an SVG path).
76
76
  * @param {Record<string, unknown>} keyframes The input keyframes possibly containing path props.
@@ -105,8 +105,8 @@ export declare const transformInitialSVGPathProperties: (element: Element, initi
105
105
  *
106
106
  * Behavior matches React/Framer Motion parity:
107
107
  * - Always sets pathLength="1" whenever any of path props are present
108
- * - stroke-dasharray = px(pathLength) + ' ' + px(pathSpacing ?? 1 - Number(pathLength))
109
- * - stroke-dashoffset = px(-(pathOffset ?? 0))
108
+ * - stroke-dasharray = pathLength + ' ' + (pathSpacing ?? 1)
109
+ * - stroke-dashoffset = -(pathOffset ?? 0)
110
110
  *
111
111
  * The returned object is suitable for direct DOM attribute assignment (dash-cased keys).
112
112
  *
package/dist/utils/svg.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildSVGPath } from 'motion-dom';
1
2
  /**
2
3
  * SVG-specific properties that need special handling during animation.
3
4
  * These properties are not standard CSS properties and need to be transformed.
@@ -145,8 +146,8 @@ export const isSVGElement = (element) => {
145
146
  *
146
147
  * Normalized behavior (React/Framer Motion parity):
147
148
  * - Ensures `pathLength="1"` is set when any path prop is present
148
- * - Maps `pathLength`/`pathSpacing` → `stroke-dasharray` (px)
149
- * - Maps `pathOffset` → `stroke-dashoffset` (negative px)
149
+ * - Maps `pathLength`/`pathSpacing` → unitless `stroke-dasharray`
150
+ * - Maps `pathOffset` → unitless negative `stroke-dashoffset`
150
151
  *
151
152
  * @param {Element} element The element being animated (must be an SVG path).
152
153
  * @param {Record<string, unknown>} keyframes The input keyframes possibly containing path props.
@@ -189,46 +190,65 @@ export const transformSVGPathProperties = (element, keyframes) => {
189
190
  const n = toNum(v);
190
191
  return n ?? v;
191
192
  })();
192
- const toPx = (v) => (typeof v === 'number' ? `${v}px` : String(v));
193
- const buildDashArray = (len, spa) => `${toPx(len)} ${toPx(spa)}`;
194
- // stroke-dasharray from pathLength/pathSpacing with default spacing = 1 - length
193
+ const toUnitless = (v) => (typeof v === 'number' ? `${v}` : String(v));
194
+ const buildDashArray = (len, spa) => `${toUnitless(len)} ${toUnitless(spa)}`;
195
+ // stroke-dasharray from pathLength/pathSpacing with upstream's default spacing = 1
195
196
  if (Array.isArray(length)) {
196
197
  const lenArr = length;
197
198
  const spaArr = Array.isArray(spacing)
198
199
  ? spacing
199
- : lenArr.map((lv) => typeof lv === 'number' ? 1 - lv : spacing);
200
+ : lenArr.map(() => (spacing !== undefined ? spacing : 1));
200
201
  const dashArray = lenArr.map((lv, i) => buildDashArray(lv, spaArr[i]));
201
202
  transformed.strokeDasharray = dashArray;
202
203
  transformed['stroke-dasharray'] = dashArray;
203
204
  }
204
205
  else if (length !== undefined) {
205
- const len = length;
206
- const lenNum = toNum(len) ?? 0;
207
- const spa = spacing !== undefined ? spacing : 1 - lenNum;
208
- const dashArray = buildDashArray(len, spa);
206
+ const lenNum = toNum(length);
207
+ const spaNum = spacing === undefined ? undefined : toNum(spacing);
208
+ const offsetNum = offset === undefined ? undefined : toNum(offset);
209
+ let dashArray;
210
+ let dashOffset;
211
+ if (lenNum !== undefined &&
212
+ (spacing === undefined || spaNum !== undefined) &&
213
+ (offset === undefined || offsetNum !== undefined)) {
214
+ const attrs = {};
215
+ buildSVGPath(attrs, lenNum, spacing === undefined ? 1 : spaNum, offsetNum, true);
216
+ dashArray = String(attrs['stroke-dasharray']);
217
+ dashOffset = String(attrs['stroke-dashoffset']);
218
+ }
219
+ else {
220
+ const spa = spacing !== undefined ? spacing : 1;
221
+ dashArray = buildDashArray(length, spa);
222
+ }
223
+ ;
209
224
  transformed.strokeDasharray = dashArray;
210
225
  transformed['stroke-dasharray'] = dashArray;
226
+ if (dashOffset !== undefined) {
227
+ ;
228
+ transformed.strokeDashoffset = dashOffset;
229
+ transformed['stroke-dashoffset'] = dashOffset;
230
+ }
211
231
  }
212
232
  // stroke-dashoffset from -pathOffset
213
233
  if (Array.isArray(offset)) {
214
234
  const offs = offset.map((ov) => {
215
235
  const n = toNum(ov);
216
- return n !== undefined ? `${-n}px` : String(ov);
236
+ return n !== undefined ? `${-n}` : String(ov);
217
237
  });
218
238
  transformed.strokeDashoffset = offs;
219
239
  transformed['stroke-dashoffset'] = offs;
220
240
  }
221
241
  else if (offset !== undefined) {
222
242
  const n = toNum(offset);
223
- const off = n !== undefined ? `${-n}px` : String(offset);
243
+ const off = n !== undefined ? `${-n}` : String(offset);
224
244
  transformed.strokeDashoffset = off;
225
245
  transformed['stroke-dashoffset'] = off;
226
246
  }
227
- else {
247
+ else if (!('stroke-dashoffset' in transformed)) {
228
248
  // default 0
229
249
  ;
230
- transformed.strokeDashoffset = '0px';
231
- transformed['stroke-dashoffset'] = '0px';
250
+ transformed.strokeDashoffset = '0';
251
+ transformed['stroke-dashoffset'] = '0';
232
252
  }
233
253
  delete transformed.pathLength;
234
254
  delete transformed.pathSpacing;
@@ -273,8 +293,8 @@ export const transformInitialSVGPathProperties = (element, initial) => {
273
293
  *
274
294
  * Behavior matches React/Framer Motion parity:
275
295
  * - Always sets pathLength="1" whenever any of path props are present
276
- * - stroke-dasharray = px(pathLength) + ' ' + px(pathSpacing ?? 1 - Number(pathLength))
277
- * - stroke-dashoffset = px(-(pathOffset ?? 0))
296
+ * - stroke-dasharray = pathLength + ' ' + (pathSpacing ?? 1)
297
+ * - stroke-dashoffset = -(pathOffset ?? 0)
278
298
  *
279
299
  * The returned object is suitable for direct DOM attribute assignment (dash-cased keys).
280
300
  *
@@ -287,19 +307,18 @@ export const computeNormalizedSVGInitialAttrs = (initial) => {
287
307
  const hasAny = 'pathLength' in initial || 'pathSpacing' in initial || 'pathOffset' in initial;
288
308
  if (!hasAny)
289
309
  return null;
290
- const toPx = (v) => (typeof v === 'number' ? `${v}px` : String(v));
291
- const negatePx = (v) => {
310
+ const toUnitless = (v) => (typeof v === 'number' ? `${v}` : String(v));
311
+ const negate = (v) => {
292
312
  if (typeof v === 'number')
293
- return `${-v}px`;
313
+ return `${-v}`;
294
314
  const s = String(v);
295
- return s.startsWith('-') ? s : /^[\d.]+(px)?$/i.test(s) ? `-${s}` : s;
315
+ return s.startsWith('-') ? s : /^[\d.]+(px)?$/i.test(s) ? `-${s.replace(/px$/i, '')}` : s;
296
316
  };
297
317
  const len = initial.pathLength ?? 0;
298
- const spa = initial.pathSpacing ??
299
- (typeof len === 'number' ? 1 - len : 1);
318
+ const spa = initial.pathSpacing ?? 1;
300
319
  const off = initial.pathOffset ?? 0;
301
- const dashArray = `${toPx(len)} ${toPx(spa)}`;
302
- const dashOffset = negatePx(off);
320
+ const dashArray = `${toUnitless(len)} ${toUnitless(spa)}`;
321
+ const dashOffset = negate(off);
303
322
  // logging removed
304
323
  return {
305
324
  pathLength: '1',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
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",