@humanspeak/svelte-motion 0.3.4 → 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.
@@ -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
- // usePresence() cannot be called inside $effect because it uses getContext() and onDestroy(),
172
- // which must be called during component initialization.
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,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
  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';
@@ -21,6 +22,8 @@ export { useReducedMotion } from './utils/reducedMotion';
21
22
  export { useReducedMotionConfig } from './utils/reducedMotionConfig';
22
23
  export { useScroll } from './utils/scroll';
23
24
  export { useSpring } from './utils/spring';
25
+ export { useIsPresent, usePresence } from './utils/usePresence';
26
+ export type { UsePresenceState } from './utils/usePresence';
24
27
  export { useVelocity } from './utils/velocity';
25
28
  /**
26
29
  * @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
@@ -29,7 +32,7 @@ export { stringifyStyleObject } from './utils/styleObject';
29
32
  export { styleString } from './utils/styleObject.svelte';
30
33
  export { useTime } from './utils/time';
31
34
  export { useTransform } from './utils/transform';
32
- export { AnimatePresence, MotionConfig };
35
+ export { AnimatePresence, MotionConfig, PresenceChild };
33
36
  export { default as MotionA } from './html/A.svelte';
34
37
  export { default as MotionAbbr } from './html/Abbr.svelte';
35
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';
@@ -19,6 +20,7 @@ export { useReducedMotion } from './utils/reducedMotion';
19
20
  export { useReducedMotionConfig } from './utils/reducedMotionConfig';
20
21
  export { useScroll } from './utils/scroll';
21
22
  export { useSpring } from './utils/spring';
23
+ export { useIsPresent, usePresence } from './utils/usePresence';
22
24
  export { useVelocity } from './utils/velocity';
23
25
  /**
24
26
  * @deprecated Use `styleString` instead for reactive styles with automatic unit handling.
@@ -27,7 +29,7 @@ export { stringifyStyleObject } from './utils/styleObject';
27
29
  export { styleString } from './utils/styleObject.svelte';
28
30
  export { useTime } from './utils/time';
29
31
  export { useTransform } from './utils/transform';
30
- export { AnimatePresence, MotionConfig };
32
+ export { AnimatePresence, MotionConfig, PresenceChild };
31
33
  // Named component exports — tree-shakeable alternative to the `motion` object
32
34
  export { default as MotionA } from './html/A.svelte';
33
35
  export { default as MotionAbbr } from './html/Abbr.svelte';
@@ -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
- * Hook used by motion elements to participate in presence.
117
- * Registers the element and ensures its exit animation runs on teardown.
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
- * Note: Svelte lifecycle wrapper - ignored for coverage.
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
- * Hook used by motion elements to participate in presence.
148
+ * Get the nearest `PresenceChild` context from Svelte component context, or
149
+ * `undefined` if the caller is not wrapped in one.
123
150
  *
124
- * Registers the element with the presence context and guarantees that the
125
- * exit animation is scheduled on teardown.
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
- * @param key Unique identifier for the presence child.
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 usePresence: (key: string, element: HTMLElement | null, exit: MotionExit, mergedTransition?: MotionTransition) => void;
159
+ export declare const setPresenceChildContext: (context: PresenceChildContext) => void;
@@ -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, onDestroy, setContext } from 'svelte';
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
- inFlightExits += 1;
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
- inFlightExits -= 1;
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
- * Hook used by motion elements to participate in presence.
552
- * Registers the element and ensures its exit animation runs on teardown.
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: Svelte lifecycle wrapper - ignored for coverage.
602
+ * Note: Trivial wrapper - ignored for coverage.
555
603
  */
556
- /* c8 ignore start */
604
+ /* c8 ignore next 3 */
605
+ export const getPresenceChildContext = () => {
606
+ return getContext(PRESENCE_CHILD_CONTEXT);
607
+ };
557
608
  /**
558
- * Hook used by motion elements to participate in presence.
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
- * @param key Unique identifier for the presence child.
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
- export const usePresence = (key, element, exit, mergedTransition) => {
569
- const context = getAnimatePresenceContext();
570
- pwLog('[presence] usePresence called', {
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.4",
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",