@humanspeak/svelte-motion 0.3.3 → 0.3.5
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/dist/components/PresenceChild.svelte +124 -0
- package/dist/components/PresenceChild.svelte.d.ts +8 -0
- package/dist/html/_MotionContainer.svelte +15 -7
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -1
- package/dist/utils/animate.svelte.d.ts +66 -0
- package/dist/utils/animate.svelte.js +66 -0
- package/dist/utils/presence.d.ts +38 -11
- package/dist/utils/presence.js +80 -54
- package/dist/utils/usePresence.d.ts +71 -0
- package/dist/utils/usePresence.js +74 -0
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import {
|
|
4
|
+
getAnimatePresenceContext,
|
|
5
|
+
getPresenceDepth,
|
|
6
|
+
setPresenceChildContext,
|
|
7
|
+
setPresenceDepth
|
|
8
|
+
} from '../utils/presence'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Holds its `children` snippet rendered until the consumer signals the
|
|
12
|
+
* exit is complete via `safeToRemove()`. Lets a child run its own (non-
|
|
13
|
+
* `motion.*`) exit animation — fade via CSS transition, canvas effect,
|
|
14
|
+
* GSAP, etc — while still participating in `AnimatePresence`'s
|
|
15
|
+
* `onExitComplete` accounting and `mode='wait'` enter blocking.
|
|
16
|
+
*
|
|
17
|
+
* Children read `useIsPresent()` or `usePresence()` to observe the
|
|
18
|
+
* exit-phase flip.
|
|
19
|
+
*
|
|
20
|
+
* @prop present Bind to the same boolean that conditionally rendered this
|
|
21
|
+
* wrapper. When it flips to `false`, the wrapper holds children
|
|
22
|
+
* mounted with `isPresent = false` until `safeToRemove` fires.
|
|
23
|
+
* @prop children Snippet rendered while present or held.
|
|
24
|
+
*/
|
|
25
|
+
const { present = true, children } = $props<{
|
|
26
|
+
present?: boolean
|
|
27
|
+
children?: Snippet
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const animatePresence = getAnimatePresenceContext()
|
|
31
|
+
const presenceDepth = getPresenceDepth()
|
|
32
|
+
|
|
33
|
+
// Descendants of PresenceChild are no longer at depth 0, so any motion.*
|
|
34
|
+
// children inside don't trip the "direct children of AnimatePresence need
|
|
35
|
+
// a key" check in _MotionContainer.
|
|
36
|
+
if (presenceDepth !== undefined) {
|
|
37
|
+
setPresenceDepth(presenceDepth + 1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Three-phase exit lifecycle:
|
|
41
|
+
// 'idle' — no exit in progress; render iff `present`.
|
|
42
|
+
// 'holding' — `present` flipped false; we're still rendering with
|
|
43
|
+
// isPresent=false, waiting for the consumer to call
|
|
44
|
+
// safeToRemove.
|
|
45
|
+
// 'completed' — consumer signaled completion. Stop rendering. Returning
|
|
46
|
+
// to 'idle' only happens if `present` flips back to true.
|
|
47
|
+
type ExitPhase = 'idle' | 'holding' | 'completed'
|
|
48
|
+
let phase = $state<ExitPhase>('idle')
|
|
49
|
+
// Track the prior `present` value so we only start an exit on a true→false
|
|
50
|
+
// transition, not when mounted with `present=false` (a no-op steady state).
|
|
51
|
+
// Using a closure variable inside $effect.pre below — see initial value
|
|
52
|
+
// capture there.
|
|
53
|
+
let prevPresent: boolean | undefined = undefined
|
|
54
|
+
|
|
55
|
+
// Each exit start mints a fresh `safeToRemove` closure bound to the cycle
|
|
56
|
+
// it was minted for. Re-entry / completion invalidates older closures so a
|
|
57
|
+
// captured-then-late-fired callback (setTimeout, external lib, stray
|
|
58
|
+
// listener after cleanup) from cycle A cannot complete cycle B.
|
|
59
|
+
let currentSafeToRemove: () => void = noopSafeToRemove
|
|
60
|
+
|
|
61
|
+
function noopSafeToRemove() {}
|
|
62
|
+
|
|
63
|
+
function mintSafeToRemove(): () => void {
|
|
64
|
+
// Closure compares against its own identity (`self`) to detect
|
|
65
|
+
// whether it's still the active cycle's callback. After re-entry or
|
|
66
|
+
// completion, `currentSafeToRemove` no longer points at `self`, so
|
|
67
|
+
// this branch no-ops — a stale capture cannot complete a later exit.
|
|
68
|
+
const self: () => void = () => {
|
|
69
|
+
if (currentSafeToRemove !== self || phase !== 'holding') return
|
|
70
|
+
phase = 'completed'
|
|
71
|
+
currentSafeToRemove = noopSafeToRemove
|
|
72
|
+
animatePresence?.notifyExitComplete()
|
|
73
|
+
}
|
|
74
|
+
return self
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isPresent = $derived(present && phase === 'idle')
|
|
78
|
+
|
|
79
|
+
setPresenceChildContext({
|
|
80
|
+
get isPresent() {
|
|
81
|
+
return isPresent
|
|
82
|
+
},
|
|
83
|
+
get safeToRemove() {
|
|
84
|
+
return currentSafeToRemove
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// $effect.pre runs before DOM updates so the phase transition lands in
|
|
89
|
+
// the same render pass as the `present` prop flip — otherwise the {#if}
|
|
90
|
+
// below re-evaluates with stale phase and unmounts/remounts the children,
|
|
91
|
+
// which (a) breaks any CSS transition the consumer relies on for exit and
|
|
92
|
+
// (b) makes `bind:this` references go stale.
|
|
93
|
+
$effect.pre(() => {
|
|
94
|
+
// Read `present` reactively first; assignments to module locals
|
|
95
|
+
// happen inside the branches below.
|
|
96
|
+
const current = present
|
|
97
|
+
// First run: just record the steady state. Don't fire any exit/enter
|
|
98
|
+
// signal — there's no transition to react to yet.
|
|
99
|
+
if (prevPresent === undefined) {
|
|
100
|
+
prevPresent = current
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (prevPresent && !current && phase === 'idle') {
|
|
104
|
+
phase = 'holding'
|
|
105
|
+
currentSafeToRemove = mintSafeToRemove()
|
|
106
|
+
animatePresence?.notifyExitStart()
|
|
107
|
+
} else if (!prevPresent && current && phase === 'holding') {
|
|
108
|
+
// Re-entry mid-hold cancels the exit accounting. Replacing the
|
|
109
|
+
// slot invalidates the cycle's `safeToRemove` for any consumer
|
|
110
|
+
// that captured it.
|
|
111
|
+
phase = 'idle'
|
|
112
|
+
currentSafeToRemove = noopSafeToRemove
|
|
113
|
+
animatePresence?.notifyExitComplete()
|
|
114
|
+
} else if (!prevPresent && current && phase === 'completed') {
|
|
115
|
+
// Re-mounted after a previous exit fully completed.
|
|
116
|
+
phase = 'idle'
|
|
117
|
+
}
|
|
118
|
+
prevPresent = current
|
|
119
|
+
})
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
{#if present || phase === 'holding'}
|
|
123
|
+
{@render children?.()}
|
|
124
|
+
{/if}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
present?: boolean;
|
|
4
|
+
children?: Snippet;
|
|
5
|
+
};
|
|
6
|
+
declare const PresenceChild: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type PresenceChild = ReturnType<typeof PresenceChild>;
|
|
8
|
+
export default PresenceChild;
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
import { isNativelyFocusable } from '../utils/a11y'
|
|
43
43
|
import {
|
|
44
44
|
getAnimatePresenceContext,
|
|
45
|
+
getPresenceChildContext,
|
|
45
46
|
getPresenceDepth,
|
|
46
47
|
setPresenceDepth
|
|
47
48
|
} from '../utils/presence'
|
|
@@ -127,6 +128,12 @@
|
|
|
127
128
|
|
|
128
129
|
// Get presence context to check if we're inside AnimatePresence
|
|
129
130
|
const context = getAnimatePresenceContext()
|
|
131
|
+
// Inside a <PresenceChild>, the wrapper drives the exit. Skip the
|
|
132
|
+
// clone-based exit registration on this element so we don't double-fire
|
|
133
|
+
// (custom exit, then clone of a node the wrapper already let go).
|
|
134
|
+
// Enter-side coordination (shouldAnimateEnter, mode='wait' blocking)
|
|
135
|
+
// remains active so the element still slots into the outer presence flow.
|
|
136
|
+
const inPresenceChild = !!getPresenceChildContext()
|
|
130
137
|
|
|
131
138
|
// Get layoutId registry (provided by AnimatePresence or a parent LayoutGroup)
|
|
132
139
|
const layoutIdRegistry = getLayoutIdRegistry()
|
|
@@ -168,9 +175,8 @@
|
|
|
168
175
|
)
|
|
169
176
|
|
|
170
177
|
// Register onDestroy at component level (guaranteed to work in Svelte 5)
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
if (context) {
|
|
178
|
+
// — getContext()/onDestroy() must run during component initialization.
|
|
179
|
+
if (context && !inPresenceChild) {
|
|
174
180
|
onDestroy(() => {
|
|
175
181
|
pwLog('[presence] onDestroy triggered', { key: presenceKey })
|
|
176
182
|
context.unregisterChild(presenceKey)
|
|
@@ -181,8 +187,9 @@
|
|
|
181
187
|
// from the correct visual state. Without this, interrupting an enter animation
|
|
182
188
|
// causes the exit to snap (the element is disconnected before onDestroy, so
|
|
183
189
|
// getAnimations()/commitStyles() can't work at clone time).
|
|
190
|
+
// Skipped inside <PresenceChild>: the wrapper drives exit, no clone path.
|
|
184
191
|
$effect(() => {
|
|
185
|
-
if (!(element && context)) return
|
|
192
|
+
if (!(element && context) || inPresenceChild) return
|
|
186
193
|
let rafId: number
|
|
187
194
|
const capture = () => {
|
|
188
195
|
if (element && element.isConnected && element.getAnimations().length > 0) {
|
|
@@ -227,7 +234,7 @@
|
|
|
227
234
|
|
|
228
235
|
// Reactively update registration when element/exit/transition props change
|
|
229
236
|
$effect(() => {
|
|
230
|
-
if (element && context && resolvedExit) {
|
|
237
|
+
if (element && context && !inPresenceChild && resolvedExit) {
|
|
231
238
|
const filteredExit = filterReducedMotionKeyframes(
|
|
232
239
|
resolvedExit as Record<string, unknown>,
|
|
233
240
|
reducedMotion
|
|
@@ -241,9 +248,10 @@
|
|
|
241
248
|
}
|
|
242
249
|
})
|
|
243
250
|
|
|
244
|
-
// Update presence context with current state when element is ready and has size
|
|
251
|
+
// Update presence context with current state when element is ready and has size.
|
|
252
|
+
// Skipped inside <PresenceChild> — the rect/style snapshot only feeds the clone path.
|
|
245
253
|
$effect(() => {
|
|
246
|
-
if (!(context && element && isLoaded === 'ready')) return
|
|
254
|
+
if (!(context && element && isLoaded === 'ready') || inPresenceChild) return
|
|
247
255
|
|
|
248
256
|
let lastWidth = 0
|
|
249
257
|
let lastHeight = 0
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import AnimatePresence from './components/AnimatePresence.svelte';
|
|
2
2
|
import MotionConfig from './components/MotionConfig.svelte';
|
|
3
|
+
import PresenceChild from './components/PresenceChild.svelte';
|
|
3
4
|
export { motion } from './motion';
|
|
4
5
|
export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
|
|
5
6
|
export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
|
|
6
7
|
export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } from 'motion';
|
|
7
8
|
export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ReducedMotionConfig, Variants } from './types';
|
|
9
|
+
export { useAnimate } from './utils/animate.svelte';
|
|
10
|
+
export type { AnimationScope } from './utils/animate.svelte';
|
|
8
11
|
export { useAnimationFrame } from './utils/animationFrame';
|
|
9
12
|
export { useCycle } from './utils/cycle';
|
|
10
13
|
export type { Cycle, CycleState } from './utils/cycle';
|
|
@@ -19,6 +22,8 @@ export { useReducedMotion } from './utils/reducedMotion';
|
|
|
19
22
|
export { useReducedMotionConfig } from './utils/reducedMotionConfig';
|
|
20
23
|
export { useScroll } from './utils/scroll';
|
|
21
24
|
export { useSpring } from './utils/spring';
|
|
25
|
+
export { useIsPresent, usePresence } from './utils/usePresence';
|
|
26
|
+
export type { UsePresenceState } from './utils/usePresence';
|
|
22
27
|
export { useVelocity } from './utils/velocity';
|
|
23
28
|
/**
|
|
24
29
|
* @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
|
|
@@ -27,7 +32,7 @@ export { stringifyStyleObject } from './utils/styleObject';
|
|
|
27
32
|
export { styleString } from './utils/styleObject.svelte';
|
|
28
33
|
export { useTime } from './utils/time';
|
|
29
34
|
export { useTransform } from './utils/transform';
|
|
30
|
-
export { AnimatePresence, MotionConfig };
|
|
35
|
+
export { AnimatePresence, MotionConfig, PresenceChild };
|
|
31
36
|
export { default as MotionA } from './html/A.svelte';
|
|
32
37
|
export { default as MotionAbbr } from './html/Abbr.svelte';
|
|
33
38
|
export { default as MotionAddress } from './html/Address.svelte';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AnimatePresence from './components/AnimatePresence.svelte';
|
|
2
2
|
import MotionConfig from './components/MotionConfig.svelte';
|
|
3
|
+
import PresenceChild from './components/PresenceChild.svelte';
|
|
3
4
|
export { motion } from './motion';
|
|
4
5
|
// Re-export core animation functions from motion
|
|
5
6
|
export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
|
|
@@ -7,6 +8,7 @@ export { animate, delay, hover, inView, press, resize, scroll, stagger, transfor
|
|
|
7
8
|
export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
|
|
8
9
|
// Re-export utility functions
|
|
9
10
|
export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } from 'motion';
|
|
11
|
+
export { useAnimate } from './utils/animate.svelte';
|
|
10
12
|
export { useAnimationFrame } from './utils/animationFrame';
|
|
11
13
|
export { useCycle } from './utils/cycle';
|
|
12
14
|
export { createDragControls } from './utils/dragControls';
|
|
@@ -18,6 +20,7 @@ export { useReducedMotion } from './utils/reducedMotion';
|
|
|
18
20
|
export { useReducedMotionConfig } from './utils/reducedMotionConfig';
|
|
19
21
|
export { useScroll } from './utils/scroll';
|
|
20
22
|
export { useSpring } from './utils/spring';
|
|
23
|
+
export { useIsPresent, usePresence } from './utils/usePresence';
|
|
21
24
|
export { useVelocity } from './utils/velocity';
|
|
22
25
|
/**
|
|
23
26
|
* @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
|
|
@@ -26,7 +29,7 @@ export { stringifyStyleObject } from './utils/styleObject';
|
|
|
26
29
|
export { styleString } from './utils/styleObject.svelte';
|
|
27
30
|
export { useTime } from './utils/time';
|
|
28
31
|
export { useTransform } from './utils/transform';
|
|
29
|
-
export { AnimatePresence, MotionConfig };
|
|
32
|
+
export { AnimatePresence, MotionConfig, PresenceChild };
|
|
30
33
|
// Named component exports — tree-shakeable alternative to the `motion` object
|
|
31
34
|
export { default as MotionA } from './html/A.svelte';
|
|
32
35
|
export { default as MotionAbbr } from './html/Abbr.svelte';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createScopedAnimate } from 'motion';
|
|
2
|
+
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
|
|
3
|
+
/**
|
|
4
|
+
* The scope returned by {@link useAnimate}. Functions as a Svelte 5
|
|
5
|
+
* attachment — pass it as `{@attach scope}` on the parent element so the
|
|
6
|
+
* scoped `animate` function can resolve string selectors against it.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors framer-motion's `AnimationScope`. `current` is hydrated once the
|
|
9
|
+
* attachment runs; `animations` tracks every in-flight animation started
|
|
10
|
+
* through the scoped `animate` so they can be stopped together when the
|
|
11
|
+
* element detaches.
|
|
12
|
+
*/
|
|
13
|
+
export type AnimationScope<T extends Element = HTMLElement> = ((node: T) => () => void) & {
|
|
14
|
+
/** The parent element, populated when the attachment fires. `undefined` before mount or after detach. */
|
|
15
|
+
current: T | undefined;
|
|
16
|
+
/** Animations currently scoped to this element. */
|
|
17
|
+
animations: AnimationPlaybackControlsWithThen[];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Imperative animation API mirroring framer-motion's `useAnimate`. Returns a
|
|
21
|
+
* tuple `[scope, animate]`:
|
|
22
|
+
*
|
|
23
|
+
* - `scope` is a Svelte 5 attachment: spread it on the parent element with
|
|
24
|
+
* `{@attach scope}`. Once mounted, `scope.current` is the element and
|
|
25
|
+
* `animate('selector', ...)` resolves selectors against it.
|
|
26
|
+
* - `animate(target, keyframes, transition)` accepts the same overloads as
|
|
27
|
+
* motion's standalone `animate` — strings, elements, motion values, and
|
|
28
|
+
* sequences.
|
|
29
|
+
*
|
|
30
|
+
* When the attached element detaches, every animation started through the
|
|
31
|
+
* scoped `animate` is stopped and `scope.animations` is cleared.
|
|
32
|
+
*
|
|
33
|
+
* `animate` ignores calls before the attachment fires — `scope.current` is
|
|
34
|
+
* still `undefined`, and motion throws when asked to query selectors against
|
|
35
|
+
* a missing root. Trigger animations from user events or `$effect` after
|
|
36
|
+
* mount.
|
|
37
|
+
*
|
|
38
|
+
* @template T The parent element type. Defaults to `HTMLElement`.
|
|
39
|
+
* @returns A `[scope, animate]` tuple.
|
|
40
|
+
* @see https://motion.dev/docs/react-use-animate
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```svelte
|
|
44
|
+
* <script lang="ts">
|
|
45
|
+
* import { useAnimate } from '@humanspeak/svelte-motion'
|
|
46
|
+
*
|
|
47
|
+
* const [scope, animate] = useAnimate()
|
|
48
|
+
*
|
|
49
|
+
* const run = () =>
|
|
50
|
+
* animate(
|
|
51
|
+
* [
|
|
52
|
+
* ['li', { opacity: 1, x: 0 }, { delay: stagger(0.1) }],
|
|
53
|
+
* ['button', { scale: 1.05 }, { at: '-0.2' }]
|
|
54
|
+
* ]
|
|
55
|
+
* )
|
|
56
|
+
* </script>
|
|
57
|
+
*
|
|
58
|
+
* <ul {@attach scope}>
|
|
59
|
+
* <li>One</li>
|
|
60
|
+
* <li>Two</li>
|
|
61
|
+
* <li>Three</li>
|
|
62
|
+
* </ul>
|
|
63
|
+
* <button onclick={run}>Animate</button>
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare const useAnimate: <T extends Element = HTMLElement>() => [AnimationScope<T>, ReturnType<typeof createScopedAnimate>];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createScopedAnimate } from 'motion';
|
|
2
|
+
/**
|
|
3
|
+
* Imperative animation API mirroring framer-motion's `useAnimate`. Returns a
|
|
4
|
+
* tuple `[scope, animate]`:
|
|
5
|
+
*
|
|
6
|
+
* - `scope` is a Svelte 5 attachment: spread it on the parent element with
|
|
7
|
+
* `{@attach scope}`. Once mounted, `scope.current` is the element and
|
|
8
|
+
* `animate('selector', ...)` resolves selectors against it.
|
|
9
|
+
* - `animate(target, keyframes, transition)` accepts the same overloads as
|
|
10
|
+
* motion's standalone `animate` — strings, elements, motion values, and
|
|
11
|
+
* sequences.
|
|
12
|
+
*
|
|
13
|
+
* When the attached element detaches, every animation started through the
|
|
14
|
+
* scoped `animate` is stopped and `scope.animations` is cleared.
|
|
15
|
+
*
|
|
16
|
+
* `animate` ignores calls before the attachment fires — `scope.current` is
|
|
17
|
+
* still `undefined`, and motion throws when asked to query selectors against
|
|
18
|
+
* a missing root. Trigger animations from user events or `$effect` after
|
|
19
|
+
* mount.
|
|
20
|
+
*
|
|
21
|
+
* @template T The parent element type. Defaults to `HTMLElement`.
|
|
22
|
+
* @returns A `[scope, animate]` tuple.
|
|
23
|
+
* @see https://motion.dev/docs/react-use-animate
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```svelte
|
|
27
|
+
* <script lang="ts">
|
|
28
|
+
* import { useAnimate } from '@humanspeak/svelte-motion'
|
|
29
|
+
*
|
|
30
|
+
* const [scope, animate] = useAnimate()
|
|
31
|
+
*
|
|
32
|
+
* const run = () =>
|
|
33
|
+
* animate(
|
|
34
|
+
* [
|
|
35
|
+
* ['li', { opacity: 1, x: 0 }, { delay: stagger(0.1) }],
|
|
36
|
+
* ['button', { scale: 1.05 }, { at: '-0.2' }]
|
|
37
|
+
* ]
|
|
38
|
+
* )
|
|
39
|
+
* </script>
|
|
40
|
+
*
|
|
41
|
+
* <ul {@attach scope}>
|
|
42
|
+
* <li>One</li>
|
|
43
|
+
* <li>Two</li>
|
|
44
|
+
* <li>Three</li>
|
|
45
|
+
* </ul>
|
|
46
|
+
* <button onclick={run}>Animate</button>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export const useAnimate = () => {
|
|
50
|
+
const scope = ((node) => {
|
|
51
|
+
scope.current = node;
|
|
52
|
+
return () => {
|
|
53
|
+
for (const animation of scope.animations) {
|
|
54
|
+
animation.stop();
|
|
55
|
+
}
|
|
56
|
+
scope.animations.length = 0;
|
|
57
|
+
scope.current = undefined;
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
scope.current = undefined;
|
|
61
|
+
scope.animations = [];
|
|
62
|
+
const animate = createScopedAnimate({
|
|
63
|
+
scope: scope
|
|
64
|
+
});
|
|
65
|
+
return [scope, animate];
|
|
66
|
+
};
|
package/dist/utils/presence.d.ts
CHANGED
|
@@ -35,6 +35,19 @@ export type AnimatePresenceContext = {
|
|
|
35
35
|
updateChildAnimatedStyle: (key: string, opacity: string, transform: string) => void;
|
|
36
36
|
/** Unregister a child. If it has an exit, clone and animate it out. */
|
|
37
37
|
unregisterChild: (key: string) => void;
|
|
38
|
+
/**
|
|
39
|
+
* @internal Used by `PresenceChild` to participate in the same exit
|
|
40
|
+
* accounting as the clone-based motion-element exit path. Increments the
|
|
41
|
+
* in-flight exit counter and applies mode='wait' enter blocking. Not
|
|
42
|
+
* intended for direct consumer use.
|
|
43
|
+
*/
|
|
44
|
+
notifyExitStart: () => void;
|
|
45
|
+
/**
|
|
46
|
+
* @internal Pairs with `notifyExitStart`. Decrements the in-flight exit
|
|
47
|
+
* counter, fires `onExitComplete` once it reaches zero, and unblocks
|
|
48
|
+
* pending enters in mode='wait'. Not intended for direct consumer use.
|
|
49
|
+
*/
|
|
50
|
+
notifyExitComplete: () => void;
|
|
38
51
|
};
|
|
39
52
|
/**
|
|
40
53
|
* Create a new `AnimatePresence` context instance.
|
|
@@ -113,20 +126,34 @@ export declare const getPresenceDepth: () => number | undefined;
|
|
|
113
126
|
*/
|
|
114
127
|
export declare const setPresenceDepth: (depth: number) => void;
|
|
115
128
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
129
|
+
* Per-`PresenceChild` Svelte context payload. Read by the `useIsPresent` and
|
|
130
|
+
* `usePresence` hooks (and consulted by motion elements so they can opt out of
|
|
131
|
+
* the outer `AnimatePresence` clone path when a `PresenceChild` is driving
|
|
132
|
+
* the exit themselves).
|
|
118
133
|
*
|
|
119
|
-
*
|
|
134
|
+
* `isPresent` is exposed as a getter so consumers see live updates as the
|
|
135
|
+
* wrapper toggles between mounted, exiting, and re-entered states.
|
|
120
136
|
*/
|
|
137
|
+
export type PresenceChildContext = {
|
|
138
|
+
/** Reactive flag — `true` while present, `false` once the exit hold begins. */
|
|
139
|
+
readonly isPresent: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Signal that the consumer's exit work is complete. Triggers actual
|
|
142
|
+
* unmount and decrements the parent `AnimatePresenceContext` exit count.
|
|
143
|
+
* Idempotent and versioned (calls from a canceled exit cycle are no-ops).
|
|
144
|
+
*/
|
|
145
|
+
safeToRemove: () => void;
|
|
146
|
+
};
|
|
121
147
|
/**
|
|
122
|
-
*
|
|
148
|
+
* Get the nearest `PresenceChild` context from Svelte component context, or
|
|
149
|
+
* `undefined` if the caller is not wrapped in one.
|
|
123
150
|
*
|
|
124
|
-
*
|
|
125
|
-
|
|
151
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
152
|
+
*/
|
|
153
|
+
export declare const getPresenceChildContext: () => PresenceChildContext | undefined;
|
|
154
|
+
/**
|
|
155
|
+
* Install a `PresenceChild` context for descendants.
|
|
126
156
|
*
|
|
127
|
-
*
|
|
128
|
-
* @param element The DOM element to track.
|
|
129
|
-
* @param exit The exit keyframes definition.
|
|
130
|
-
* @param mergedTransition The element's merged transition for precedence.
|
|
157
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
131
158
|
*/
|
|
132
|
-
export declare const
|
|
159
|
+
export declare const setPresenceChildContext: (context: PresenceChildContext) => void;
|
package/dist/utils/presence.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mergeTransitions } from './animation';
|
|
2
2
|
import { pwLog } from './log';
|
|
3
3
|
import { animate } from 'motion';
|
|
4
|
-
import { getContext,
|
|
4
|
+
import { getContext, setContext } from 'svelte';
|
|
5
5
|
/**
|
|
6
6
|
* Context key for `AnimatePresence`.
|
|
7
7
|
*
|
|
@@ -195,6 +195,66 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
195
195
|
const children = new Map();
|
|
196
196
|
// Track number of in-flight exit animations to invoke onExitComplete once
|
|
197
197
|
let inFlightExits = 0;
|
|
198
|
+
/**
|
|
199
|
+
* Begin tracking an exit.
|
|
200
|
+
*
|
|
201
|
+
* Increments the `inFlightExits` counter and, in `mode='wait'`, raises the
|
|
202
|
+
* `enterBlocked` flag so sibling motion-element enters defer until every
|
|
203
|
+
* exit reports back via {@link finishExit}. Shared by the clone-based exit
|
|
204
|
+
* path in {@link unregisterChild} and the user-driven `PresenceChild` hold.
|
|
205
|
+
*
|
|
206
|
+
* Must be paired with exactly one {@link finishExit} call per invocation.
|
|
207
|
+
*
|
|
208
|
+
* @returns void
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* // unregisterChild (clone path)
|
|
212
|
+
* startExit()
|
|
213
|
+
* requestAnimationFrame(() => {
|
|
214
|
+
* animate(clone, exitKeyframes, transition).finished.finally(finishExit)
|
|
215
|
+
* })
|
|
216
|
+
*
|
|
217
|
+
* // PresenceChild (user-driven path) — exposed as `notifyExitStart`
|
|
218
|
+
* presenceContext.notifyExitStart()
|
|
219
|
+
* // ... later, on transitionend or user signal ...
|
|
220
|
+
* presenceContext.notifyExitComplete()
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
const startExit = () => {
|
|
224
|
+
if (mode === 'wait') {
|
|
225
|
+
enterBlocked = true;
|
|
226
|
+
}
|
|
227
|
+
inFlightExits += 1;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Mark an exit as finished.
|
|
231
|
+
*
|
|
232
|
+
* Decrements the `inFlightExits` counter. When the count reaches zero,
|
|
233
|
+
* fires the consumer's `onExitComplete` callback and, in `mode='wait'`,
|
|
234
|
+
* lowers `enterBlocked` plus notifies any deferred-enter callbacks
|
|
235
|
+
* registered via {@link onEnterUnblocked}.
|
|
236
|
+
*
|
|
237
|
+
* Must be called exactly once per matching {@link startExit}; double-fires
|
|
238
|
+
* underflow the counter and can permanently mis-route subsequent exits.
|
|
239
|
+
*
|
|
240
|
+
* @returns void
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* startExit()
|
|
244
|
+
* // ... exit work ...
|
|
245
|
+
* finishExit() // fires onExitComplete if the last exit, unblocks waiters
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
const finishExit = () => {
|
|
249
|
+
inFlightExits -= 1;
|
|
250
|
+
if (inFlightExits === 0) {
|
|
251
|
+
context.onExitComplete?.();
|
|
252
|
+
if (mode === 'wait' && enterBlocked) {
|
|
253
|
+
enterBlocked = false;
|
|
254
|
+
notifyEnterUnblocked();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
198
258
|
/**
|
|
199
259
|
* Register a child element and snapshot its initial rect/styles.
|
|
200
260
|
*/
|
|
@@ -275,11 +335,6 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
275
335
|
children.delete(key);
|
|
276
336
|
return;
|
|
277
337
|
}
|
|
278
|
-
// For mode='wait': block new enters while exit is in progress
|
|
279
|
-
if (mode === 'wait') {
|
|
280
|
-
enterBlocked = true;
|
|
281
|
-
pwLog('[presence] mode=wait: blocking enters during exit');
|
|
282
|
-
}
|
|
283
338
|
const rect = child.lastRect;
|
|
284
339
|
const computed = child.lastComputedStyle;
|
|
285
340
|
// For sync/wait, preserve layout by inserting a hidden placeholder.
|
|
@@ -415,8 +470,8 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
415
470
|
// This prevents race conditions where re-entry registers a new element with the same key
|
|
416
471
|
// before this exit animation completes
|
|
417
472
|
const exitingElement = child.element;
|
|
418
|
-
// Start exit and track in-flight count
|
|
419
|
-
|
|
473
|
+
// Start exit and track in-flight count (handles wait-mode blocking)
|
|
474
|
+
startExit();
|
|
420
475
|
requestAnimationFrame(() => {
|
|
421
476
|
animate(clone, exitKeyframes, finalTransition)
|
|
422
477
|
.finished.catch(() => { })
|
|
@@ -459,17 +514,7 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
459
514
|
inFlightExits: inFlightExits - 1,
|
|
460
515
|
clonesInDOM: document.querySelectorAll('[data-clone="true"]').length
|
|
461
516
|
});
|
|
462
|
-
|
|
463
|
-
if (inFlightExits === 0) {
|
|
464
|
-
pwLog('[presence] all exits complete, calling onExitComplete');
|
|
465
|
-
context.onExitComplete?.();
|
|
466
|
-
// For mode='wait': unblock enters now that all exits are complete
|
|
467
|
-
if (mode === 'wait' && enterBlocked) {
|
|
468
|
-
enterBlocked = false;
|
|
469
|
-
pwLog('[presence] mode=wait: unblocking enters, notifying callbacks');
|
|
470
|
-
notifyEnterUnblocked();
|
|
471
|
-
}
|
|
472
|
-
}
|
|
517
|
+
finishExit();
|
|
473
518
|
});
|
|
474
519
|
});
|
|
475
520
|
};
|
|
@@ -483,7 +528,9 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
483
528
|
registerChild,
|
|
484
529
|
updateChildState,
|
|
485
530
|
updateChildAnimatedStyle,
|
|
486
|
-
unregisterChild
|
|
531
|
+
unregisterChild,
|
|
532
|
+
notifyExitStart: startExit,
|
|
533
|
+
notifyExitComplete: finishExit
|
|
487
534
|
};
|
|
488
535
|
};
|
|
489
536
|
/**
|
|
@@ -547,44 +594,23 @@ export const getPresenceDepth = () => getContext(PRESENCE_DEPTH_CONTEXT);
|
|
|
547
594
|
export const setPresenceDepth = (depth) => {
|
|
548
595
|
setContext(PRESENCE_DEPTH_CONTEXT, depth);
|
|
549
596
|
};
|
|
597
|
+
const PRESENCE_CHILD_CONTEXT = Symbol('presence-child-context');
|
|
550
598
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
599
|
+
* Get the nearest `PresenceChild` context from Svelte component context, or
|
|
600
|
+
* `undefined` if the caller is not wrapped in one.
|
|
553
601
|
*
|
|
554
|
-
* Note:
|
|
602
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
555
603
|
*/
|
|
556
|
-
/* c8 ignore
|
|
604
|
+
/* c8 ignore next 3 */
|
|
605
|
+
export const getPresenceChildContext = () => {
|
|
606
|
+
return getContext(PRESENCE_CHILD_CONTEXT);
|
|
607
|
+
};
|
|
557
608
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
* Registers the element with the presence context and guarantees that the
|
|
561
|
-
* exit animation is scheduled on teardown.
|
|
609
|
+
* Install a `PresenceChild` context for descendants.
|
|
562
610
|
*
|
|
563
|
-
*
|
|
564
|
-
* @param element The DOM element to track.
|
|
565
|
-
* @param exit The exit keyframes definition.
|
|
566
|
-
* @param mergedTransition The element's merged transition for precedence.
|
|
611
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
567
612
|
*/
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
key,
|
|
572
|
-
hasElement: !!element,
|
|
573
|
-
hasContext: !!context,
|
|
574
|
-
hasExit: !!exit,
|
|
575
|
-
exit
|
|
576
|
-
});
|
|
577
|
-
if (element && context && exit) {
|
|
578
|
-
context.registerChild(key, element, exit, mergedTransition);
|
|
579
|
-
onDestroy(() => {
|
|
580
|
-
pwLog('[presence] onDestroy triggered', { key });
|
|
581
|
-
context.unregisterChild(key);
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
else {
|
|
585
|
-
pwLog('[presence] usePresence - skipping registration', {
|
|
586
|
-
reason: !element ? 'no element' : !context ? 'no context' : 'no exit'
|
|
587
|
-
});
|
|
588
|
-
}
|
|
613
|
+
/* c8 ignore next 3 */
|
|
614
|
+
export const setPresenceChildContext = (context) => {
|
|
615
|
+
setContext(PRESENCE_CHILD_CONTEXT, context);
|
|
589
616
|
};
|
|
590
|
-
/* c8 ignore end */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuple returned by {@link usePresence}, matching framer-motion's shape:
|
|
3
|
+
* `[true, null]` while present (or when not inside a `PresenceChild`), and
|
|
4
|
+
* `[false, () => void]` after the wrapper enters its exit hold.
|
|
5
|
+
*/
|
|
6
|
+
export type UsePresenceState = [true, null] | [false, () => void];
|
|
7
|
+
/**
|
|
8
|
+
* Returns whether the calling component is currently present in its parent
|
|
9
|
+
* `<PresenceChild>`. While the wrapper holds the component for an exit, this
|
|
10
|
+
* flips to `false` so the consumer can branch (render different markup, run
|
|
11
|
+
* a custom exit animation, etc.).
|
|
12
|
+
*
|
|
13
|
+
* Outside of a `<PresenceChild>` always returns `true`.
|
|
14
|
+
*
|
|
15
|
+
* Reactivity note: the boolean tracks the wrapper's state and updates in
|
|
16
|
+
* Svelte 5 reactive contexts (`$derived`, `$effect`, template). For non-
|
|
17
|
+
* reactive snapshots, prefer `usePresence()` which exposes the same state
|
|
18
|
+
* alongside the `safeToRemove` callback.
|
|
19
|
+
*
|
|
20
|
+
* @returns `true` while present, `false` while exiting.
|
|
21
|
+
* @see https://motion.dev/docs/react-use-is-present
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```svelte
|
|
25
|
+
* <script lang="ts">
|
|
26
|
+
* import { useIsPresent } from '@humanspeak/svelte-motion'
|
|
27
|
+
* const isPresent = $derived(useIsPresent())
|
|
28
|
+
* </script>
|
|
29
|
+
* <div class:exiting={!isPresent}>{isPresent ? 'live' : 'goodbye'}</div>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare const useIsPresent: () => boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Returns `[isPresent, safeToRemove]`. `isPresent` reflects the wrapper's
|
|
35
|
+
* presence state; `safeToRemove` is the callback to invoke once a custom exit
|
|
36
|
+
* animation finishes. Calling it triggers the actual unmount and decrements
|
|
37
|
+
* the parent `<AnimatePresence>` exit-completion count.
|
|
38
|
+
*
|
|
39
|
+
* Outside of a `<PresenceChild>` returns `[true, null]` — the consumer is
|
|
40
|
+
* effectively always present and there is nothing to safely remove.
|
|
41
|
+
*
|
|
42
|
+
* `safeToRemove` is idempotent and versioned: a stale callback from a
|
|
43
|
+
* canceled exit cycle (re-entry before the consumer signaled completion) is
|
|
44
|
+
* a no-op.
|
|
45
|
+
*
|
|
46
|
+
* @returns `[true, null]` while present (or outside any `PresenceChild`),
|
|
47
|
+
* `[false, () => void]` while the wrapper holds the component for exit.
|
|
48
|
+
* @see https://motion.dev/docs/react-use-presence
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```svelte
|
|
52
|
+
* <script lang="ts">
|
|
53
|
+
* import { usePresence } from '@humanspeak/svelte-motion'
|
|
54
|
+
*
|
|
55
|
+
* let node: HTMLElement | undefined = $state()
|
|
56
|
+
* const presence = $derived(usePresence())
|
|
57
|
+
*
|
|
58
|
+
* $effect(() => {
|
|
59
|
+
* const [isPresent, safeToRemove] = presence
|
|
60
|
+
* if (isPresent || !node) return
|
|
61
|
+
* const onEnd = () => safeToRemove()
|
|
62
|
+
* node.addEventListener('transitionend', onEnd, { once: true })
|
|
63
|
+
* node.classList.add('exiting')
|
|
64
|
+
* return () => node?.removeEventListener('transitionend', onEnd)
|
|
65
|
+
* })
|
|
66
|
+
* </script>
|
|
67
|
+
*
|
|
68
|
+
* <div bind:this={node}>…</div>
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare const usePresence: () => UsePresenceState;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getPresenceChildContext } from './presence';
|
|
2
|
+
/**
|
|
3
|
+
* Returns whether the calling component is currently present in its parent
|
|
4
|
+
* `<PresenceChild>`. While the wrapper holds the component for an exit, this
|
|
5
|
+
* flips to `false` so the consumer can branch (render different markup, run
|
|
6
|
+
* a custom exit animation, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Outside of a `<PresenceChild>` always returns `true`.
|
|
9
|
+
*
|
|
10
|
+
* Reactivity note: the boolean tracks the wrapper's state and updates in
|
|
11
|
+
* Svelte 5 reactive contexts (`$derived`, `$effect`, template). For non-
|
|
12
|
+
* reactive snapshots, prefer `usePresence()` which exposes the same state
|
|
13
|
+
* alongside the `safeToRemove` callback.
|
|
14
|
+
*
|
|
15
|
+
* @returns `true` while present, `false` while exiting.
|
|
16
|
+
* @see https://motion.dev/docs/react-use-is-present
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```svelte
|
|
20
|
+
* <script lang="ts">
|
|
21
|
+
* import { useIsPresent } from '@humanspeak/svelte-motion'
|
|
22
|
+
* const isPresent = $derived(useIsPresent())
|
|
23
|
+
* </script>
|
|
24
|
+
* <div class:exiting={!isPresent}>{isPresent ? 'live' : 'goodbye'}</div>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export const useIsPresent = () => {
|
|
28
|
+
const context = getPresenceChildContext();
|
|
29
|
+
return context ? context.isPresent : true;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Returns `[isPresent, safeToRemove]`. `isPresent` reflects the wrapper's
|
|
33
|
+
* presence state; `safeToRemove` is the callback to invoke once a custom exit
|
|
34
|
+
* animation finishes. Calling it triggers the actual unmount and decrements
|
|
35
|
+
* the parent `<AnimatePresence>` exit-completion count.
|
|
36
|
+
*
|
|
37
|
+
* Outside of a `<PresenceChild>` returns `[true, null]` — the consumer is
|
|
38
|
+
* effectively always present and there is nothing to safely remove.
|
|
39
|
+
*
|
|
40
|
+
* `safeToRemove` is idempotent and versioned: a stale callback from a
|
|
41
|
+
* canceled exit cycle (re-entry before the consumer signaled completion) is
|
|
42
|
+
* a no-op.
|
|
43
|
+
*
|
|
44
|
+
* @returns `[true, null]` while present (or outside any `PresenceChild`),
|
|
45
|
+
* `[false, () => void]` while the wrapper holds the component for exit.
|
|
46
|
+
* @see https://motion.dev/docs/react-use-presence
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```svelte
|
|
50
|
+
* <script lang="ts">
|
|
51
|
+
* import { usePresence } from '@humanspeak/svelte-motion'
|
|
52
|
+
*
|
|
53
|
+
* let node: HTMLElement | undefined = $state()
|
|
54
|
+
* const presence = $derived(usePresence())
|
|
55
|
+
*
|
|
56
|
+
* $effect(() => {
|
|
57
|
+
* const [isPresent, safeToRemove] = presence
|
|
58
|
+
* if (isPresent || !node) return
|
|
59
|
+
* const onEnd = () => safeToRemove()
|
|
60
|
+
* node.addEventListener('transitionend', onEnd, { once: true })
|
|
61
|
+
* node.classList.add('exiting')
|
|
62
|
+
* return () => node?.removeEventListener('transitionend', onEnd)
|
|
63
|
+
* })
|
|
64
|
+
* </script>
|
|
65
|
+
*
|
|
66
|
+
* <div bind:this={node}>…</div>
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export const usePresence = () => {
|
|
70
|
+
const context = getPresenceChildContext();
|
|
71
|
+
if (!context)
|
|
72
|
+
return [true, null];
|
|
73
|
+
return context.isPresent ? [true, null] : [false, context.safeToRemove];
|
|
74
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-motion",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|