@humanspeak/svelte-motion 0.1.14 → 0.1.16
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/README.md +1 -1
- package/dist/components/AnimatePresence.svelte +10 -2
- package/dist/components/AnimatePresence.svelte.d.ts +1 -0
- package/dist/html/_MotionContainer.svelte +152 -31
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +36 -0
- package/dist/utils/animation.js +11 -5
- package/dist/utils/inView.d.ts +79 -0
- package/dist/utils/inView.js +179 -0
- package/dist/utils/presence.d.ts +9 -0
- package/dist/utils/presence.js +149 -3
- package/package.json +21 -21
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ This package carefully selects its dependencies to provide a robust and maintain
|
|
|
117
117
|
| [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
|
|
118
118
|
| [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
|
|
119
119
|
| [Animated Border Gradient (conic-gradient rotate)](https://www.youtube.com/watch?v=OgQI1-9T6ZA) | `/tests/random/animated-border-gradient` | [View Example](https://svelte.dev/playground/6983a61b4c35441b8aa72a971de01a23?version=5.38.10) |
|
|
120
|
-
| [Exit Animation](https://motion.dev/docs/react#exit-animations) | `/tests/
|
|
120
|
+
| [Exit Animation](https://motion.dev/docs/react#exit-animations) | `/tests/animate-presence/basic` | [View Example](https://svelte.dev/playground/ef277e283d864653ace54e7453801601?version=5.38.10) |
|
|
121
121
|
|
|
122
122
|
## Interactions
|
|
123
123
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte'
|
|
3
3
|
import { createAnimatePresenceContext, setAnimatePresenceContext } from '../utils/presence'
|
|
4
|
+
import { pwLog } from '../utils/log'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Provide `AnimatePresence` context to descendants.
|
|
@@ -10,14 +11,21 @@
|
|
|
10
11
|
* styled clone is animated out before being removed from the DOM.
|
|
11
12
|
*
|
|
12
13
|
* @prop children Slotted content participating in presence.
|
|
14
|
+
* @prop initial When false, children skip their enter animation on initial mount.
|
|
13
15
|
* @prop onExitComplete Optional callback invoked once all exits complete.
|
|
14
16
|
*/
|
|
15
|
-
const {
|
|
17
|
+
const {
|
|
18
|
+
children,
|
|
19
|
+
initial = true,
|
|
20
|
+
onExitComplete
|
|
21
|
+
} = $props<{
|
|
16
22
|
children?: Snippet
|
|
23
|
+
initial?: boolean
|
|
17
24
|
onExitComplete?: () => void
|
|
18
25
|
}>()
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
pwLog('[AnimatePresence] mounting', { initial, hasOnExitComplete: !!onExitComplete })
|
|
28
|
+
const context = createAnimatePresenceContext({ initial, onExitComplete })
|
|
21
29
|
setAnimatePresenceContext(context)
|
|
22
30
|
</script>
|
|
23
31
|
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
// Module-level counter for deterministic key generation (avoids SSR hydration mismatch)
|
|
3
|
+
let keyCounter = 0
|
|
4
|
+
</script>
|
|
5
|
+
|
|
1
6
|
<script lang="ts">
|
|
2
7
|
import { getMotionConfig } from '../components/motionConfig.context'
|
|
3
8
|
import type {
|
|
@@ -20,6 +25,7 @@
|
|
|
20
25
|
import { attachWhileTap } from '../utils/interaction'
|
|
21
26
|
import { attachWhileHover } from '../utils/hover'
|
|
22
27
|
import { attachWhileFocus } from '../utils/focus'
|
|
28
|
+
import { attachWhileInView } from '../utils/inView'
|
|
23
29
|
import {
|
|
24
30
|
measureRect,
|
|
25
31
|
computeFlipTransforms,
|
|
@@ -52,6 +58,7 @@
|
|
|
52
58
|
let {
|
|
53
59
|
children,
|
|
54
60
|
tag = 'div',
|
|
61
|
+
key: keyProp,
|
|
55
62
|
variants: variantsProp,
|
|
56
63
|
initial: initialProp,
|
|
57
64
|
animate: animateProp,
|
|
@@ -64,11 +71,14 @@
|
|
|
64
71
|
whileTap: whileTapProp,
|
|
65
72
|
whileHover: whileHoverProp,
|
|
66
73
|
whileFocus: whileFocusProp,
|
|
74
|
+
whileInView: whileInViewProp,
|
|
67
75
|
whileDrag: whileDragProp,
|
|
68
76
|
onHoverStart: onHoverStartProp,
|
|
69
77
|
onHoverEnd: onHoverEndProp,
|
|
70
78
|
onFocusStart: onFocusStartProp,
|
|
71
79
|
onFocusEnd: onFocusEndProp,
|
|
80
|
+
onInViewStart: onInViewStartProp,
|
|
81
|
+
onInViewEnd: onInViewEndProp,
|
|
72
82
|
onTapStart: onTapStartProp,
|
|
73
83
|
onTap: onTapProp,
|
|
74
84
|
onTapCancel: onTapCancelProp,
|
|
@@ -95,8 +105,20 @@
|
|
|
95
105
|
let dataPath = $state<number>(-1)
|
|
96
106
|
const motionConfig = $derived(getMotionConfig())
|
|
97
107
|
|
|
98
|
-
//
|
|
99
|
-
const
|
|
108
|
+
// Get presence context to check if we're inside AnimatePresence
|
|
109
|
+
const context = getAnimatePresenceContext()
|
|
110
|
+
|
|
111
|
+
// Validate key prop when inside AnimatePresence
|
|
112
|
+
if (context && !keyProp) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
'motion elements inside AnimatePresence must have a `key` prop. ' +
|
|
115
|
+
'Example: <motion.div key="unique-id" />'
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Use the provided key for presence tracking
|
|
120
|
+
// When not inside AnimatePresence, use a stable identifier based on component instance
|
|
121
|
+
const presenceKey = keyProp ?? `motion-${++keyCounter}`
|
|
100
122
|
|
|
101
123
|
// Compute merged transition without mutating props to avoid effect write loops
|
|
102
124
|
const mergedTransition = $derived<AnimationOptions>(
|
|
@@ -118,12 +140,10 @@
|
|
|
118
140
|
}
|
|
119
141
|
})
|
|
120
142
|
|
|
121
|
-
const context = getAnimatePresenceContext()
|
|
122
143
|
// Update presence context with current state when element is ready and has size
|
|
123
144
|
$effect(() => {
|
|
124
145
|
if (!(context && element && isLoaded === 'ready')) return
|
|
125
146
|
|
|
126
|
-
let rafId: number | null = null
|
|
127
147
|
let lastWidth = 0
|
|
128
148
|
let lastHeight = 0
|
|
129
149
|
let stopped = false
|
|
@@ -132,11 +152,6 @@
|
|
|
132
152
|
if (stopped || !element || !element.isConnected) return
|
|
133
153
|
const rect = element.getBoundingClientRect()
|
|
134
154
|
const style = getComputedStyle(element)
|
|
135
|
-
pwLog('[motion][measure]', {
|
|
136
|
-
w: rect.width,
|
|
137
|
-
h: rect.height,
|
|
138
|
-
transform: style.transform
|
|
139
|
-
})
|
|
140
155
|
if (
|
|
141
156
|
Math.abs(rect.width - lastWidth) > 0.5 ||
|
|
142
157
|
Math.abs(rect.height - lastHeight) > 0.5
|
|
@@ -149,6 +164,7 @@
|
|
|
149
164
|
|
|
150
165
|
// Observe size changes
|
|
151
166
|
const resizeObserver = new ResizeObserver(() => {
|
|
167
|
+
pwLog('[motion][resize]', { key: presenceKey })
|
|
152
168
|
measureAndUpdate()
|
|
153
169
|
})
|
|
154
170
|
try {
|
|
@@ -157,15 +173,8 @@
|
|
|
157
173
|
// Ignore
|
|
158
174
|
}
|
|
159
175
|
|
|
160
|
-
// Also poll on RAF to catch transform/layout-driven changes
|
|
161
|
-
const tick = () => {
|
|
162
|
-
if (stopped) return
|
|
163
|
-
measureAndUpdate()
|
|
164
|
-
rafId = requestAnimationFrame(tick)
|
|
165
|
-
}
|
|
166
|
-
rafId = requestAnimationFrame(tick)
|
|
167
|
-
|
|
168
176
|
// Initial measure once
|
|
177
|
+
pwLog('[motion][initial-measure]', { key: presenceKey })
|
|
169
178
|
measureAndUpdate()
|
|
170
179
|
|
|
171
180
|
return () => {
|
|
@@ -175,7 +184,6 @@
|
|
|
175
184
|
} catch {
|
|
176
185
|
// Ignore
|
|
177
186
|
}
|
|
178
|
-
if (rafId) cancelAnimationFrame(rafId)
|
|
179
187
|
}
|
|
180
188
|
})
|
|
181
189
|
|
|
@@ -217,13 +225,23 @@
|
|
|
217
225
|
)
|
|
218
226
|
|
|
219
227
|
// Propagate initial={false} to children BEFORE setting variant context
|
|
228
|
+
// AnimatePresence initial={false} only applies on first render - check shouldAnimateEnter(key)
|
|
220
229
|
const parentInitialFalse = getInitialFalseContext()
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
const presenceSkipEnter = context ? !context.shouldAnimateEnter(presenceKey) : false
|
|
231
|
+
const effectiveInitialProp = presenceSkipEnter
|
|
232
|
+
? false
|
|
233
|
+
: initialProp !== undefined
|
|
234
|
+
? initialProp
|
|
235
|
+
: parentInitialFalse && variantsProp
|
|
236
|
+
? false
|
|
237
|
+
: undefined
|
|
238
|
+
|
|
239
|
+
pwLog('[motion] mount', {
|
|
240
|
+
presenceSkipEnter,
|
|
241
|
+
effectiveInitialProp,
|
|
242
|
+
initialProp,
|
|
243
|
+
animateProp
|
|
244
|
+
})
|
|
227
245
|
|
|
228
246
|
if (initialProp === false) {
|
|
229
247
|
setInitialFalseContext(true)
|
|
@@ -279,8 +297,20 @@
|
|
|
279
297
|
initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
|
|
280
298
|
? `${styleProp || ''};visibility:hidden`
|
|
281
299
|
: styleProp,
|
|
282
|
-
initialKeyframes as
|
|
283
|
-
|
|
300
|
+
// Apply initialKeyframes as inline styles during mounting and initial phases
|
|
301
|
+
// The animation starts in RAF after 'initial' phase, so we need styles until then
|
|
302
|
+
// When ready AND we have initialKeyframes: DON'T set any animated properties!
|
|
303
|
+
// WAAPI is controlling them and inline styles can override the animation
|
|
304
|
+
isLoaded === 'mounting' || isLoaded === 'initial'
|
|
305
|
+
? (initialKeyframes as unknown as Record<string, unknown>)
|
|
306
|
+
: undefined,
|
|
307
|
+
// Only use resolvedAnimate as fallback when we DON'T have initialKeyframes
|
|
308
|
+
// If we have initialKeyframes, the enter animation is running - setting
|
|
309
|
+
// inline styles to the target values will override the WAAPI animation
|
|
310
|
+
// Use isNotEmpty to handle empty initial objects (initial: {}) which should fallback
|
|
311
|
+
isNotEmpty(initialKeyframes)
|
|
312
|
+
? undefined
|
|
313
|
+
: (resolvedAnimate as unknown as Record<string, unknown>)
|
|
284
314
|
),
|
|
285
315
|
class: classProp
|
|
286
316
|
})
|
|
@@ -357,7 +387,16 @@
|
|
|
357
387
|
})
|
|
358
388
|
|
|
359
389
|
const runAnimation = () => {
|
|
360
|
-
|
|
390
|
+
pwLog('[motion] runAnimation called', {
|
|
391
|
+
hasElement: !!element,
|
|
392
|
+
resolvedAnimate,
|
|
393
|
+
mergedTransition
|
|
394
|
+
})
|
|
395
|
+
if (!element || !resolvedAnimate) {
|
|
396
|
+
pwLog('[motion] runAnimation bailing - no element or resolvedAnimate')
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
361
400
|
const transitionAnimate: MotionTransition = mergedTransition ?? {}
|
|
362
401
|
let payload = $state.snapshot(resolvedAnimate)
|
|
363
402
|
|
|
@@ -373,6 +412,11 @@
|
|
|
373
412
|
;(element as HTMLElement).style.removeProperty('stroke-dashoffset')
|
|
374
413
|
}
|
|
375
414
|
|
|
415
|
+
pwLog('[motion] runAnimation animating', {
|
|
416
|
+
payload,
|
|
417
|
+
transitionAnimate
|
|
418
|
+
})
|
|
419
|
+
|
|
376
420
|
animateWithLifecycle(
|
|
377
421
|
element,
|
|
378
422
|
payload as unknown as DOMKeyframesDefinition,
|
|
@@ -385,6 +429,12 @@
|
|
|
385
429
|
// Track the last variant key we ran to avoid re-running on mount
|
|
386
430
|
let lastRanVariantKey = $state<string | undefined>(undefined)
|
|
387
431
|
let mountedWithInitialFalse = $state(false)
|
|
432
|
+
// Track if the initial->animate transition has already been triggered by main effect
|
|
433
|
+
let initialAnimationTriggered = $state(false)
|
|
434
|
+
// Track if we've run the animation for object animateProp on this mount
|
|
435
|
+
let objectAnimateRanOnMount = $state(false)
|
|
436
|
+
// Track the serialized animateProp to detect changes for object animate props
|
|
437
|
+
let lastAnimatePropJson = $state<string | undefined>(undefined)
|
|
388
438
|
const currentAnimateKey = $derived(
|
|
389
439
|
typeof animateProp === 'string'
|
|
390
440
|
? animateProp
|
|
@@ -487,6 +537,25 @@
|
|
|
487
537
|
)
|
|
488
538
|
})
|
|
489
539
|
|
|
540
|
+
// whileInView handling for viewport intersection
|
|
541
|
+
$effect(() => {
|
|
542
|
+
if (!(element && isLoaded === 'ready' && isNotEmpty(whileInViewProp))) return
|
|
543
|
+
return attachWhileInView(
|
|
544
|
+
element!,
|
|
545
|
+
(whileInViewProp ?? {}) as Record<string, unknown>,
|
|
546
|
+
(mergedTransition ?? {}) as AnimationOptions,
|
|
547
|
+
{
|
|
548
|
+
onStart: onInViewStartProp,
|
|
549
|
+
onEnd: onInViewEndProp,
|
|
550
|
+
onAnimationComplete: onAnimationCompleteProp
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
initial: (resolvedInitial ?? {}) as Record<string, unknown>,
|
|
554
|
+
animate: (resolvedAnimate ?? {}) as Record<string, unknown>
|
|
555
|
+
}
|
|
556
|
+
)
|
|
557
|
+
})
|
|
558
|
+
|
|
490
559
|
// Re-run animate when animateProp changes while ready
|
|
491
560
|
$effect(() => {
|
|
492
561
|
if (!(element && isLoaded === 'ready')) return
|
|
@@ -500,15 +569,39 @@
|
|
|
500
569
|
// Variant has changed, so we should animate
|
|
501
570
|
mountedWithInitialFalse = false
|
|
502
571
|
}
|
|
572
|
+
// Skip if the initial animation was already triggered by the main effect
|
|
573
|
+
if (initialAnimationTriggered) {
|
|
574
|
+
pwLog('[motion] effect: skipping, initial animation already triggered')
|
|
575
|
+
initialAnimationTriggered = false
|
|
576
|
+
// Also mark object animate as ran to prevent duplicate runs from effect re-triggers
|
|
577
|
+
if (animateProp && typeof animateProp !== 'string') {
|
|
578
|
+
objectAnimateRanOnMount = true
|
|
579
|
+
}
|
|
580
|
+
return
|
|
581
|
+
}
|
|
503
582
|
if (typeof animateProp === 'string') {
|
|
504
583
|
if (lastRanVariantKey !== animateProp) {
|
|
505
584
|
lastRanVariantKey = animateProp
|
|
506
585
|
runAnimation()
|
|
507
586
|
}
|
|
508
587
|
} else if (animateProp) {
|
|
509
|
-
// Object animate props -
|
|
510
|
-
|
|
511
|
-
|
|
588
|
+
// Object animate props - detect if the prop actually changed
|
|
589
|
+
const currentJson = JSON.stringify(animateProp)
|
|
590
|
+
const propChanged = lastAnimatePropJson !== currentJson
|
|
591
|
+
|
|
592
|
+
// Reset flag if animate prop changed
|
|
593
|
+
if (propChanged) {
|
|
594
|
+
objectAnimateRanOnMount = false
|
|
595
|
+
lastAnimatePropJson = currentJson
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Only run if we haven't already animated on this mount (or prop changed)
|
|
599
|
+
// This prevents duplicate animations when Svelte re-triggers the effect
|
|
600
|
+
if (!objectAnimateRanOnMount) {
|
|
601
|
+
objectAnimateRanOnMount = true
|
|
602
|
+
lastRanVariantKey = undefined
|
|
603
|
+
runAnimation()
|
|
604
|
+
}
|
|
512
605
|
}
|
|
513
606
|
})
|
|
514
607
|
|
|
@@ -539,9 +632,18 @@
|
|
|
539
632
|
$effect(() => {
|
|
540
633
|
if (!(element && isLoaded === 'mounting')) return
|
|
541
634
|
|
|
635
|
+
pwLog('[motion] main effect running', {
|
|
636
|
+
effectiveAnimate: !!effectiveAnimate,
|
|
637
|
+
effectiveInitialProp,
|
|
638
|
+
resolvedAnimate,
|
|
639
|
+
initialKeyframes,
|
|
640
|
+
hasInitialKeyframes: isNotEmpty(initialKeyframes)
|
|
641
|
+
})
|
|
642
|
+
|
|
542
643
|
if (effectiveAnimate) {
|
|
543
644
|
// If initial={false}, render at animate state immediately with no transition
|
|
544
645
|
if (effectiveInitialProp === false && resolvedAnimate) {
|
|
646
|
+
pwLog('[motion] path: initial=false, skip to animate')
|
|
545
647
|
// Use Motion's animate() with duration:0 so it takes control of these properties
|
|
546
648
|
// This prevents inline styles from pinning the properties during future animations
|
|
547
649
|
let snapshot = $state.snapshot(resolvedAnimate) as Record<string, unknown>
|
|
@@ -555,6 +657,7 @@
|
|
|
555
657
|
dataPath = 5
|
|
556
658
|
isLoaded = 'ready'
|
|
557
659
|
} else if (isNotEmpty(initialKeyframes)) {
|
|
660
|
+
pwLog('[motion] path: has initialKeyframes, will animate to target')
|
|
558
661
|
// Apply initial instantly BEFORE exposing 'initial' state
|
|
559
662
|
const transformedInitial = transformSVGPathProperties(
|
|
560
663
|
element!,
|
|
@@ -598,11 +701,29 @@
|
|
|
598
701
|
if (isPlaywright) {
|
|
599
702
|
await sleep(10)
|
|
600
703
|
}
|
|
601
|
-
|
|
704
|
+
pwLog('[motion] RAF: promoting to ready and running animation')
|
|
705
|
+
|
|
706
|
+
// Mark that we're triggering the initial animation to prevent duplicate runs
|
|
707
|
+
initialAnimationTriggered = true
|
|
602
708
|
|
|
709
|
+
// IMPORTANT: Start the animation BEFORE changing isLoaded.
|
|
710
|
+
// When isLoaded changes to 'ready', Svelte will reactively remove the
|
|
711
|
+
// initial inline styles. We need the animation to capture the current
|
|
712
|
+
// state (from inline styles) before they're removed.
|
|
603
713
|
runAnimation()
|
|
714
|
+
|
|
715
|
+
// CRITICAL: Wait for the next animation frame before changing isLoaded.
|
|
716
|
+
// This gives WAAPI time to:
|
|
717
|
+
// 1. Parse and create the animation
|
|
718
|
+
// 2. Start the animation layer
|
|
719
|
+
// 3. Lock in the "from" values from current computed style
|
|
720
|
+
// Only THEN can we safely clear inline styles without killing the animation
|
|
721
|
+
requestAnimationFrame(() => {
|
|
722
|
+
isLoaded = 'ready'
|
|
723
|
+
})
|
|
604
724
|
})
|
|
605
725
|
} else {
|
|
726
|
+
pwLog('[motion] path: no initialKeyframes, skip to ready')
|
|
606
727
|
dataPath = 2
|
|
607
728
|
isLoaded = 'ready'
|
|
608
729
|
// If we're inheriting a variant and parent had initial={false}, apply the variant instantly
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import MotionConfig from './components/MotionConfig.svelte';
|
|
|
3
3
|
import type { MotionComponents } from './html/index';
|
|
4
4
|
export declare const motion: MotionComponents;
|
|
5
5
|
export { animate, hover } from 'motion';
|
|
6
|
-
export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileTap, Variants } from './types';
|
|
6
|
+
export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, Variants } from './types';
|
|
7
7
|
export { useAnimationFrame } from './utils/animationFrame';
|
|
8
8
|
export { createDragControls } from './utils/dragControls';
|
|
9
9
|
export { useSpring } from './utils/spring';
|
package/dist/types.d.ts
CHANGED
|
@@ -121,6 +121,18 @@ export type MotionWhileFocus = (Record<string, unknown> & {
|
|
|
121
121
|
export type MotionWhileDrag = (Record<string, unknown> & {
|
|
122
122
|
transition?: AnimationOptions;
|
|
123
123
|
}) | DOMKeyframesDefinition | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* Animation properties for in-view interactions.
|
|
126
|
+
* When the element enters the viewport, it animates to this state; when it leaves,
|
|
127
|
+
* it animates back to its baseline (from animate/initial), restoring only the changed keys.
|
|
128
|
+
* @example
|
|
129
|
+
* ```svelte
|
|
130
|
+
* <motion.div whileInView={{ opacity: 1, y: 0 }} />
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export type MotionWhileInView = (Record<string, unknown> & {
|
|
134
|
+
transition?: AnimationOptions;
|
|
135
|
+
}) | DOMKeyframesDefinition | undefined;
|
|
124
136
|
/**
|
|
125
137
|
* Animation transition configuration for hover interactions.
|
|
126
138
|
* Overrides the global transition when provided.
|
|
@@ -136,6 +148,9 @@ export type MotionOnHoverEnd = (() => void) | undefined;
|
|
|
136
148
|
/** Focus lifecycle callbacks */
|
|
137
149
|
export type MotionOnFocusStart = (() => void) | undefined;
|
|
138
150
|
export type MotionOnFocusEnd = (() => void) | undefined;
|
|
151
|
+
/** InView lifecycle callbacks */
|
|
152
|
+
export type MotionOnInViewStart = (() => void) | undefined;
|
|
153
|
+
export type MotionOnInViewEnd = (() => void) | undefined;
|
|
139
154
|
/** Tap lifecycle callbacks */
|
|
140
155
|
export type MotionOnTapStart = (() => void) | undefined;
|
|
141
156
|
export type MotionOnTap = (() => void) | undefined;
|
|
@@ -190,6 +205,21 @@ export type DragControls = {
|
|
|
190
205
|
* Base motion props shared by all motion components.
|
|
191
206
|
*/
|
|
192
207
|
export type MotionProps = {
|
|
208
|
+
/**
|
|
209
|
+
* Unique key for AnimatePresence tracking.
|
|
210
|
+
* Required when inside an AnimatePresence component.
|
|
211
|
+
* Used to track enter/exit state and determine whether to animate.
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```svelte
|
|
215
|
+
* <AnimatePresence>
|
|
216
|
+
* {#if isVisible}
|
|
217
|
+
* <motion.div key="box" exit={{ opacity: 0 }} />
|
|
218
|
+
* {/if}
|
|
219
|
+
* </AnimatePresence>
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
key?: string;
|
|
193
223
|
/** Variants define named animation states */
|
|
194
224
|
variants?: Variants;
|
|
195
225
|
/** Initial state of the animation (object or variant key) */
|
|
@@ -208,6 +238,8 @@ export type MotionProps = {
|
|
|
208
238
|
whileFocus?: MotionWhileFocus;
|
|
209
239
|
/** Drag interaction animation */
|
|
210
240
|
whileDrag?: MotionWhileDrag;
|
|
241
|
+
/** In-view interaction animation - animates when element enters viewport */
|
|
242
|
+
whileInView?: MotionWhileInView;
|
|
211
243
|
/** Called right before a main animate transition starts */
|
|
212
244
|
onAnimationStart?: MotionAnimationStart;
|
|
213
245
|
/** Called after a main animate transition completes */
|
|
@@ -220,6 +252,10 @@ export type MotionProps = {
|
|
|
220
252
|
onFocusStart?: MotionOnFocusStart;
|
|
221
253
|
/** Called when element loses keyboard focus */
|
|
222
254
|
onFocusEnd?: MotionOnFocusEnd;
|
|
255
|
+
/** Called when element enters viewport */
|
|
256
|
+
onInViewStart?: MotionOnInViewStart;
|
|
257
|
+
/** Called when element leaves viewport */
|
|
258
|
+
onInViewEnd?: MotionOnInViewEnd;
|
|
223
259
|
/** Called when a tap gesture starts (pointerdown recognized) */
|
|
224
260
|
onTapStart?: MotionOnTapStart;
|
|
225
261
|
/** Called when a tap gesture ends successfully (pointerup) */
|
package/dist/utils/animation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pwLog } from './log';
|
|
1
2
|
import { hasFinishedPromise, isPromiseLike } from './promise';
|
|
2
3
|
import { animate } from 'motion';
|
|
3
4
|
/**
|
|
@@ -32,12 +33,17 @@ export const mergeTransitions = (...args) => {
|
|
|
32
33
|
* @param onStart Optional lifecycle fired before animation starts.
|
|
33
34
|
* @param onComplete Optional lifecycle fired after animation completes.
|
|
34
35
|
*/
|
|
35
|
-
export const animateWithLifecycle = (el, keyframes, transition,
|
|
36
|
-
/* trunk-ignore(eslint/no-unused-vars) */
|
|
37
|
-
onStart,
|
|
38
|
-
/* trunk-ignore(eslint/no-unused-vars) */
|
|
39
|
-
onComplete) => {
|
|
36
|
+
export const animateWithLifecycle = (el, keyframes, transition, onStart, onComplete) => {
|
|
40
37
|
const payload = keyframes;
|
|
38
|
+
const computed = getComputedStyle(el);
|
|
39
|
+
pwLog('[animateWithLifecycle] starting', {
|
|
40
|
+
keyframes: payload,
|
|
41
|
+
transition,
|
|
42
|
+
currentOpacity: el.style.opacity,
|
|
43
|
+
currentTransform: el.style.transform,
|
|
44
|
+
computedOpacity: computed.opacity,
|
|
45
|
+
computedTransform: computed.transform
|
|
46
|
+
});
|
|
41
47
|
onStart?.(payload);
|
|
42
48
|
const controls = animate(el, payload, transition);
|
|
43
49
|
if (hasFinishedPromise(controls)) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
|
|
2
|
+
/**
|
|
3
|
+
* Split a whileInView definition into keyframes and an optional nested transition.
|
|
4
|
+
*
|
|
5
|
+
* @param def While-in-view record that may include a nested `transition`.
|
|
6
|
+
* @returns Object with `keyframes` (no `transition`) and optional `transition`.
|
|
7
|
+
* @example
|
|
8
|
+
* // With transition
|
|
9
|
+
* splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
|
|
10
|
+
* // => { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Without transition
|
|
14
|
+
* splitInViewDefinition({ opacity: 1, scale: 1 })
|
|
15
|
+
* // => { keyframes: { opacity: 1, scale: 1 }, transition: undefined }
|
|
16
|
+
*/
|
|
17
|
+
export declare const splitInViewDefinition: (def: Record<string, unknown>) => {
|
|
18
|
+
keyframes: Record<string, unknown>;
|
|
19
|
+
transition?: AnimationOptions;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Compute the baseline values to restore to when element leaves viewport.
|
|
23
|
+
*
|
|
24
|
+
* Preference order per key: `animate` → `initial` → neutral transform defaults
|
|
25
|
+
* → computed style value if present.
|
|
26
|
+
*
|
|
27
|
+
* @param el Target element.
|
|
28
|
+
* @param opts Source records for baseline computation.
|
|
29
|
+
* @returns Minimal baseline record to restore when element leaves viewport.
|
|
30
|
+
* @example
|
|
31
|
+
* computeInViewBaseline(element, {
|
|
32
|
+
* initial: { opacity: 0, y: 50 },
|
|
33
|
+
* animate: { opacity: 1, y: 0 },
|
|
34
|
+
* whileInView: { opacity: 1, scale: 1.1 }
|
|
35
|
+
* })
|
|
36
|
+
* // => { opacity: 1, scale: 1 } (scale defaults to 1, opacity from animate)
|
|
37
|
+
*/
|
|
38
|
+
export declare const computeInViewBaseline: (el: HTMLElement, opts: {
|
|
39
|
+
initial?: Record<string, unknown>;
|
|
40
|
+
animate?: Record<string, unknown>;
|
|
41
|
+
whileInView?: Record<string, unknown>;
|
|
42
|
+
}) => Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Attach whileInView interactions to an element using IntersectionObserver.
|
|
45
|
+
*
|
|
46
|
+
* On intersection (element enters viewport), animates to `whileInView` state
|
|
47
|
+
* (using nested `transition` if provided). On un-intersection, restores changed
|
|
48
|
+
* keys to the baseline using the merged root/component transition.
|
|
49
|
+
*
|
|
50
|
+
* Critical fix for issue #230: Checks `entry.isIntersecting` immediately on
|
|
51
|
+
* first callback to handle elements already in viewport on mount.
|
|
52
|
+
*
|
|
53
|
+
* @param el Target element.
|
|
54
|
+
* @param whileInView While-in-view definition.
|
|
55
|
+
* @param mergedTransition Root/component merged transition.
|
|
56
|
+
* @param callbacks Optional lifecycle callbacks for in-view start/end and animation completion.
|
|
57
|
+
* @param baselineSources Optional sources used to compute baseline.
|
|
58
|
+
* @returns Cleanup function to disconnect the IntersectionObserver.
|
|
59
|
+
* @example
|
|
60
|
+
* const cleanup = attachWhileInView(
|
|
61
|
+
* element,
|
|
62
|
+
* { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
|
63
|
+
* { duration: 0.3 },
|
|
64
|
+
* {
|
|
65
|
+
* onStart: () => console.log('Entered viewport'),
|
|
66
|
+
* onEnd: () => console.log('Left viewport')
|
|
67
|
+
* },
|
|
68
|
+
* { initial: { opacity: 0, y: 50 } }
|
|
69
|
+
* )
|
|
70
|
+
* // Later: cleanup() to disconnect observer
|
|
71
|
+
*/
|
|
72
|
+
export declare const attachWhileInView: (el: HTMLElement, whileInView: Record<string, unknown> | undefined, mergedTransition: AnimationOptions, callbacks?: {
|
|
73
|
+
onStart?: () => void;
|
|
74
|
+
onEnd?: () => void;
|
|
75
|
+
onAnimationComplete?: (definition: DOMKeyframesDefinition | undefined) => void;
|
|
76
|
+
}, baselineSources?: {
|
|
77
|
+
initial?: Record<string, unknown>;
|
|
78
|
+
animate?: Record<string, unknown>;
|
|
79
|
+
}) => (() => void);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { animate } from 'motion';
|
|
2
|
+
/**
|
|
3
|
+
* Split a whileInView definition into keyframes and an optional nested transition.
|
|
4
|
+
*
|
|
5
|
+
* @param def While-in-view record that may include a nested `transition`.
|
|
6
|
+
* @returns Object with `keyframes` (no `transition`) and optional `transition`.
|
|
7
|
+
* @example
|
|
8
|
+
* // With transition
|
|
9
|
+
* splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
|
|
10
|
+
* // => { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Without transition
|
|
14
|
+
* splitInViewDefinition({ opacity: 1, scale: 1 })
|
|
15
|
+
* // => { keyframes: { opacity: 1, scale: 1 }, transition: undefined }
|
|
16
|
+
*/
|
|
17
|
+
export const splitInViewDefinition = (def) => {
|
|
18
|
+
const { transition, ...rest } = (def ?? {});
|
|
19
|
+
return { keyframes: rest, transition };
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Compute the baseline values to restore to when element leaves viewport.
|
|
23
|
+
*
|
|
24
|
+
* Preference order per key: `animate` → `initial` → neutral transform defaults
|
|
25
|
+
* → computed style value if present.
|
|
26
|
+
*
|
|
27
|
+
* @param el Target element.
|
|
28
|
+
* @param opts Source records for baseline computation.
|
|
29
|
+
* @returns Minimal baseline record to restore when element leaves viewport.
|
|
30
|
+
* @example
|
|
31
|
+
* computeInViewBaseline(element, {
|
|
32
|
+
* initial: { opacity: 0, y: 50 },
|
|
33
|
+
* animate: { opacity: 1, y: 0 },
|
|
34
|
+
* whileInView: { opacity: 1, scale: 1.1 }
|
|
35
|
+
* })
|
|
36
|
+
* // => { opacity: 1, scale: 1 } (scale defaults to 1, opacity from animate)
|
|
37
|
+
*/
|
|
38
|
+
export const computeInViewBaseline = (el, opts) => {
|
|
39
|
+
const baseline = {};
|
|
40
|
+
const initialRecord = (opts.initial ?? {});
|
|
41
|
+
const animateRecord = (opts.animate ?? {});
|
|
42
|
+
const whileInViewRecordRaw = (opts.whileInView ?? {});
|
|
43
|
+
const whileInViewRecord = { ...whileInViewRecordRaw };
|
|
44
|
+
delete whileInViewRecord.transition;
|
|
45
|
+
const neutralTransformDefaults = {
|
|
46
|
+
x: 0,
|
|
47
|
+
y: 0,
|
|
48
|
+
translateX: 0,
|
|
49
|
+
translateY: 0,
|
|
50
|
+
scale: 1,
|
|
51
|
+
scaleX: 1,
|
|
52
|
+
scaleY: 1,
|
|
53
|
+
rotate: 0,
|
|
54
|
+
rotateX: 0,
|
|
55
|
+
rotateY: 0,
|
|
56
|
+
rotateZ: 0,
|
|
57
|
+
skewX: 0,
|
|
58
|
+
skewY: 0,
|
|
59
|
+
opacity: 1
|
|
60
|
+
};
|
|
61
|
+
const cs = getComputedStyle(el);
|
|
62
|
+
const inlineStyle = el.getAttribute('style') || '';
|
|
63
|
+
// Helper to escape regex metacharacters to prevent ReDoS and ensure literal matching
|
|
64
|
+
const escapeRegExp = (str) => {
|
|
65
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
66
|
+
};
|
|
67
|
+
// Helper to extract CSS function (var, calc, min, max, etc.) from inline style if present
|
|
68
|
+
const getInlineStyleValue = (propName) => {
|
|
69
|
+
const kebabCase = propName.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
70
|
+
const escapedKebabCase = escapeRegExp(kebabCase);
|
|
71
|
+
// Match property name at start of string or after semicolon
|
|
72
|
+
const regex = new RegExp(`(?:^|;)\\s*${escapedKebabCase}\\s*:\\s*([^;]+)`, 'i');
|
|
73
|
+
const match = inlineStyle.match(regex);
|
|
74
|
+
if (match) {
|
|
75
|
+
const value = match[1].trim();
|
|
76
|
+
// Preserve CSS functions: var(), calc(), min(), max(), clamp(), rgb(), hsl(), url(), etc.
|
|
77
|
+
if (/\b(var|calc|min|max|clamp|rgb|rgba|hsl|hsla|url)\s*\(/.test(value)) {
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
for (const key of Object.keys(whileInViewRecord)) {
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(animateRecord, key)) {
|
|
85
|
+
baseline[key] = animateRecord[key];
|
|
86
|
+
}
|
|
87
|
+
else if (Object.prototype.hasOwnProperty.call(initialRecord, key)) {
|
|
88
|
+
baseline[key] = initialRecord[key];
|
|
89
|
+
}
|
|
90
|
+
else if (key in neutralTransformDefaults) {
|
|
91
|
+
baseline[key] = neutralTransformDefaults[key];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Check if inline style has a CSS variable for this property
|
|
95
|
+
const inlineValue = getInlineStyleValue(key);
|
|
96
|
+
if (inlineValue) {
|
|
97
|
+
baseline[key] = inlineValue;
|
|
98
|
+
}
|
|
99
|
+
else if (key in cs) {
|
|
100
|
+
baseline[key] = cs[key];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return baseline;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Attach whileInView interactions to an element using IntersectionObserver.
|
|
108
|
+
*
|
|
109
|
+
* On intersection (element enters viewport), animates to `whileInView` state
|
|
110
|
+
* (using nested `transition` if provided). On un-intersection, restores changed
|
|
111
|
+
* keys to the baseline using the merged root/component transition.
|
|
112
|
+
*
|
|
113
|
+
* Critical fix for issue #230: Checks `entry.isIntersecting` immediately on
|
|
114
|
+
* first callback to handle elements already in viewport on mount.
|
|
115
|
+
*
|
|
116
|
+
* @param el Target element.
|
|
117
|
+
* @param whileInView While-in-view definition.
|
|
118
|
+
* @param mergedTransition Root/component merged transition.
|
|
119
|
+
* @param callbacks Optional lifecycle callbacks for in-view start/end and animation completion.
|
|
120
|
+
* @param baselineSources Optional sources used to compute baseline.
|
|
121
|
+
* @returns Cleanup function to disconnect the IntersectionObserver.
|
|
122
|
+
* @example
|
|
123
|
+
* const cleanup = attachWhileInView(
|
|
124
|
+
* element,
|
|
125
|
+
* { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
|
126
|
+
* { duration: 0.3 },
|
|
127
|
+
* {
|
|
128
|
+
* onStart: () => console.log('Entered viewport'),
|
|
129
|
+
* onEnd: () => console.log('Left viewport')
|
|
130
|
+
* },
|
|
131
|
+
* { initial: { opacity: 0, y: 50 } }
|
|
132
|
+
* )
|
|
133
|
+
* // Later: cleanup() to disconnect observer
|
|
134
|
+
*/
|
|
135
|
+
export const attachWhileInView = (el, whileInView, mergedTransition, callbacks, baselineSources) => {
|
|
136
|
+
if (!whileInView)
|
|
137
|
+
return () => { };
|
|
138
|
+
let hasAnimated = false;
|
|
139
|
+
let inViewBaseline = null;
|
|
140
|
+
const observer = new IntersectionObserver((entries) => {
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (entry.isIntersecting && !hasAnimated) {
|
|
143
|
+
// Element entered viewport: animate to whileInView state
|
|
144
|
+
hasAnimated = true;
|
|
145
|
+
inViewBaseline = computeInViewBaseline(el, {
|
|
146
|
+
initial: baselineSources?.initial,
|
|
147
|
+
animate: baselineSources?.animate,
|
|
148
|
+
whileInView
|
|
149
|
+
});
|
|
150
|
+
callbacks?.onStart?.();
|
|
151
|
+
const { keyframes, transition } = splitInViewDefinition(whileInView);
|
|
152
|
+
const animation = animate(el, keyframes, (transition ?? mergedTransition));
|
|
153
|
+
// Call onAnimationComplete when animation finishes
|
|
154
|
+
animation.finished
|
|
155
|
+
.then(() => {
|
|
156
|
+
callbacks?.onAnimationComplete?.(keyframes);
|
|
157
|
+
})
|
|
158
|
+
.catch(() => {
|
|
159
|
+
// Animation was cancelled, don't call completion callback
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else if (!entry.isIntersecting && hasAnimated) {
|
|
163
|
+
// Element left viewport: animate back to baseline
|
|
164
|
+
if (inViewBaseline && Object.keys(inViewBaseline).length > 0) {
|
|
165
|
+
animate(el, inViewBaseline, mergedTransition);
|
|
166
|
+
}
|
|
167
|
+
callbacks?.onEnd?.();
|
|
168
|
+
hasAnimated = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}, { threshold: 0 } // Fire as soon as any part is visible
|
|
172
|
+
);
|
|
173
|
+
// Start observing - IntersectionObserver fires immediately for already-visible elements
|
|
174
|
+
observer.observe(el);
|
|
175
|
+
// Return cleanup function
|
|
176
|
+
return () => {
|
|
177
|
+
observer.disconnect();
|
|
178
|
+
};
|
|
179
|
+
};
|
package/dist/utils/presence.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ import type { MotionExit, MotionTransition } from '../types';
|
|
|
5
5
|
* so we can clone and animate them out after removal.
|
|
6
6
|
*/
|
|
7
7
|
export type AnimatePresenceContext = {
|
|
8
|
+
/** When false, children skip their enter animation on initial mount. */
|
|
9
|
+
initial: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Returns true if a child with the given key should animate its enter.
|
|
12
|
+
* Returns false only during first render when initial={false} AND the key has never been seen.
|
|
13
|
+
* Re-entries (after exit) always animate.
|
|
14
|
+
*/
|
|
15
|
+
shouldAnimateEnter: (key: string) => boolean;
|
|
8
16
|
/** Called when all exit animations complete (optional). */
|
|
9
17
|
onExitComplete?: () => void;
|
|
10
18
|
/** Register a child element and its exit definition. */
|
|
@@ -35,6 +43,7 @@ export type AnimatePresenceContext = {
|
|
|
35
43
|
* @returns A presence context with register/update/unregister APIs.
|
|
36
44
|
*/
|
|
37
45
|
export declare function createAnimatePresenceContext(context: {
|
|
46
|
+
initial?: boolean;
|
|
38
47
|
onExitComplete?: () => void;
|
|
39
48
|
}): AnimatePresenceContext;
|
|
40
49
|
/**
|
package/dist/utils/presence.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mergeTransitions } from './animation';
|
|
2
|
+
import { pwLog } from './log';
|
|
2
3
|
import { animate } from 'motion';
|
|
3
4
|
import { getContext, onDestroy, setContext } from 'svelte';
|
|
4
5
|
/**
|
|
@@ -44,6 +45,67 @@ const resetTransforms = (element) => {
|
|
|
44
45
|
* @returns A presence context with register/update/unregister APIs.
|
|
45
46
|
*/
|
|
46
47
|
export function createAnimatePresenceContext(context) {
|
|
48
|
+
// Default initial to true (animate on first mount) unless explicitly false
|
|
49
|
+
const initial = context.initial !== false;
|
|
50
|
+
// Track whether we're still in the initial render phase
|
|
51
|
+
// This is true only when initial={false} and we haven't completed the first frame
|
|
52
|
+
let isInitialRenderPhase = context.initial === false;
|
|
53
|
+
// Track keys that have been seen (registered at least once)
|
|
54
|
+
const seenKeys = new Set();
|
|
55
|
+
// Track keys that have exited (unregistered after being registered)
|
|
56
|
+
const exitedKeys = new Set();
|
|
57
|
+
// After first frame, mark initial render phase as complete
|
|
58
|
+
// Guard for SSR - requestAnimationFrame only exists in browser
|
|
59
|
+
if (isInitialRenderPhase && typeof window !== 'undefined') {
|
|
60
|
+
requestAnimationFrame(() => {
|
|
61
|
+
requestAnimationFrame(() => {
|
|
62
|
+
pwLog('[presence] initial render phase complete, enabling animations for new keys');
|
|
63
|
+
isInitialRenderPhase = false;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Determine if a child with the given key should animate its enter.
|
|
69
|
+
*
|
|
70
|
+
* - If we're past the initial render phase → always animate
|
|
71
|
+
* - If key has previously exited → animate (re-entry)
|
|
72
|
+
* - If key has never been seen AND we're in initial render phase → skip animation
|
|
73
|
+
*/
|
|
74
|
+
const shouldAnimateEnter = (key) => {
|
|
75
|
+
// If the key has previously exited, it's a re-entry - always animate
|
|
76
|
+
if (exitedKeys.has(key)) {
|
|
77
|
+
pwLog('[presence] shouldAnimateEnter', {
|
|
78
|
+
key,
|
|
79
|
+
result: true,
|
|
80
|
+
reason: 're-entry after exit'
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
// If we're past the initial render phase, all new entries animate
|
|
85
|
+
if (!isInitialRenderPhase) {
|
|
86
|
+
pwLog('[presence] shouldAnimateEnter', {
|
|
87
|
+
key,
|
|
88
|
+
result: true,
|
|
89
|
+
reason: 'past initial render phase'
|
|
90
|
+
});
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
// We're in initial render phase and key hasn't exited before
|
|
94
|
+
// Check if key has been seen - if not, skip animation (initial={false} behavior)
|
|
95
|
+
const hasBeenSeen = seenKeys.has(key);
|
|
96
|
+
const shouldAnimate = hasBeenSeen; // Only animate if we've seen it before (shouldn't happen in initial phase)
|
|
97
|
+
pwLog('[presence] shouldAnimateEnter', {
|
|
98
|
+
key,
|
|
99
|
+
result: shouldAnimate,
|
|
100
|
+
reason: shouldAnimate ? 'previously seen' : 'first appearance during initial render'
|
|
101
|
+
});
|
|
102
|
+
return shouldAnimate;
|
|
103
|
+
};
|
|
104
|
+
pwLog('[presence] createContext', {
|
|
105
|
+
initial,
|
|
106
|
+
isInitialRenderPhase,
|
|
107
|
+
onExitComplete: !!context.onExitComplete
|
|
108
|
+
});
|
|
47
109
|
const children = new Map();
|
|
48
110
|
// Track number of in-flight exit animations to invoke onExitComplete once
|
|
49
111
|
let inFlightExits = 0;
|
|
@@ -53,6 +115,20 @@ export function createAnimatePresenceContext(context) {
|
|
|
53
115
|
const registerChild = (key, element, exit, mergedTransition) => {
|
|
54
116
|
const initialRect = element.getBoundingClientRect();
|
|
55
117
|
const initialStyle = getComputedStyle(element);
|
|
118
|
+
// Mark this key as seen
|
|
119
|
+
const wasExited = exitedKeys.has(key);
|
|
120
|
+
seenKeys.add(key);
|
|
121
|
+
// If this key was previously exited, remove it from exitedKeys (it's re-entering)
|
|
122
|
+
if (wasExited) {
|
|
123
|
+
exitedKeys.delete(key);
|
|
124
|
+
}
|
|
125
|
+
pwLog('[presence] registerChild', {
|
|
126
|
+
key,
|
|
127
|
+
hasExit: !!exit,
|
|
128
|
+
exit,
|
|
129
|
+
wasExited,
|
|
130
|
+
rect: { w: initialRect.width, h: initialRect.height }
|
|
131
|
+
});
|
|
56
132
|
children.set(key, {
|
|
57
133
|
element,
|
|
58
134
|
exit,
|
|
@@ -77,7 +153,21 @@ export function createAnimatePresenceContext(context) {
|
|
|
77
153
|
*/
|
|
78
154
|
const unregisterChild = (key) => {
|
|
79
155
|
const child = children.get(key);
|
|
80
|
-
|
|
156
|
+
pwLog('[presence] unregisterChild', {
|
|
157
|
+
key,
|
|
158
|
+
found: !!child,
|
|
159
|
+
hasExit: !!child?.exit,
|
|
160
|
+
exit: child?.exit
|
|
161
|
+
});
|
|
162
|
+
// Only process if child was actually registered
|
|
163
|
+
if (!child) {
|
|
164
|
+
pwLog('[presence] unregisterChild - child not found, ignoring');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Mark this key as exited so re-entry will animate
|
|
168
|
+
exitedKeys.add(key);
|
|
169
|
+
if (!child.exit) {
|
|
170
|
+
pwLog('[presence] unregisterChild - no exit animation, removing immediately');
|
|
81
171
|
children.delete(key);
|
|
82
172
|
return;
|
|
83
173
|
}
|
|
@@ -143,6 +233,10 @@ export function createAnimatePresenceContext(context) {
|
|
|
143
233
|
}
|
|
144
234
|
clone.setAttribute('data-clone', 'true');
|
|
145
235
|
clone.setAttribute('data-exiting', 'true');
|
|
236
|
+
pwLog('[presence] clone created', {
|
|
237
|
+
key,
|
|
238
|
+
rect: { w: rect.width, h: rect.height, top: rect.top, left: rect.left }
|
|
239
|
+
});
|
|
146
240
|
parent.appendChild(clone);
|
|
147
241
|
// Merge transitions: default < mergedTransition < exit.transition (last wins)
|
|
148
242
|
const exitObj = (child.exit ?? {});
|
|
@@ -151,12 +245,22 @@ export function createAnimatePresenceContext(context) {
|
|
|
151
245
|
const rawExit = (child.exit ?? {});
|
|
152
246
|
/* trunk-ignore(eslint/@typescript-eslint/no-unused-vars) */
|
|
153
247
|
const { transition: _ignoredTransition, ...exitKeyframes } = rawExit;
|
|
248
|
+
pwLog('[presence] starting exit animation', {
|
|
249
|
+
key,
|
|
250
|
+
exitKeyframes,
|
|
251
|
+
finalTransition
|
|
252
|
+
});
|
|
253
|
+
// Capture the element reference for this specific exit animation
|
|
254
|
+
// This prevents race conditions where re-entry registers a new element with the same key
|
|
255
|
+
// before this exit animation completes
|
|
256
|
+
const exitingElement = child.element;
|
|
154
257
|
// Start exit and track in-flight count
|
|
155
258
|
inFlightExits += 1;
|
|
156
259
|
requestAnimationFrame(() => {
|
|
157
260
|
animate(clone, exitKeyframes, finalTransition)
|
|
158
261
|
.finished.catch(() => { })
|
|
159
262
|
.finally(() => {
|
|
263
|
+
pwLog('[presence] exit animation complete', { key });
|
|
160
264
|
// Reset elevated styles then remove
|
|
161
265
|
try {
|
|
162
266
|
clone.style.zIndex = '';
|
|
@@ -165,16 +269,45 @@ export function createAnimatePresenceContext(context) {
|
|
|
165
269
|
// ignore
|
|
166
270
|
}
|
|
167
271
|
clone.remove();
|
|
168
|
-
|
|
272
|
+
// Log clone removal and element counts for debugging rapid toggle
|
|
273
|
+
pwLog('[presence] clone REMOVED from DOM', {
|
|
274
|
+
key,
|
|
275
|
+
clonesInDOM: document.querySelectorAll('[data-clone="true"]').length,
|
|
276
|
+
boxesInDOM: document.querySelectorAll('[data-testid="box"]').length
|
|
277
|
+
});
|
|
278
|
+
// Only delete from children map if the current registration is for the SAME element
|
|
279
|
+
// If a re-entry happened while we were animating, a new element is registered
|
|
280
|
+
// and we should NOT delete it
|
|
281
|
+
const currentChild = children.get(key);
|
|
282
|
+
if (currentChild && currentChild.element === exitingElement) {
|
|
283
|
+
children.delete(key);
|
|
284
|
+
pwLog('[presence] child deleted from map (same element)', { key });
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
pwLog('[presence] child NOT deleted (re-entry registered new element)', {
|
|
288
|
+
key,
|
|
289
|
+
hasCurrentChild: !!currentChild,
|
|
290
|
+
isSameElement: currentChild?.element === exitingElement
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// Log final state
|
|
294
|
+
pwLog('[presence] element count after exit', {
|
|
295
|
+
childrenMapSize: children.size,
|
|
296
|
+
inFlightExits: inFlightExits - 1,
|
|
297
|
+
clonesInDOM: document.querySelectorAll('[data-clone="true"]').length
|
|
298
|
+
});
|
|
169
299
|
inFlightExits -= 1;
|
|
170
300
|
if (inFlightExits === 0) {
|
|
301
|
+
pwLog('[presence] all exits complete, calling onExitComplete');
|
|
171
302
|
context.onExitComplete?.();
|
|
172
303
|
}
|
|
173
304
|
});
|
|
174
305
|
});
|
|
175
306
|
};
|
|
176
307
|
return {
|
|
177
|
-
|
|
308
|
+
initial,
|
|
309
|
+
shouldAnimateEnter,
|
|
310
|
+
onExitComplete: context.onExitComplete,
|
|
178
311
|
registerChild,
|
|
179
312
|
updateChildState,
|
|
180
313
|
unregisterChild
|
|
@@ -218,11 +351,24 @@ export function setAnimatePresenceContext(context) {
|
|
|
218
351
|
*/
|
|
219
352
|
export function usePresence(key, element, exit, mergedTransition) {
|
|
220
353
|
const context = getAnimatePresenceContext();
|
|
354
|
+
pwLog('[presence] usePresence called', {
|
|
355
|
+
key,
|
|
356
|
+
hasElement: !!element,
|
|
357
|
+
hasContext: !!context,
|
|
358
|
+
hasExit: !!exit,
|
|
359
|
+
exit
|
|
360
|
+
});
|
|
221
361
|
if (element && context && exit) {
|
|
222
362
|
context.registerChild(key, element, exit, mergedTransition);
|
|
223
363
|
onDestroy(() => {
|
|
364
|
+
pwLog('[presence] onDestroy triggered', { key });
|
|
224
365
|
context.unregisterChild(key);
|
|
225
366
|
});
|
|
226
367
|
}
|
|
368
|
+
else {
|
|
369
|
+
pwLog('[presence] usePresence - skipping registration', {
|
|
370
|
+
reason: !element ? 'no element' : !context ? 'no context' : 'no exit'
|
|
371
|
+
});
|
|
372
|
+
}
|
|
227
373
|
}
|
|
228
374
|
/* c8 ignore end */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-motion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "A lightweight 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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -53,18 +53,18 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"motion": "^12.
|
|
57
|
-
"motion-dom": "^12.
|
|
56
|
+
"motion": "^12.29.2",
|
|
57
|
+
"motion-dom": "^12.29.2"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@changesets/cli": "^2.29.8",
|
|
61
|
-
"@eslint/compat": "^2.0.
|
|
61
|
+
"@eslint/compat": "^2.0.2",
|
|
62
62
|
"@eslint/js": "^9.39.2",
|
|
63
|
-
"@playwright/test": "^1.
|
|
63
|
+
"@playwright/test": "^1.58.1",
|
|
64
64
|
"@sveltejs/adapter-auto": "^7.0.0",
|
|
65
|
-
"@sveltejs/kit": "^2.
|
|
65
|
+
"@sveltejs/kit": "^2.50.1",
|
|
66
66
|
"@sveltejs/package": "^2.5.7",
|
|
67
|
-
"@sveltejs/vite-plugin-svelte": "^6.2.
|
|
67
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
68
68
|
"@tailwindcss/aspect-ratio": "^0.4.2",
|
|
69
69
|
"@tailwindcss/container-queries": "^0.1.1",
|
|
70
70
|
"@tailwindcss/forms": "^0.5.11",
|
|
@@ -72,29 +72,29 @@
|
|
|
72
72
|
"@tailwindcss/typography": "^0.5.19",
|
|
73
73
|
"@testing-library/jest-dom": "^6.9.1",
|
|
74
74
|
"@testing-library/svelte": "^5.3.1",
|
|
75
|
-
"@types/node": "^25.0
|
|
76
|
-
"@vitest/coverage-v8": "^4.0.
|
|
75
|
+
"@types/node": "^25.1.0",
|
|
76
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
77
77
|
"concurrently": "^9.2.1",
|
|
78
78
|
"eslint": "^9.39.2",
|
|
79
79
|
"eslint-config-prettier": "10.1.8",
|
|
80
80
|
"eslint-plugin-import": "2.32.0",
|
|
81
|
-
"eslint-plugin-svelte": "3.
|
|
81
|
+
"eslint-plugin-svelte": "3.14.0",
|
|
82
82
|
"eslint-plugin-unused-imports": "4.3.0",
|
|
83
83
|
"esm-env": "^1.2.2",
|
|
84
|
-
"globals": "^17.
|
|
84
|
+
"globals": "^17.2.0",
|
|
85
85
|
"html-tags": "^5.1.0",
|
|
86
86
|
"html-void-elements": "^3.0.0",
|
|
87
87
|
"husky": "^9.1.7",
|
|
88
88
|
"jsdom": "^27.4.0",
|
|
89
|
-
"prettier": "^3.
|
|
89
|
+
"prettier": "^3.8.1",
|
|
90
90
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
91
|
-
"prettier-plugin-sort-json": "^4.
|
|
91
|
+
"prettier-plugin-sort-json": "^4.2.0",
|
|
92
92
|
"prettier-plugin-svelte": "^3.4.1",
|
|
93
93
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
94
|
-
"publint": "^0.3.
|
|
94
|
+
"publint": "^0.3.17",
|
|
95
95
|
"runed": "0.37.1",
|
|
96
|
-
"svelte": "^5.
|
|
97
|
-
"svelte-check": "^4.3.
|
|
96
|
+
"svelte": "^5.49.1",
|
|
97
|
+
"svelte-check": "^4.3.6",
|
|
98
98
|
"svg-tags": "^1.0.0",
|
|
99
99
|
"tailwind-merge": "^3.4.0",
|
|
100
100
|
"tailwind-variants": "^3.2.2",
|
|
@@ -102,16 +102,16 @@
|
|
|
102
102
|
"tailwindcss-animate": "^1.0.7",
|
|
103
103
|
"tsx": "^4.21.0",
|
|
104
104
|
"typescript": "^5.9.3",
|
|
105
|
-
"typescript-eslint": "^8.
|
|
106
|
-
"vite": "^7.3.
|
|
107
|
-
"vite-tsconfig-paths": "^6.0.
|
|
108
|
-
"vitest": "^4.0.
|
|
105
|
+
"typescript-eslint": "^8.54.0",
|
|
106
|
+
"vite": "^7.3.1",
|
|
107
|
+
"vite-tsconfig-paths": "^6.0.5",
|
|
108
|
+
"vitest": "^4.0.18"
|
|
109
109
|
},
|
|
110
110
|
"peerDependencies": {
|
|
111
111
|
"svelte": "^5.0.0"
|
|
112
112
|
},
|
|
113
113
|
"volta": {
|
|
114
|
-
"node": "
|
|
114
|
+
"node": "24.12.0"
|
|
115
115
|
},
|
|
116
116
|
"publishConfig": {
|
|
117
117
|
"access": "public"
|