@humanspeak/svelte-motion 0.4.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html/_MotionContainer.svelte +8 -8
- package/dist/index.d.ts +17 -11
- package/dist/index.js +9 -9
- package/dist/utils/attachable.js +14 -9
- package/dist/utils/augmentMotionValue.svelte.d.ts +156 -0
- package/dist/utils/augmentMotionValue.svelte.js +193 -0
- package/dist/utils/booleanSnapshot.svelte.d.ts +37 -0
- package/dist/utils/booleanSnapshot.svelte.js +48 -0
- package/dist/utils/dom.d.ts +13 -0
- package/dist/utils/dom.js +19 -0
- package/dist/utils/inView.svelte.d.ts +209 -0
- package/dist/utils/inView.svelte.js +323 -0
- package/dist/utils/motionTemplate.svelte.d.ts +53 -0
- package/dist/utils/motionTemplate.svelte.js +78 -0
- package/dist/utils/motionValue.svelte.d.ts +61 -0
- package/dist/utils/motionValue.svelte.js +49 -0
- package/dist/utils/reducedMotion.svelte.d.ts +43 -0
- package/dist/utils/reducedMotion.svelte.js +80 -0
- package/dist/utils/reducedMotionConfig.svelte.d.ts +74 -0
- package/dist/utils/reducedMotionConfig.svelte.js +144 -0
- package/dist/utils/scroll.svelte.d.ts +91 -0
- package/dist/utils/scroll.svelte.js +259 -0
- package/dist/utils/spring.svelte.d.ts +2 -6
- package/dist/utils/spring.svelte.js +6 -70
- package/dist/utils/time.svelte.d.ts +47 -0
- package/dist/utils/time.svelte.js +128 -0
- package/dist/utils/transform.svelte.d.ts +170 -0
- package/dist/utils/transform.svelte.js +189 -0
- package/dist/utils/velocity.svelte.d.ts +61 -0
- package/dist/utils/velocity.svelte.js +132 -0
- package/package.json +1 -1
- package/dist/utils/inView.d.ts +0 -136
- package/dist/utils/inView.js +0 -266
- package/dist/utils/motionTemplate.d.ts +0 -21
- package/dist/utils/motionTemplate.js +0 -33
- package/dist/utils/motionValue.d.ts +0 -6
- package/dist/utils/motionValue.js +0 -13
- package/dist/utils/reducedMotion.d.ts +0 -20
- package/dist/utils/reducedMotion.js +0 -42
- package/dist/utils/reducedMotionConfig.d.ts +0 -39
- package/dist/utils/reducedMotionConfig.js +0 -92
- package/dist/utils/scroll.d.ts +0 -63
- package/dist/utils/scroll.js +0 -79
- package/dist/utils/time.d.ts +0 -14
- package/dist/utils/time.js +0 -68
- package/dist/utils/transform.d.ts +0 -74
- package/dist/utils/transform.js +0 -211
- package/dist/utils/velocity.d.ts +0 -15
- package/dist/utils/velocity.js +0 -62
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { type AnimationOptions, type DOMKeyframesDefinition } from 'motion';
|
|
2
|
+
import type { MotionViewport } from '../types.js';
|
|
3
|
+
import { type BooleanSnapshot } from './booleanSnapshot.svelte.js';
|
|
4
|
+
import { type ElementOrGetter } from './dom.js';
|
|
5
|
+
/**
|
|
6
|
+
* Split a `whileInView` definition into the visual keyframes and an
|
|
7
|
+
* optional nested `transition`. Mirrors the shape framer-motion uses
|
|
8
|
+
* where a single object carries both the target values and their
|
|
9
|
+
* timing config.
|
|
10
|
+
*
|
|
11
|
+
* Defensive against `undefined` / `null` input: `def ?? {}` ensures
|
|
12
|
+
* destructuring never throws, and the returned `keyframes` is then an
|
|
13
|
+
* empty record.
|
|
14
|
+
*
|
|
15
|
+
* @param def `whileInView` record possibly carrying a nested
|
|
16
|
+
* `transition` config. May be `null` / `undefined` defensively (the
|
|
17
|
+
* spread normalises to `{}`).
|
|
18
|
+
* @returns Object with the keyframes (everything *except* `transition`)
|
|
19
|
+
* and the extracted `transition` (or `undefined` if none was nested).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
|
|
24
|
+
* // → { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
|
|
25
|
+
*
|
|
26
|
+
* splitInViewDefinition({ opacity: 1 })
|
|
27
|
+
* // → { keyframes: { opacity: 1 }, transition: undefined }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare const splitInViewDefinition: (def: Record<string, unknown>) => {
|
|
31
|
+
keyframes: Record<string, unknown>;
|
|
32
|
+
transition?: AnimationOptions;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Compute the baseline values to restore to when an element leaves the
|
|
36
|
+
* viewport — only for the keys named in `whileInView`. Any key the
|
|
37
|
+
* element is not animating into stays as it was.
|
|
38
|
+
*
|
|
39
|
+
* For each key in `whileInView`, resolve a baseline by walking sources
|
|
40
|
+
* in this preference order:
|
|
41
|
+
*
|
|
42
|
+
* 1. `animate[key]` — the user's declared resting state
|
|
43
|
+
* 2. `initial[key]` — the pre-animation state
|
|
44
|
+
* 3. Neutral transform defaults (e.g. `x: 0`, `scale: 1`, `opacity: 1`)
|
|
45
|
+
* when the key is a known transform property
|
|
46
|
+
* 4. Inline CSS function value (`var(...)`, `calc(...)`, `url(...)`)
|
|
47
|
+
* read off `style.getPropertyValue` — handles cases where nested
|
|
48
|
+
* semicolons (e.g. `url(data:...;base64,...)`) would break a
|
|
49
|
+
* string-scrape
|
|
50
|
+
* 5. `getComputedStyle(el)[key]` — last resort
|
|
51
|
+
*
|
|
52
|
+
* The walk is per-key, so different baseline keys may be sourced from
|
|
53
|
+
* different layers.
|
|
54
|
+
*
|
|
55
|
+
* @param el Element whose computed style is read as the final fallback.
|
|
56
|
+
* Must be a real DOM node (the function reads inline style and
|
|
57
|
+
* `getComputedStyle`).
|
|
58
|
+
* @param opts Layered animation definitions:
|
|
59
|
+
* @param opts.initial Optional `initial` record from the component.
|
|
60
|
+
* @param opts.animate Optional `animate` record from the component.
|
|
61
|
+
* @param opts.whileInView The `whileInView` record — its keys drive
|
|
62
|
+
* which baseline entries get computed. Nested `transition` is
|
|
63
|
+
* stripped before walking.
|
|
64
|
+
* @returns A new record containing one entry per key found in
|
|
65
|
+
* `opts.whileInView`. May be empty if `whileInView` is empty.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* computeInViewBaseline(element, {
|
|
70
|
+
* initial: { opacity: 0, y: 50 },
|
|
71
|
+
* animate: { opacity: 1, y: 0 },
|
|
72
|
+
* whileInView: { opacity: 1, scale: 1.1 }
|
|
73
|
+
* })
|
|
74
|
+
* // → { opacity: 1, scale: 1 }
|
|
75
|
+
* // opacity sourced from animate; scale falls to the neutral default.
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export declare const computeInViewBaseline: (el: HTMLElement, opts: {
|
|
79
|
+
initial?: Record<string, unknown>;
|
|
80
|
+
animate?: Record<string, unknown>;
|
|
81
|
+
whileInView?: Record<string, unknown>;
|
|
82
|
+
}) => Record<string, unknown>;
|
|
83
|
+
/**
|
|
84
|
+
* Wire a `whileInView` interaction onto an element using motion's
|
|
85
|
+
* `inView` primitive. On viewport entry the element animates to the
|
|
86
|
+
* supplied keyframes; on exit it animates back to a baseline computed
|
|
87
|
+
* via {@link computeInViewBaseline}.
|
|
88
|
+
*
|
|
89
|
+
* Used internally by `motion.<tag>` components to power the
|
|
90
|
+
* `whileInView` prop, and exposed for callers that want the same
|
|
91
|
+
* declarative behavior without going through a motion component.
|
|
92
|
+
*
|
|
93
|
+
* When `viewport.once` is `true`, the element latches on first entry
|
|
94
|
+
* — no exit animation runs, and the IntersectionObserver is detached
|
|
95
|
+
* via a `queueMicrotask(stop)` after the entry handler returns.
|
|
96
|
+
*
|
|
97
|
+
* @param el Target element to observe and animate.
|
|
98
|
+
* @param whileInView Keyframes to apply on entry. May carry a nested
|
|
99
|
+
* `transition` config (extracted via {@link splitInViewDefinition}).
|
|
100
|
+
* If `undefined`, the function returns a no-op cleanup without
|
|
101
|
+
* creating an observer.
|
|
102
|
+
* @param mergedTransition Default transition used both when
|
|
103
|
+
* `whileInView` has no nested `transition` and for the exit
|
|
104
|
+
* animation back to baseline.
|
|
105
|
+
* @param callbacks Optional lifecycle hooks:
|
|
106
|
+
* - `onStart` — fires on viewport entry, before the entry animation.
|
|
107
|
+
* - `onEnd` — fires on viewport exit, after the baseline restore
|
|
108
|
+
* animation kicks off. Not called when `viewport.once` is `true`.
|
|
109
|
+
* - `onAnimationComplete` — fires when the entry animation
|
|
110
|
+
* resolves; passed the keyframes that ran.
|
|
111
|
+
* @param baselineSources Sources for {@link computeInViewBaseline}'s
|
|
112
|
+
* per-key walk:
|
|
113
|
+
* - `initial` — the component's `initial` record.
|
|
114
|
+
* - `animate` — the component's `animate` record.
|
|
115
|
+
* @param viewport IntersectionObserver options:
|
|
116
|
+
* - `root` — scroll container (default page).
|
|
117
|
+
* - `margin` — `rootMargin` string.
|
|
118
|
+
* - `amount` — fraction visible required (defaults to `0` here so
|
|
119
|
+
* any pixel counts).
|
|
120
|
+
* - `once` — latch on first entry; skip exit animation.
|
|
121
|
+
* @returns A cleanup function that detaches the IntersectionObserver
|
|
122
|
+
* on call. Safe to invoke after a `once` latch has already fired.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* const cleanup = attachWhileInView(
|
|
127
|
+
* element,
|
|
128
|
+
* { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
|
129
|
+
* { duration: 0.3 },
|
|
130
|
+
* {
|
|
131
|
+
* onStart: () => trackImpression(),
|
|
132
|
+
* onEnd: () => console.log('left viewport')
|
|
133
|
+
* },
|
|
134
|
+
* { initial: { opacity: 0, y: 50 } },
|
|
135
|
+
* { once: true, amount: 0.5 }
|
|
136
|
+
* )
|
|
137
|
+
* // Later — typically component teardown:
|
|
138
|
+
* cleanup()
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export declare const attachWhileInView: (el: HTMLElement, whileInView: Record<string, unknown> | undefined, mergedTransition: AnimationOptions, callbacks?: {
|
|
142
|
+
onStart?: () => void;
|
|
143
|
+
onEnd?: () => void;
|
|
144
|
+
onAnimationComplete?: (definition: DOMKeyframesDefinition | undefined) => void;
|
|
145
|
+
}, baselineSources?: {
|
|
146
|
+
initial?: Record<string, unknown>;
|
|
147
|
+
animate?: Record<string, unknown>;
|
|
148
|
+
}, viewport?: MotionViewport) => (() => void);
|
|
149
|
+
/**
|
|
150
|
+
* Options accepted by `useInView`.
|
|
151
|
+
*/
|
|
152
|
+
export type UseInViewOptions = {
|
|
153
|
+
/** Element to use as the IntersectionObserver root. Defaults to the viewport. */
|
|
154
|
+
root?: ElementOrGetter;
|
|
155
|
+
/** CSS margin string applied to the root bounding box (e.g. `"100px 0px"`). */
|
|
156
|
+
margin?: string;
|
|
157
|
+
/** Fraction (0-1) or `"some"` / `"all"` of the target that must be visible. */
|
|
158
|
+
amount?: 'some' | 'all' | number;
|
|
159
|
+
/** When `true`, the state latches to `true` on first entry and never flips back. */
|
|
160
|
+
once?: boolean;
|
|
161
|
+
/** Initial value emitted before the first IntersectionObserver callback. */
|
|
162
|
+
initial?: boolean;
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* State returned by {@link useInView}.
|
|
166
|
+
*/
|
|
167
|
+
export type InViewState = BooleanSnapshot;
|
|
168
|
+
/**
|
|
169
|
+
* Returns an `InViewState` that tracks whether `target` is in the viewport.
|
|
170
|
+
* Mirrors framer-motion's `useInView` adapted for Svelte 5 runes.
|
|
171
|
+
*
|
|
172
|
+
* `target` (and `options.root`) accept either an `HTMLElement` directly or
|
|
173
|
+
* a getter `() => HTMLElement | undefined`. With Svelte's `bind:this` the
|
|
174
|
+
* element isn't available until after mount, so element resolution is
|
|
175
|
+
* deferred — if the element isn't ready, the hook polls on
|
|
176
|
+
* `requestAnimationFrame` until it is.
|
|
177
|
+
*
|
|
178
|
+
* Lifecycle: the IntersectionObserver is bound to the surrounding reactive
|
|
179
|
+
* scope via `$effect`. The observer attaches at mount and detaches at
|
|
180
|
+
* unmount, regardless of how many consumers are reading `.current` or
|
|
181
|
+
* `.subscribe()`. This is a deliberate divergence from the previous
|
|
182
|
+
* store-based impl, which attached lazily on first subscribe.
|
|
183
|
+
*
|
|
184
|
+
* SSR-safe: returns a static `{ current: options.initial ?? false }` when
|
|
185
|
+
* `window` or `IntersectionObserver` is unavailable.
|
|
186
|
+
*
|
|
187
|
+
* @param target - Element (or getter) to observe.
|
|
188
|
+
* @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
|
|
189
|
+
* `once`, `initial`).
|
|
190
|
+
* @returns A `InViewState` reflecting the target's viewport intersection.
|
|
191
|
+
* @see https://motion.dev/docs/react-use-in-view
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```svelte
|
|
195
|
+
* <script>
|
|
196
|
+
* import { useInView } from '@humanspeak/svelte-motion'
|
|
197
|
+
*
|
|
198
|
+
* let ref
|
|
199
|
+
* const inView = useInView(() => ref, { once: true })
|
|
200
|
+
*
|
|
201
|
+
* $effect(() => {
|
|
202
|
+
* if (inView.current) trackImpression()
|
|
203
|
+
* })
|
|
204
|
+
* </script>
|
|
205
|
+
*
|
|
206
|
+
* <div bind:this={ref}>{inView.current ? 'visible' : 'hidden'}</div>
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
export declare const useInView: (target: ElementOrGetter, options?: UseInViewOptions) => InViewState;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { animate, inView as motionInView } from 'motion';
|
|
2
|
+
import { createAttachable } from './attachable.js';
|
|
3
|
+
import { createBooleanSnapshot } from './booleanSnapshot.svelte.js';
|
|
4
|
+
import {} from './dom.js';
|
|
5
|
+
const CSS_FUNCTION_RE = /\b(var|calc|min|max|clamp|rgb|rgba|hsl|hsla|url)\s*\(/i;
|
|
6
|
+
/**
|
|
7
|
+
* Read an inline CSS-function value (var/calc/url/etc.) for `propName`
|
|
8
|
+
* directly from the element's style declaration. Returns `null` for missing
|
|
9
|
+
* or non-function values so the caller can fall back to `getComputedStyle`.
|
|
10
|
+
*
|
|
11
|
+
* Uses `style.getPropertyValue` so values like `url(data:image/svg+xml;...)`
|
|
12
|
+
* with nested semicolons are preserved intact - the browser has already
|
|
13
|
+
* parsed the declaration, no string scraping required.
|
|
14
|
+
*
|
|
15
|
+
* @param el Element whose inline style is read.
|
|
16
|
+
* @param propName Camel-case JS property name (e.g. `borderColor`).
|
|
17
|
+
* @returns The inline CSS function value, or `null`.
|
|
18
|
+
*/
|
|
19
|
+
const getInlineCssFunction = (el, propName) => {
|
|
20
|
+
const kebab = propName.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
21
|
+
const value = el.style.getPropertyValue(kebab).trim();
|
|
22
|
+
if (!value)
|
|
23
|
+
return null;
|
|
24
|
+
return CSS_FUNCTION_RE.test(value) ? value : null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Split a `whileInView` definition into the visual keyframes and an
|
|
28
|
+
* optional nested `transition`. Mirrors the shape framer-motion uses
|
|
29
|
+
* where a single object carries both the target values and their
|
|
30
|
+
* timing config.
|
|
31
|
+
*
|
|
32
|
+
* Defensive against `undefined` / `null` input: `def ?? {}` ensures
|
|
33
|
+
* destructuring never throws, and the returned `keyframes` is then an
|
|
34
|
+
* empty record.
|
|
35
|
+
*
|
|
36
|
+
* @param def `whileInView` record possibly carrying a nested
|
|
37
|
+
* `transition` config. May be `null` / `undefined` defensively (the
|
|
38
|
+
* spread normalises to `{}`).
|
|
39
|
+
* @returns Object with the keyframes (everything *except* `transition`)
|
|
40
|
+
* and the extracted `transition` (or `undefined` if none was nested).
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* splitInViewDefinition({ opacity: 1, y: 0, transition: { duration: 0.5 } })
|
|
45
|
+
* // → { keyframes: { opacity: 1, y: 0 }, transition: { duration: 0.5 } }
|
|
46
|
+
*
|
|
47
|
+
* splitInViewDefinition({ opacity: 1 })
|
|
48
|
+
* // → { keyframes: { opacity: 1 }, transition: undefined }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export const splitInViewDefinition = (def) => {
|
|
52
|
+
const { transition, ...rest } = (def ?? {});
|
|
53
|
+
return { keyframes: rest, transition };
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Compute the baseline values to restore to when an element leaves the
|
|
57
|
+
* viewport — only for the keys named in `whileInView`. Any key the
|
|
58
|
+
* element is not animating into stays as it was.
|
|
59
|
+
*
|
|
60
|
+
* For each key in `whileInView`, resolve a baseline by walking sources
|
|
61
|
+
* in this preference order:
|
|
62
|
+
*
|
|
63
|
+
* 1. `animate[key]` — the user's declared resting state
|
|
64
|
+
* 2. `initial[key]` — the pre-animation state
|
|
65
|
+
* 3. Neutral transform defaults (e.g. `x: 0`, `scale: 1`, `opacity: 1`)
|
|
66
|
+
* when the key is a known transform property
|
|
67
|
+
* 4. Inline CSS function value (`var(...)`, `calc(...)`, `url(...)`)
|
|
68
|
+
* read off `style.getPropertyValue` — handles cases where nested
|
|
69
|
+
* semicolons (e.g. `url(data:...;base64,...)`) would break a
|
|
70
|
+
* string-scrape
|
|
71
|
+
* 5. `getComputedStyle(el)[key]` — last resort
|
|
72
|
+
*
|
|
73
|
+
* The walk is per-key, so different baseline keys may be sourced from
|
|
74
|
+
* different layers.
|
|
75
|
+
*
|
|
76
|
+
* @param el Element whose computed style is read as the final fallback.
|
|
77
|
+
* Must be a real DOM node (the function reads inline style and
|
|
78
|
+
* `getComputedStyle`).
|
|
79
|
+
* @param opts Layered animation definitions:
|
|
80
|
+
* @param opts.initial Optional `initial` record from the component.
|
|
81
|
+
* @param opts.animate Optional `animate` record from the component.
|
|
82
|
+
* @param opts.whileInView The `whileInView` record — its keys drive
|
|
83
|
+
* which baseline entries get computed. Nested `transition` is
|
|
84
|
+
* stripped before walking.
|
|
85
|
+
* @returns A new record containing one entry per key found in
|
|
86
|
+
* `opts.whileInView`. May be empty if `whileInView` is empty.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* computeInViewBaseline(element, {
|
|
91
|
+
* initial: { opacity: 0, y: 50 },
|
|
92
|
+
* animate: { opacity: 1, y: 0 },
|
|
93
|
+
* whileInView: { opacity: 1, scale: 1.1 }
|
|
94
|
+
* })
|
|
95
|
+
* // → { opacity: 1, scale: 1 }
|
|
96
|
+
* // opacity sourced from animate; scale falls to the neutral default.
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export const computeInViewBaseline = (el, opts) => {
|
|
100
|
+
const baseline = {};
|
|
101
|
+
const initialRecord = (opts.initial ?? {});
|
|
102
|
+
const animateRecord = (opts.animate ?? {});
|
|
103
|
+
const whileInViewRecordRaw = (opts.whileInView ?? {});
|
|
104
|
+
const whileInViewRecord = { ...whileInViewRecordRaw };
|
|
105
|
+
delete whileInViewRecord.transition;
|
|
106
|
+
const neutralTransformDefaults = {
|
|
107
|
+
x: 0,
|
|
108
|
+
y: 0,
|
|
109
|
+
translateX: 0,
|
|
110
|
+
translateY: 0,
|
|
111
|
+
scale: 1,
|
|
112
|
+
scaleX: 1,
|
|
113
|
+
scaleY: 1,
|
|
114
|
+
rotate: 0,
|
|
115
|
+
rotateX: 0,
|
|
116
|
+
rotateY: 0,
|
|
117
|
+
rotateZ: 0,
|
|
118
|
+
skewX: 0,
|
|
119
|
+
skewY: 0,
|
|
120
|
+
opacity: 1
|
|
121
|
+
};
|
|
122
|
+
const cs = getComputedStyle(el);
|
|
123
|
+
for (const key of Object.keys(whileInViewRecord)) {
|
|
124
|
+
if (Object.prototype.hasOwnProperty.call(animateRecord, key)) {
|
|
125
|
+
baseline[key] = animateRecord[key];
|
|
126
|
+
}
|
|
127
|
+
else if (Object.prototype.hasOwnProperty.call(initialRecord, key)) {
|
|
128
|
+
baseline[key] = initialRecord[key];
|
|
129
|
+
}
|
|
130
|
+
else if (key in neutralTransformDefaults) {
|
|
131
|
+
baseline[key] = neutralTransformDefaults[key];
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const inlineValue = getInlineCssFunction(el, key);
|
|
135
|
+
if (inlineValue) {
|
|
136
|
+
baseline[key] = inlineValue;
|
|
137
|
+
}
|
|
138
|
+
else if (key in cs) {
|
|
139
|
+
baseline[key] = cs[key];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return baseline;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Wire a `whileInView` interaction onto an element using motion's
|
|
147
|
+
* `inView` primitive. On viewport entry the element animates to the
|
|
148
|
+
* supplied keyframes; on exit it animates back to a baseline computed
|
|
149
|
+
* via {@link computeInViewBaseline}.
|
|
150
|
+
*
|
|
151
|
+
* Used internally by `motion.<tag>` components to power the
|
|
152
|
+
* `whileInView` prop, and exposed for callers that want the same
|
|
153
|
+
* declarative behavior without going through a motion component.
|
|
154
|
+
*
|
|
155
|
+
* When `viewport.once` is `true`, the element latches on first entry
|
|
156
|
+
* — no exit animation runs, and the IntersectionObserver is detached
|
|
157
|
+
* via a `queueMicrotask(stop)` after the entry handler returns.
|
|
158
|
+
*
|
|
159
|
+
* @param el Target element to observe and animate.
|
|
160
|
+
* @param whileInView Keyframes to apply on entry. May carry a nested
|
|
161
|
+
* `transition` config (extracted via {@link splitInViewDefinition}).
|
|
162
|
+
* If `undefined`, the function returns a no-op cleanup without
|
|
163
|
+
* creating an observer.
|
|
164
|
+
* @param mergedTransition Default transition used both when
|
|
165
|
+
* `whileInView` has no nested `transition` and for the exit
|
|
166
|
+
* animation back to baseline.
|
|
167
|
+
* @param callbacks Optional lifecycle hooks:
|
|
168
|
+
* - `onStart` — fires on viewport entry, before the entry animation.
|
|
169
|
+
* - `onEnd` — fires on viewport exit, after the baseline restore
|
|
170
|
+
* animation kicks off. Not called when `viewport.once` is `true`.
|
|
171
|
+
* - `onAnimationComplete` — fires when the entry animation
|
|
172
|
+
* resolves; passed the keyframes that ran.
|
|
173
|
+
* @param baselineSources Sources for {@link computeInViewBaseline}'s
|
|
174
|
+
* per-key walk:
|
|
175
|
+
* - `initial` — the component's `initial` record.
|
|
176
|
+
* - `animate` — the component's `animate` record.
|
|
177
|
+
* @param viewport IntersectionObserver options:
|
|
178
|
+
* - `root` — scroll container (default page).
|
|
179
|
+
* - `margin` — `rootMargin` string.
|
|
180
|
+
* - `amount` — fraction visible required (defaults to `0` here so
|
|
181
|
+
* any pixel counts).
|
|
182
|
+
* - `once` — latch on first entry; skip exit animation.
|
|
183
|
+
* @returns A cleanup function that detaches the IntersectionObserver
|
|
184
|
+
* on call. Safe to invoke after a `once` latch has already fired.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```ts
|
|
188
|
+
* const cleanup = attachWhileInView(
|
|
189
|
+
* element,
|
|
190
|
+
* { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
|
191
|
+
* { duration: 0.3 },
|
|
192
|
+
* {
|
|
193
|
+
* onStart: () => trackImpression(),
|
|
194
|
+
* onEnd: () => console.log('left viewport')
|
|
195
|
+
* },
|
|
196
|
+
* { initial: { opacity: 0, y: 50 } },
|
|
197
|
+
* { once: true, amount: 0.5 }
|
|
198
|
+
* )
|
|
199
|
+
* // Later — typically component teardown:
|
|
200
|
+
* cleanup()
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
export const attachWhileInView = (el, whileInView, mergedTransition, callbacks, baselineSources, viewport) => {
|
|
204
|
+
if (!whileInView)
|
|
205
|
+
return () => { };
|
|
206
|
+
let latched = false;
|
|
207
|
+
const stop = motionInView(el, () => {
|
|
208
|
+
if (latched)
|
|
209
|
+
return;
|
|
210
|
+
const inViewBaseline = computeInViewBaseline(el, {
|
|
211
|
+
initial: baselineSources?.initial,
|
|
212
|
+
animate: baselineSources?.animate,
|
|
213
|
+
whileInView
|
|
214
|
+
});
|
|
215
|
+
callbacks?.onStart?.();
|
|
216
|
+
const { keyframes, transition } = splitInViewDefinition(whileInView);
|
|
217
|
+
const animation = animate(el, keyframes, (transition ?? mergedTransition));
|
|
218
|
+
animation.finished
|
|
219
|
+
.then(() => {
|
|
220
|
+
callbacks?.onAnimationComplete?.(keyframes);
|
|
221
|
+
})
|
|
222
|
+
.catch(() => {
|
|
223
|
+
/* animation cancelled — skip completion callback */
|
|
224
|
+
});
|
|
225
|
+
if (viewport?.once) {
|
|
226
|
+
latched = true;
|
|
227
|
+
queueMicrotask(stop);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
return () => {
|
|
231
|
+
if (Object.keys(inViewBaseline).length > 0) {
|
|
232
|
+
animate(el, inViewBaseline, mergedTransition);
|
|
233
|
+
}
|
|
234
|
+
callbacks?.onEnd?.();
|
|
235
|
+
};
|
|
236
|
+
}, {
|
|
237
|
+
root: viewport?.root,
|
|
238
|
+
margin: viewport?.margin,
|
|
239
|
+
amount: viewport?.amount ?? 0
|
|
240
|
+
});
|
|
241
|
+
return stop;
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Returns an `InViewState` that tracks whether `target` is in the viewport.
|
|
245
|
+
* Mirrors framer-motion's `useInView` adapted for Svelte 5 runes.
|
|
246
|
+
*
|
|
247
|
+
* `target` (and `options.root`) accept either an `HTMLElement` directly or
|
|
248
|
+
* a getter `() => HTMLElement | undefined`. With Svelte's `bind:this` the
|
|
249
|
+
* element isn't available until after mount, so element resolution is
|
|
250
|
+
* deferred — if the element isn't ready, the hook polls on
|
|
251
|
+
* `requestAnimationFrame` until it is.
|
|
252
|
+
*
|
|
253
|
+
* Lifecycle: the IntersectionObserver is bound to the surrounding reactive
|
|
254
|
+
* scope via `$effect`. The observer attaches at mount and detaches at
|
|
255
|
+
* unmount, regardless of how many consumers are reading `.current` or
|
|
256
|
+
* `.subscribe()`. This is a deliberate divergence from the previous
|
|
257
|
+
* store-based impl, which attached lazily on first subscribe.
|
|
258
|
+
*
|
|
259
|
+
* SSR-safe: returns a static `{ current: options.initial ?? false }` when
|
|
260
|
+
* `window` or `IntersectionObserver` is unavailable.
|
|
261
|
+
*
|
|
262
|
+
* @param target - Element (or getter) to observe.
|
|
263
|
+
* @param options - Optional `UseInViewOptions` (`root`, `margin`, `amount`,
|
|
264
|
+
* `once`, `initial`).
|
|
265
|
+
* @returns A `InViewState` reflecting the target's viewport intersection.
|
|
266
|
+
* @see https://motion.dev/docs/react-use-in-view
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```svelte
|
|
270
|
+
* <script>
|
|
271
|
+
* import { useInView } from '@humanspeak/svelte-motion'
|
|
272
|
+
*
|
|
273
|
+
* let ref
|
|
274
|
+
* const inView = useInView(() => ref, { once: true })
|
|
275
|
+
*
|
|
276
|
+
* $effect(() => {
|
|
277
|
+
* if (inView.current) trackImpression()
|
|
278
|
+
* })
|
|
279
|
+
* </script>
|
|
280
|
+
*
|
|
281
|
+
* <div bind:this={ref}>{inView.current ? 'visible' : 'hidden'}</div>
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
export const useInView = (target, options = {}) => {
|
|
285
|
+
const initial = options.initial ?? false;
|
|
286
|
+
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
|
287
|
+
return {
|
|
288
|
+
get current() {
|
|
289
|
+
return initial;
|
|
290
|
+
},
|
|
291
|
+
subscribe(run) {
|
|
292
|
+
run(initial);
|
|
293
|
+
return () => undefined;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const [state, set] = createBooleanSnapshot(initial);
|
|
298
|
+
let latched = false;
|
|
299
|
+
const attachable = createAttachable({
|
|
300
|
+
refs: { target, root: options.root },
|
|
301
|
+
isLatched: () => latched,
|
|
302
|
+
onAttach: ({ target: el, root }, stop) => motionInView(el, () => {
|
|
303
|
+
set(true);
|
|
304
|
+
if (options.once) {
|
|
305
|
+
// Detach inside the entry callback; motion's inView
|
|
306
|
+
// handles re-entry safely via observer.unobserve.
|
|
307
|
+
latched = true;
|
|
308
|
+
stop();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
return () => set(false);
|
|
312
|
+
}, {
|
|
313
|
+
root: root,
|
|
314
|
+
// framer-motion types `margin` as a CSS-shorthand template
|
|
315
|
+
// literal; we expose plain `string` so the public API is
|
|
316
|
+
// ergonomic and forward-compat with future motion changes.
|
|
317
|
+
margin: options.margin,
|
|
318
|
+
amount: options.amount
|
|
319
|
+
})
|
|
320
|
+
});
|
|
321
|
+
$effect(() => attachable.subscribe());
|
|
322
|
+
return state;
|
|
323
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Readable } from 'svelte/store';
|
|
2
|
+
import { type AugmentedMotionValue } from './augmentMotionValue.svelte.js';
|
|
3
|
+
/**
|
|
4
|
+
* Input to {@link useMotionTemplate}'s interpolation slots: a motion-dom
|
|
5
|
+
* `MotionValue`, a Svelte readable, or a plain `number` / `string` literal.
|
|
6
|
+
* Mirrors framer-motion's `useMotionTemplate` signature.
|
|
7
|
+
*/
|
|
8
|
+
export type MotionTemplateInput = AugmentedMotionValue<number | string> | Readable<number | string> | number | string;
|
|
9
|
+
/**
|
|
10
|
+
* Tagged template literal that builds an augmented `MotionValue<string>`
|
|
11
|
+
* from interpolated motion values, Svelte readables, and plain literals.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors framer-motion 1:1: returns a real motion-dom `MotionValue<string>`
|
|
14
|
+
* (via `transformValue`) that auto-tracks every `MotionValue.get()` called
|
|
15
|
+
* during template composition. Whenever any tracked input emits, the
|
|
16
|
+
* composer reruns and writes the new string into the result value.
|
|
17
|
+
*
|
|
18
|
+
* Svelte-readable slots are sampled via `svelte/store`'s `get()` inside the
|
|
19
|
+
* composer so they re-emit when adjacent motion values trigger a recompute.
|
|
20
|
+
* Plain `number` / `string` slots are stringified inline.
|
|
21
|
+
*
|
|
22
|
+
* The result is augmented with a `$state`-backed `.current` getter and a
|
|
23
|
+
* Svelte readable `.subscribe` shim so it composes with the rest of the
|
|
24
|
+
* Tier 2 surface and reads reactively in Svelte 5 scopes.
|
|
25
|
+
*
|
|
26
|
+
* Lifecycle: must be called during component initialization. motion-dom
|
|
27
|
+
* cleans up the change-subscriptions when the result `MotionValue` is
|
|
28
|
+
* destroyed; we wire that destroy to the surrounding `$effect`.
|
|
29
|
+
*
|
|
30
|
+
* SSR-safe: motion-dom's `transformValue` works without DOM access (no
|
|
31
|
+
* timers, no listeners). On the server the result is a static augmented
|
|
32
|
+
* motion value with no `$effect` registered.
|
|
33
|
+
*
|
|
34
|
+
* @param strings Static template string parts (supplied by the tagged-template syntax).
|
|
35
|
+
* @param values Interpolated motion values, Svelte readables, or literals.
|
|
36
|
+
* @returns An `AugmentedMotionValue<string>` with the composed template.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```svelte
|
|
40
|
+
* <script>
|
|
41
|
+
* import { useSpring, useTransform, useMotionTemplate } from '@humanspeak/svelte-motion'
|
|
42
|
+
*
|
|
43
|
+
* const x = useSpring(0)
|
|
44
|
+
* const blur = useTransform(x, [-100, 0, 100], [10, 0, 10])
|
|
45
|
+
* const filter = useMotionTemplate`blur(${blur}px)`
|
|
46
|
+
* </script>
|
|
47
|
+
*
|
|
48
|
+
* <div style="filter: {filter.current}">Animated blur</div>
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* @see https://motion.dev/docs/react-use-motion-template
|
|
52
|
+
*/
|
|
53
|
+
export declare const useMotionTemplate: (strings: TemplateStringsArray, ...values: MotionTemplateInput[]) => AugmentedMotionValue<string>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { isMotionValue, transformValue } from 'motion-dom';
|
|
2
|
+
import {} from 'svelte/store';
|
|
3
|
+
import { augmentMotionValue, sampleSource } from './augmentMotionValue.svelte.js';
|
|
4
|
+
/**
|
|
5
|
+
* Tagged template literal that builds an augmented `MotionValue<string>`
|
|
6
|
+
* from interpolated motion values, Svelte readables, and plain literals.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors framer-motion 1:1: returns a real motion-dom `MotionValue<string>`
|
|
9
|
+
* (via `transformValue`) that auto-tracks every `MotionValue.get()` called
|
|
10
|
+
* during template composition. Whenever any tracked input emits, the
|
|
11
|
+
* composer reruns and writes the new string into the result value.
|
|
12
|
+
*
|
|
13
|
+
* Svelte-readable slots are sampled via `svelte/store`'s `get()` inside the
|
|
14
|
+
* composer so they re-emit when adjacent motion values trigger a recompute.
|
|
15
|
+
* Plain `number` / `string` slots are stringified inline.
|
|
16
|
+
*
|
|
17
|
+
* The result is augmented with a `$state`-backed `.current` getter and a
|
|
18
|
+
* Svelte readable `.subscribe` shim so it composes with the rest of the
|
|
19
|
+
* Tier 2 surface and reads reactively in Svelte 5 scopes.
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle: must be called during component initialization. motion-dom
|
|
22
|
+
* cleans up the change-subscriptions when the result `MotionValue` is
|
|
23
|
+
* destroyed; we wire that destroy to the surrounding `$effect`.
|
|
24
|
+
*
|
|
25
|
+
* SSR-safe: motion-dom's `transformValue` works without DOM access (no
|
|
26
|
+
* timers, no listeners). On the server the result is a static augmented
|
|
27
|
+
* motion value with no `$effect` registered.
|
|
28
|
+
*
|
|
29
|
+
* @param strings Static template string parts (supplied by the tagged-template syntax).
|
|
30
|
+
* @param values Interpolated motion values, Svelte readables, or literals.
|
|
31
|
+
* @returns An `AugmentedMotionValue<string>` with the composed template.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```svelte
|
|
35
|
+
* <script>
|
|
36
|
+
* import { useSpring, useTransform, useMotionTemplate } from '@humanspeak/svelte-motion'
|
|
37
|
+
*
|
|
38
|
+
* const x = useSpring(0)
|
|
39
|
+
* const blur = useTransform(x, [-100, 0, 100], [10, 0, 10])
|
|
40
|
+
* const filter = useMotionTemplate`blur(${blur}px)`
|
|
41
|
+
* </script>
|
|
42
|
+
*
|
|
43
|
+
* <div style="filter: {filter.current}">Animated blur</div>
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @see https://motion.dev/docs/react-use-motion-template
|
|
47
|
+
*/
|
|
48
|
+
export const useMotionTemplate = (strings, ...values) => {
|
|
49
|
+
const numFragments = strings.length;
|
|
50
|
+
const buildValue = () => {
|
|
51
|
+
let output = '';
|
|
52
|
+
for (let i = 0; i < numFragments; i++) {
|
|
53
|
+
output += strings[i] ?? '';
|
|
54
|
+
if (i >= values.length)
|
|
55
|
+
continue;
|
|
56
|
+
const value = values[i];
|
|
57
|
+
// motion-dom's collectMotionValues session inside transformValue
|
|
58
|
+
// auto-discovers MotionValue deps via `.get()`. Readables don't
|
|
59
|
+
// participate — they're sampled inline via get(); they only
|
|
60
|
+
// re-emit if some adjacent motion value triggers a recompute.
|
|
61
|
+
if (isMotionValue(value)) {
|
|
62
|
+
output += String(value.get());
|
|
63
|
+
}
|
|
64
|
+
else if (value && typeof value === 'object' && 'subscribe' in value) {
|
|
65
|
+
output += String(sampleSource(value));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
output += String(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return output;
|
|
72
|
+
};
|
|
73
|
+
const result = transformValue(buildValue);
|
|
74
|
+
if (typeof window !== 'undefined') {
|
|
75
|
+
$effect(() => () => result.destroy());
|
|
76
|
+
}
|
|
77
|
+
return augmentMotionValue(result);
|
|
78
|
+
};
|