@humanspeak/svelte-motion 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -8
- package/dist/html/_MotionContainer.svelte +49 -49
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/types.d.ts +12 -2
- package/dist/utils/a11y.d.ts +2 -0
- package/dist/utils/a11y.js +20 -0
- package/dist/utils/animation.d.ts +1 -1
- package/dist/utils/animation.js +1 -1
- package/dist/utils/hover.d.ts +1 -1
- package/dist/utils/interaction.d.ts +8 -1
- package/dist/utils/interaction.js +140 -5
- package/dist/utils/spring.d.ts +38 -0
- package/dist/utils/spring.js +157 -0
- package/dist/utils/time.d.ts +14 -0
- package/dist/utils/time.js +68 -0
- package/dist/utils/transform.d.ts +42 -0
- package/dist/utils/transform.js +129 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,14 +74,16 @@ This package carefully selects its dependencies to provide a robust and maintain
|
|
|
74
74
|
|
|
75
75
|
### Examples
|
|
76
76
|
|
|
77
|
-
| Motion | Demo / Route
|
|
78
|
-
| -------------------------------------------------------------------------------------------------------- |
|
|
79
|
-
| [React - Enter Animation](https://examples.motion.dev/react/enter-animation) | `/tests/motion/enter-animation`
|
|
80
|
-
| [HTML Content (0→100 counter)](https://examples.motion.dev/react/html-content) | `/tests/motion/html-content`
|
|
81
|
-
| [Aspect Ratio](https://examples.motion.dev/react/aspect-ratio) | `/tests/motion/aspect-ratio`
|
|
82
|
-
| [
|
|
83
|
-
| [
|
|
84
|
-
| [
|
|
77
|
+
| Motion | Demo / Route | REPL |
|
|
78
|
+
| -------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
79
|
+
| [React - Enter Animation](https://examples.motion.dev/react/enter-animation) | `/tests/motion/enter-animation` | [View Example](https://svelte.dev/playground/7f60c347729f4ea48b1a4590c9dedc02?version=5.38.10) |
|
|
80
|
+
| [HTML Content (0→100 counter)](https://examples.motion.dev/react/html-content) | `/tests/motion/html-content` | [View Example](https://svelte.dev/playground/31cd72df4a3242b4b4589501a25e774f?version=5.38.10) |
|
|
81
|
+
| [Aspect Ratio](https://examples.motion.dev/react/aspect-ratio) | `/tests/motion/aspect-ratio` | [View Example](https://svelte.dev/playground/1bf60e745fae44f5becb4c830fde9b6e?version=5.38.10) |
|
|
82
|
+
| [Hover + Tap (whileHover + whileTap)](https://motion.dev/docs/react?platform=react#hover-tap-animation) | `/tests/motion/hover-and-tap` | [View Example](https://svelte.dev/playground/674c7d58f2c740baa4886b01340a97ea?version=5.38.10) |
|
|
83
|
+
| [Random - Shiny Button](https://www.youtube.com/watch?v=jcpLprT5F0I) by [@verse\_](https://x.com/verse_) | `/tests/random/shiny-button` | [View Example](https://svelte.dev/playground/96f9e0bf624f4396adaf06c519147450?version=5.38.10) |
|
|
84
|
+
| [Fancy Like Button](https://github.com/DRlFTER/fancyLikeButton) | `/tests/random/fancy-like-button` | [View Example](https://svelte.dev/playground/c34b7e53d41c48b0ab1eaf21ca120c6e?version=5.38.10) |
|
|
85
|
+
| [Keyframes (square → circle → square; scale 1→2→1)](https://motion.dev/docs/react-animation#keyframes) | `/tests/motion/keyframes` | [View Example](https://svelte.dev/playground/05595ce0db124c1cbbe4e74fda68d717?version=5.38.10) |
|
|
86
|
+
| [Animated Border Gradient (conic-gradient rotate)](https://www.youtube.com/watch?v=OgQI1-9T6ZA) | `/tests/random/animated-border-gradient` | [View Example](https://svelte.dev/playground/6983a61b4c35441b8aa72a971de01a23?version=5.38.10) |
|
|
85
87
|
|
|
86
88
|
## Interactions
|
|
87
89
|
|
|
@@ -111,6 +113,13 @@ Svelte Motion now supports hover interactions via the `whileHover` prop, similar
|
|
|
111
113
|
<motion.button whileTap={{ scale: 0.95 }} />
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
- Callbacks: `onTapStart`, `onTap`, `onTapCancel` are supported.
|
|
117
|
+
- Accessibility: Elements with `whileTap` are keyboard-accessible (Enter and Space).
|
|
118
|
+
- Enter or Space down → fires `onTapStart` and applies `whileTap` (Space prevents default scrolling)
|
|
119
|
+
- Enter or Space up → fires `onTap`
|
|
120
|
+
- Blur while key is held → fires `onTapCancel`
|
|
121
|
+
- `MotionContainer` sets `tabindex="0"` automatically when `whileTap` is present and no `tabindex`/`tabIndex` is provided.
|
|
122
|
+
|
|
114
123
|
### Animation lifecycle
|
|
115
124
|
|
|
116
125
|
```svelte
|
|
@@ -141,6 +150,91 @@ Notes:
|
|
|
141
150
|
- Transform properties like `scale`/`rotate` are composed into a single `transform` style during SSR.
|
|
142
151
|
- When `initial` is empty, the first keyframe from `animate` is used to seed SSR styles.
|
|
143
152
|
|
|
153
|
+
## Utilities
|
|
154
|
+
|
|
155
|
+
### useTime(id?)
|
|
156
|
+
|
|
157
|
+
- Returns a Svelte readable store that updates once per animation frame with elapsed milliseconds since creation.
|
|
158
|
+
- If you pass an `id`, calls with the same id return a shared timeline (kept in sync across components).
|
|
159
|
+
- SSR-safe: Returns a static `0` store when `window` is not available.
|
|
160
|
+
|
|
161
|
+
```svelte
|
|
162
|
+
<script lang="ts">
|
|
163
|
+
import { motion, useTime } from '$lib'
|
|
164
|
+
import { derived } from 'svelte/store'
|
|
165
|
+
|
|
166
|
+
const time = useTime('global') // shared
|
|
167
|
+
const rotate = derived(time, (t) => ((t % 4000) / 4000) * 360)
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<motion.div style={`rotate: ${$rotate}deg`} />
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### useSpring
|
|
174
|
+
|
|
175
|
+
`useSpring` creates a readable store that animates to its latest target with a spring. You can either control it directly with `set`/`jump`, or have it follow another readable (like a time-derived value).
|
|
176
|
+
|
|
177
|
+
```svelte
|
|
178
|
+
<script lang="ts">
|
|
179
|
+
import { useTime, useTransform, useSpring } from '$lib'
|
|
180
|
+
|
|
181
|
+
// Track another readable
|
|
182
|
+
const time = useTime()
|
|
183
|
+
const blurTarget = useTransform(() => {
|
|
184
|
+
const phase = ($time % 2000) / 2000
|
|
185
|
+
return 4 * (0.5 + 0.5 * Math.sin(phase * Math.PI * 2)) // 0..4
|
|
186
|
+
}, [time])
|
|
187
|
+
const blur = useSpring(blurTarget, { stiffness: 300 })
|
|
188
|
+
|
|
189
|
+
// Or direct control
|
|
190
|
+
const x = useSpring(0, { stiffness: 300 })
|
|
191
|
+
// x.set(100) // animates to 100
|
|
192
|
+
// x.jump(0) // jumps without animation
|
|
193
|
+
</script>
|
|
194
|
+
|
|
195
|
+
<div style={`filter: blur(${$blur}px)`} />
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- Accepts number or unit string (e.g., `"100vh"`) or a readable source.
|
|
199
|
+
- Returns a readable with `{ set, jump }` methods when used in the browser; SSR-safe on the server.
|
|
200
|
+
- Reference: Motion useSpring docs [motion.dev](https://motion.dev/docs/react-use-spring?platform=react).
|
|
201
|
+
|
|
202
|
+
### useTransform
|
|
203
|
+
|
|
204
|
+
`useTransform` creates a derived readable. It supports:
|
|
205
|
+
|
|
206
|
+
- Range mapping: map a numeric source across input/output ranges with optional `{ clamp, ease, mixer }`.
|
|
207
|
+
- Function form: compute from one or more dependencies.
|
|
208
|
+
|
|
209
|
+
Range mapping example:
|
|
210
|
+
|
|
211
|
+
```svelte
|
|
212
|
+
<script lang="ts">
|
|
213
|
+
import { useTime, useTransform } from '$lib'
|
|
214
|
+
const time = useTime()
|
|
215
|
+
// Map 0..4000ms to 0..360deg, unclamped to allow wrap-around
|
|
216
|
+
const rotate = useTransform(time, [0, 4000], [0, 360], { clamp: false })
|
|
217
|
+
</script>
|
|
218
|
+
|
|
219
|
+
<div style={`rotate: ${$rotate}deg`} />
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Function form example:
|
|
223
|
+
|
|
224
|
+
```svelte
|
|
225
|
+
<script lang="ts">
|
|
226
|
+
import { useTransform } from '$lib'
|
|
227
|
+
// Given stores a and b, compute their sum
|
|
228
|
+
const add = (a: number, b: number) => a + b
|
|
229
|
+
// deps are stores; body can access them via $ syntax
|
|
230
|
+
const total = useTransform(() => add($a, $b), [a, b])
|
|
231
|
+
</script>
|
|
232
|
+
|
|
233
|
+
<span>{$total}</span>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- Reference: Motion useTransform docs [motion.dev](https://motion.dev/docs/react-use-transform?platform=react).
|
|
237
|
+
|
|
144
238
|
## Access the underlying element (bind:ref)
|
|
145
239
|
|
|
146
240
|
You can bind a ref to access the underlying DOM element rendered by a motion component:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { MotionProps, MotionTransition } from '../types.js'
|
|
4
4
|
import { isNotEmpty } from '../utils/objects.js'
|
|
5
5
|
import { sleep } from '../utils/testing.js'
|
|
6
|
-
import { animate } from 'motion'
|
|
6
|
+
import { animate, type AnimationOptions, type DOMKeyframesDefinition } from 'motion'
|
|
7
7
|
import { type Snippet } from 'svelte'
|
|
8
8
|
import { VOID_TAGS } from '../utils/constants.js'
|
|
9
9
|
import { mergeTransitions, animateWithLifecycle } from '../utils/animation.js'
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
} from '../utils/layout.js'
|
|
19
19
|
import type { SvelteHTMLElements } from 'svelte/elements'
|
|
20
20
|
import { mergeInlineStyles } from '../utils/style.js'
|
|
21
|
+
import { isNativelyFocusable } from '../utils/a11y.js'
|
|
21
22
|
|
|
22
23
|
type Props = MotionProps & {
|
|
23
24
|
children?: Snippet
|
|
@@ -37,10 +38,13 @@
|
|
|
37
38
|
class: classProp,
|
|
38
39
|
whileTap: whileTapProp,
|
|
39
40
|
whileHover: whileHoverProp,
|
|
40
|
-
ref: element = $bindable(null),
|
|
41
41
|
onHoverStart: onHoverStartProp,
|
|
42
42
|
onHoverEnd: onHoverEndProp,
|
|
43
|
+
onTapStart: onTapStartProp,
|
|
44
|
+
onTap: onTapProp,
|
|
45
|
+
onTapCancel: onTapCancelProp,
|
|
43
46
|
layout: layoutProp,
|
|
47
|
+
ref: element = $bindable(null),
|
|
44
48
|
...rest
|
|
45
49
|
}: Props = $props()
|
|
46
50
|
let isLoaded = $state<'mounting' | 'initial' | 'ready' | 'animated'>('mounting')
|
|
@@ -54,26 +58,45 @@
|
|
|
54
58
|
const isVoidTag = $derived(VOID_TAGS.has(tag as string))
|
|
55
59
|
|
|
56
60
|
// Compute merged transition without mutating props to avoid effect write loops
|
|
57
|
-
|
|
61
|
+
const mergedTransition = $derived<MotionTransition>(
|
|
58
62
|
mergeTransitions(motionConfig?.transition, transitionProp)
|
|
59
63
|
)
|
|
60
64
|
|
|
65
|
+
// Derived attributes to keep both branches in sync (focusability, data flags, style, class)
|
|
66
|
+
const derivedAttrs = $derived<Record<string, unknown>>({
|
|
67
|
+
...(rest as Record<string, unknown>),
|
|
68
|
+
...(whileTapProp &&
|
|
69
|
+
!isNativelyFocusable(tag, rest as Record<string, unknown>) &&
|
|
70
|
+
((rest as Record<string, unknown>)?.tabindex ??
|
|
71
|
+
(rest as Record<string, unknown>)?.tabIndex ??
|
|
72
|
+
undefined) === undefined
|
|
73
|
+
? { tabindex: 0 }
|
|
74
|
+
: {}),
|
|
75
|
+
...(isPlaywright
|
|
76
|
+
? {
|
|
77
|
+
'data-playwright': isPlaywright,
|
|
78
|
+
'data-is-loaded': isLoaded,
|
|
79
|
+
'data-path': dataPath
|
|
80
|
+
}
|
|
81
|
+
: {}),
|
|
82
|
+
style: mergeInlineStyles(
|
|
83
|
+
styleProp,
|
|
84
|
+
initialProp as unknown as Record<string, unknown>,
|
|
85
|
+
animateProp as unknown as Record<string, unknown>
|
|
86
|
+
),
|
|
87
|
+
class: classProp
|
|
88
|
+
})
|
|
89
|
+
|
|
61
90
|
const runAnimation = () => {
|
|
62
91
|
if (!element || !animateProp) return
|
|
63
|
-
const
|
|
92
|
+
const transitionAnimate: MotionTransition = mergedTransition ?? {}
|
|
64
93
|
const payload = $state.snapshot(animateProp)
|
|
65
94
|
animateWithLifecycle(
|
|
66
95
|
element,
|
|
67
|
-
payload as unknown as
|
|
68
|
-
|
|
69
|
-
(def) =>
|
|
70
|
-
|
|
71
|
-
def as unknown as import('motion').DOMKeyframesDefinition | undefined
|
|
72
|
-
),
|
|
73
|
-
(def) =>
|
|
74
|
-
onAnimationCompleteProp?.(
|
|
75
|
-
def as unknown as import('motion').DOMKeyframesDefinition | undefined
|
|
76
|
-
)
|
|
96
|
+
payload as unknown as DOMKeyframesDefinition,
|
|
97
|
+
transitionAnimate as unknown as AnimationOptions,
|
|
98
|
+
(def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
|
|
99
|
+
(def) => onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
|
|
77
100
|
)
|
|
78
101
|
}
|
|
79
102
|
|
|
@@ -97,11 +120,7 @@
|
|
|
97
120
|
}
|
|
98
121
|
const next = measureRect(element!)
|
|
99
122
|
const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
|
|
100
|
-
runFlipAnimation(
|
|
101
|
-
element!,
|
|
102
|
-
transforms,
|
|
103
|
-
(mergedTransition ?? {}) as import('motion').AnimationOptions
|
|
104
|
-
)
|
|
123
|
+
runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
|
|
105
124
|
lastRect = next
|
|
106
125
|
}
|
|
107
126
|
|
|
@@ -134,7 +153,14 @@
|
|
|
134
153
|
element!,
|
|
135
154
|
(whileTapProp ?? {}) as Record<string, unknown>,
|
|
136
155
|
(initialProp ?? {}) as Record<string, unknown>,
|
|
137
|
-
(animateProp ?? {}) as Record<string, unknown
|
|
156
|
+
(animateProp ?? {}) as Record<string, unknown>,
|
|
157
|
+
{
|
|
158
|
+
onTapStart: onTapStartProp,
|
|
159
|
+
onTap: onTapProp,
|
|
160
|
+
onTapCancel: onTapCancelProp,
|
|
161
|
+
hoverDef: (whileHoverProp ?? {}) as Record<string, unknown>,
|
|
162
|
+
hoverFallbackTransition: (mergedTransition ?? {}) as AnimationOptions
|
|
163
|
+
}
|
|
138
164
|
)
|
|
139
165
|
})
|
|
140
166
|
|
|
@@ -144,7 +170,7 @@
|
|
|
144
170
|
return attachWhileHover(
|
|
145
171
|
element!,
|
|
146
172
|
(whileHoverProp ?? {}) as Record<string, unknown>,
|
|
147
|
-
(mergedTransition ?? {}) as
|
|
173
|
+
(mergedTransition ?? {}) as AnimationOptions,
|
|
148
174
|
{ onStart: onHoverStartProp, onEnd: onHoverEndProp },
|
|
149
175
|
undefined,
|
|
150
176
|
{
|
|
@@ -202,35 +228,9 @@
|
|
|
202
228
|
</script>
|
|
203
229
|
|
|
204
230
|
{#if isVoidTag}
|
|
205
|
-
<svelte:element
|
|
206
|
-
this={tag}
|
|
207
|
-
bind:this={element}
|
|
208
|
-
{...rest}
|
|
209
|
-
data-playwright={isPlaywright ? isPlaywright : undefined}
|
|
210
|
-
data-is-loaded={isPlaywright ? isLoaded : undefined}
|
|
211
|
-
data-path={isPlaywright ? dataPath : undefined}
|
|
212
|
-
style={mergeInlineStyles(
|
|
213
|
-
styleProp,
|
|
214
|
-
initialProp as unknown as Record<string, unknown>,
|
|
215
|
-
animateProp as unknown as Record<string, unknown>
|
|
216
|
-
)}
|
|
217
|
-
class={classProp}
|
|
218
|
-
/>
|
|
231
|
+
<svelte:element this={tag} bind:this={element} {...derivedAttrs} />
|
|
219
232
|
{:else}
|
|
220
|
-
<svelte:element
|
|
221
|
-
this={tag}
|
|
222
|
-
bind:this={element}
|
|
223
|
-
{...rest}
|
|
224
|
-
data-playwright={isPlaywright ? isPlaywright : undefined}
|
|
225
|
-
data-is-loaded={isPlaywright ? isLoaded : undefined}
|
|
226
|
-
data-path={isPlaywright ? dataPath : undefined}
|
|
227
|
-
style={mergeInlineStyles(
|
|
228
|
-
styleProp,
|
|
229
|
-
initialProp as unknown as Record<string, unknown>,
|
|
230
|
-
animateProp as unknown as Record<string, unknown>
|
|
231
|
-
)}
|
|
232
|
-
class={classProp}
|
|
233
|
-
>
|
|
233
|
+
<svelte:element this={tag} bind:this={element} {...derivedAttrs}>
|
|
234
234
|
{#if isLoaded === 'ready'}
|
|
235
235
|
{@render children?.()}
|
|
236
236
|
{/if}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import MotionConfig from './components/MotionConfig.svelte';
|
|
2
|
-
import type { MotionComponents } from './html/index
|
|
2
|
+
import type { MotionComponents } from './html/index';
|
|
3
3
|
export declare const motion: MotionComponents;
|
|
4
4
|
export { animate, hover } from 'motion';
|
|
5
|
-
export type { MotionAnimate, MotionInitial, MotionTransition, MotionWhileTap } from './types
|
|
5
|
+
export type { MotionAnimate, MotionInitial, MotionTransition, MotionWhileTap } from './types';
|
|
6
|
+
export { useSpring } from './utils/spring';
|
|
7
|
+
export { useTime } from './utils/time';
|
|
8
|
+
export { useTransform } from './utils/transform';
|
|
6
9
|
export { MotionConfig };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import MotionConfig from './components/MotionConfig.svelte';
|
|
2
|
-
import * as html from './html/index
|
|
2
|
+
import * as html from './html/index';
|
|
3
3
|
// Create the motion object with all components
|
|
4
4
|
export const motion = Object.fromEntries(Object.entries(html).map(([key, component]) => [key.toLowerCase(), component]));
|
|
5
5
|
// Export all types
|
|
6
6
|
export { animate, hover } from 'motion';
|
|
7
|
+
export { useSpring } from './utils/spring';
|
|
8
|
+
export { useTime } from './utils/time';
|
|
9
|
+
export { useTransform } from './utils/transform';
|
|
7
10
|
export { MotionConfig };
|
package/dist/types.d.ts
CHANGED
|
@@ -58,11 +58,15 @@ export type MotionWhileHover = (Record<string, unknown> & {
|
|
|
58
58
|
/**
|
|
59
59
|
* Animation lifecycle callbacks for motion components.
|
|
60
60
|
*/
|
|
61
|
-
export type MotionAnimationStart = ((
|
|
62
|
-
export type MotionAnimationComplete = ((
|
|
61
|
+
export type MotionAnimationStart = ((_definition: DOMKeyframesDefinition | undefined) => void) | undefined;
|
|
62
|
+
export type MotionAnimationComplete = ((_definition: DOMKeyframesDefinition | undefined) => void) | undefined;
|
|
63
63
|
/** Hover lifecycle callbacks */
|
|
64
64
|
export type MotionOnHoverStart = (() => void) | undefined;
|
|
65
65
|
export type MotionOnHoverEnd = (() => void) | undefined;
|
|
66
|
+
/** Tap lifecycle callbacks */
|
|
67
|
+
export type MotionOnTapStart = (() => void) | undefined;
|
|
68
|
+
export type MotionOnTap = (() => void) | undefined;
|
|
69
|
+
export type MotionOnTapCancel = (() => void) | undefined;
|
|
66
70
|
/**
|
|
67
71
|
* Base motion props shared by all motion components.
|
|
68
72
|
*/
|
|
@@ -85,6 +89,12 @@ export type MotionProps = {
|
|
|
85
89
|
onHoverStart?: MotionOnHoverStart;
|
|
86
90
|
/** Called when a true hover gesture ends */
|
|
87
91
|
onHoverEnd?: MotionOnHoverEnd;
|
|
92
|
+
/** Called when a tap gesture starts (pointerdown recognized) */
|
|
93
|
+
onTapStart?: MotionOnTapStart;
|
|
94
|
+
/** Called when a tap gesture ends successfully (pointerup) */
|
|
95
|
+
onTap?: MotionOnTap;
|
|
96
|
+
/** Called when a tap gesture is cancelled (pointercancel) */
|
|
97
|
+
onTapCancel?: MotionOnTapCancel;
|
|
88
98
|
/** Inline styles */
|
|
89
99
|
style?: string;
|
|
90
100
|
/** CSS classes */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const isNativelyFocusable = (tag, attrs = {}) => {
|
|
2
|
+
if (attrs.tabindex != null)
|
|
3
|
+
return true;
|
|
4
|
+
if (attrs.tabIndex != null)
|
|
5
|
+
return true;
|
|
6
|
+
if (attrs.contenteditable != null)
|
|
7
|
+
return true;
|
|
8
|
+
switch (tag) {
|
|
9
|
+
case 'a':
|
|
10
|
+
return Boolean(attrs.href);
|
|
11
|
+
case 'button':
|
|
12
|
+
case 'input':
|
|
13
|
+
case 'select':
|
|
14
|
+
case 'textarea':
|
|
15
|
+
case 'summary':
|
|
16
|
+
return true;
|
|
17
|
+
default:
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
package/dist/utils/animation.js
CHANGED
package/dist/utils/hover.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AnimationOptions } from 'motion';
|
|
1
2
|
/**
|
|
2
3
|
* Build a reset record for whileTap on pointerup.
|
|
3
4
|
*
|
|
@@ -24,4 +25,10 @@ export declare const buildTapResetRecord: (initial: Record<string, unknown>, ani
|
|
|
24
25
|
* @param animateDef Animate keyframe record.
|
|
25
26
|
* @return Cleanup function to remove listeners.
|
|
26
27
|
*/
|
|
27
|
-
export declare const attachWhileTap: (el: HTMLElement, whileTap: Record<string, unknown> | undefined, initial?: Record<string, unknown>, animateDef?: Record<string, unknown
|
|
28
|
+
export declare const attachWhileTap: (el: HTMLElement, whileTap: Record<string, unknown> | undefined, initial?: Record<string, unknown>, animateDef?: Record<string, unknown>, callbacks?: {
|
|
29
|
+
onTapStart?: () => void;
|
|
30
|
+
onTap?: () => void;
|
|
31
|
+
onTapCancel?: () => void;
|
|
32
|
+
hoverDef?: Record<string, unknown> | undefined;
|
|
33
|
+
hoverFallbackTransition?: AnimationOptions | undefined;
|
|
34
|
+
}) => (() => void);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHoverCapable, splitHoverDefinition } from './hover';
|
|
1
2
|
import { animate } from 'motion';
|
|
2
3
|
/**
|
|
3
4
|
* Build a reset record for whileTap on pointerup.
|
|
@@ -38,15 +39,139 @@ export const buildTapResetRecord = (initial, animateDef, whileTap) => {
|
|
|
38
39
|
* @param animateDef Animate keyframe record.
|
|
39
40
|
* @return Cleanup function to remove listeners.
|
|
40
41
|
*/
|
|
41
|
-
export const attachWhileTap = (el, whileTap, initial, animateDef) => {
|
|
42
|
+
export const attachWhileTap = (el, whileTap, initial, animateDef, callbacks) => {
|
|
42
43
|
if (!whileTap)
|
|
43
44
|
return () => { };
|
|
44
|
-
|
|
45
|
+
let keyboardActive = false;
|
|
46
|
+
let activePointerId = null;
|
|
47
|
+
const handlePointerDown = (event) => {
|
|
48
|
+
// Capture pointer so we receive up/cancel even if pointer leaves the element
|
|
49
|
+
if (typeof event.pointerId === 'number') {
|
|
50
|
+
try {
|
|
51
|
+
if ('setPointerCapture' in el) {
|
|
52
|
+
el.setPointerCapture(event.pointerId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// noop if not supported
|
|
57
|
+
}
|
|
58
|
+
activePointerId = event.pointerId;
|
|
59
|
+
// Attach global listeners to catch off-element releases (even if capture unsupported)
|
|
60
|
+
window.addEventListener('pointerup', handlePointerUp);
|
|
61
|
+
window.addEventListener('pointercancel', handlePointerCancel);
|
|
62
|
+
document.addEventListener('pointerup', handlePointerUp);
|
|
63
|
+
document.addEventListener('pointercancel', handlePointerCancel);
|
|
64
|
+
}
|
|
65
|
+
callbacks?.onTapStart?.();
|
|
45
66
|
animate(el, whileTap);
|
|
46
67
|
};
|
|
47
|
-
const
|
|
68
|
+
const reapplyHoverIfActive = () => {
|
|
69
|
+
if (!callbacks?.hoverDef)
|
|
70
|
+
return false;
|
|
71
|
+
if (!isHoverCapable())
|
|
72
|
+
return false;
|
|
73
|
+
try {
|
|
74
|
+
if (!el.matches(':hover'))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const { keyframes, transition } = splitHoverDefinition(callbacks.hoverDef);
|
|
81
|
+
animate(el, keyframes, (transition ?? callbacks.hoverFallbackTransition));
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
const handlePointerUp = (event) => {
|
|
85
|
+
if (typeof event.pointerId === 'number' && activePointerId !== null) {
|
|
86
|
+
if (event.pointerId !== activePointerId)
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
if ('releasePointerCapture' in el)
|
|
90
|
+
el.releasePointerCapture(event.pointerId);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// noop
|
|
94
|
+
}
|
|
95
|
+
activePointerId = null;
|
|
96
|
+
window.removeEventListener('pointerup', handlePointerUp);
|
|
97
|
+
window.removeEventListener('pointercancel', handlePointerCancel);
|
|
98
|
+
document.removeEventListener('pointerup', handlePointerUp);
|
|
99
|
+
document.removeEventListener('pointercancel', handlePointerCancel);
|
|
100
|
+
}
|
|
101
|
+
callbacks?.onTap?.();
|
|
48
102
|
if (!whileTap)
|
|
49
103
|
return;
|
|
104
|
+
if (reapplyHoverIfActive())
|
|
105
|
+
return;
|
|
106
|
+
if (initial || animateDef) {
|
|
107
|
+
const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
|
|
108
|
+
if (Object.keys(resetRecord).length > 0) {
|
|
109
|
+
animate(el, resetRecord);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const handlePointerCancel = (event) => {
|
|
114
|
+
if (typeof event.pointerId === 'number' && activePointerId !== null) {
|
|
115
|
+
if (event.pointerId !== activePointerId)
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
if ('releasePointerCapture' in el)
|
|
119
|
+
el.releasePointerCapture(event.pointerId);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// noop
|
|
123
|
+
}
|
|
124
|
+
activePointerId = null;
|
|
125
|
+
window.removeEventListener('pointerup', handlePointerUp);
|
|
126
|
+
window.removeEventListener('pointercancel', handlePointerCancel);
|
|
127
|
+
document.removeEventListener('pointerup', handlePointerUp);
|
|
128
|
+
document.removeEventListener('pointercancel', handlePointerCancel);
|
|
129
|
+
}
|
|
130
|
+
callbacks?.onTapCancel?.();
|
|
131
|
+
// On cancel, also restore baseline if available
|
|
132
|
+
if (initial || animateDef) {
|
|
133
|
+
const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
|
|
134
|
+
if (Object.keys(resetRecord).length > 0) {
|
|
135
|
+
animate(el, resetRecord);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const handleKeyDown = (e) => {
|
|
140
|
+
if (!(e.key === 'Enter' || e.key === ' ' || e.key === 'Space'))
|
|
141
|
+
return;
|
|
142
|
+
// Prevent page scroll/activation for Space
|
|
143
|
+
if (e.key === ' ' || e.key === 'Space')
|
|
144
|
+
e.preventDefault?.();
|
|
145
|
+
if (keyboardActive)
|
|
146
|
+
return;
|
|
147
|
+
keyboardActive = true;
|
|
148
|
+
callbacks?.onTapStart?.();
|
|
149
|
+
animate(el, whileTap);
|
|
150
|
+
};
|
|
151
|
+
const handleKeyUp = (e) => {
|
|
152
|
+
if (!(e.key === 'Enter' || e.key === ' ' || e.key === 'Space'))
|
|
153
|
+
return;
|
|
154
|
+
// Prevent page scroll/activation for Space
|
|
155
|
+
if (e.key === ' ' || e.key === 'Space')
|
|
156
|
+
e.preventDefault?.();
|
|
157
|
+
if (!keyboardActive)
|
|
158
|
+
return;
|
|
159
|
+
keyboardActive = false;
|
|
160
|
+
callbacks?.onTap?.();
|
|
161
|
+
if (reapplyHoverIfActive())
|
|
162
|
+
return;
|
|
163
|
+
if (initial || animateDef) {
|
|
164
|
+
const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
|
|
165
|
+
if (Object.keys(resetRecord).length > 0) {
|
|
166
|
+
animate(el, resetRecord);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const handleBlur = () => {
|
|
171
|
+
if (!keyboardActive)
|
|
172
|
+
return;
|
|
173
|
+
keyboardActive = false;
|
|
174
|
+
callbacks?.onTapCancel?.();
|
|
50
175
|
if (initial || animateDef) {
|
|
51
176
|
const resetRecord = buildTapResetRecord(initial ?? {}, animateDef ?? {}, whileTap ?? {});
|
|
52
177
|
if (Object.keys(resetRecord).length > 0) {
|
|
@@ -56,10 +181,20 @@ export const attachWhileTap = (el, whileTap, initial, animateDef) => {
|
|
|
56
181
|
};
|
|
57
182
|
el.addEventListener('pointerdown', handlePointerDown);
|
|
58
183
|
el.addEventListener('pointerup', handlePointerUp);
|
|
59
|
-
el.addEventListener('pointercancel',
|
|
184
|
+
el.addEventListener('pointercancel', handlePointerCancel);
|
|
185
|
+
el.addEventListener('keydown', handleKeyDown);
|
|
186
|
+
el.addEventListener('keyup', handleKeyUp);
|
|
187
|
+
el.addEventListener('blur', handleBlur);
|
|
60
188
|
return () => {
|
|
61
189
|
el.removeEventListener('pointerdown', handlePointerDown);
|
|
62
190
|
el.removeEventListener('pointerup', handlePointerUp);
|
|
63
|
-
el.removeEventListener('pointercancel',
|
|
191
|
+
el.removeEventListener('pointercancel', handlePointerCancel);
|
|
192
|
+
window.removeEventListener('pointerup', handlePointerUp);
|
|
193
|
+
window.removeEventListener('pointercancel', handlePointerCancel);
|
|
194
|
+
document.removeEventListener('pointerup', handlePointerUp);
|
|
195
|
+
document.removeEventListener('pointercancel', handlePointerCancel);
|
|
196
|
+
el.removeEventListener('keydown', handleKeyDown);
|
|
197
|
+
el.removeEventListener('keyup', handleKeyUp);
|
|
198
|
+
el.removeEventListener('blur', handleBlur);
|
|
64
199
|
};
|
|
65
200
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Readable } from 'svelte/store';
|
|
2
|
+
/**
|
|
3
|
+
* Spring configuration options.
|
|
4
|
+
*
|
|
5
|
+
* This is a minimal subset modeled after Motion's spring transition options.
|
|
6
|
+
* Values are tuned for sensible defaults, not parity.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {Object} SpringOptions
|
|
9
|
+
* @property {number=} stiffness Spring stiffness (higher = snappier). Default 170.
|
|
10
|
+
* @property {number=} damping Spring damping (higher = less oscillation). Default 26.
|
|
11
|
+
* @property {number=} mass Mass of the object. Default 1.
|
|
12
|
+
* @property {number=} restDelta Threshold for absolute position delta to stop. Default 0.01.
|
|
13
|
+
* @property {number=} restSpeed Threshold for velocity magnitude to stop. Default 0.01.
|
|
14
|
+
*/
|
|
15
|
+
export type SpringOptions = {
|
|
16
|
+
stiffness?: number;
|
|
17
|
+
damping?: number;
|
|
18
|
+
mass?: number;
|
|
19
|
+
restDelta?: number;
|
|
20
|
+
restSpeed?: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Creates a spring-animated readable store. The store exposes `set` to
|
|
24
|
+
* animate towards a target, or `jump` to immediately set the value without
|
|
25
|
+
* animation. When constructed with another readable store, the spring
|
|
26
|
+
* automatically follows it.
|
|
27
|
+
*
|
|
28
|
+
* This is SSR-safe: On the server it returns a static store and no timers run.
|
|
29
|
+
*
|
|
30
|
+
* @template T
|
|
31
|
+
* @param {number|string|Readable<number|string>} source Initial value or a source store to follow.
|
|
32
|
+
* @param {SpringOptions=} options Spring configuration.
|
|
33
|
+
* @returns {Readable<number|string> & { set: (v: number|string) => void; jump: (v: number|string) => void; }}
|
|
34
|
+
*/
|
|
35
|
+
export declare const useSpring: (source: number | string | Readable<number | string>, options?: SpringOptions) => Readable<number | string> & {
|
|
36
|
+
set: (v: number | string) => void;
|
|
37
|
+
jump: (v: number | string) => void;
|
|
38
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readable, writable } from 'svelte/store';
|
|
2
|
+
/**
|
|
3
|
+
* Parses a number or unit string into numeric value and unit.
|
|
4
|
+
* @param {number|string} v The input value.
|
|
5
|
+
* @returns {UnitValue} Parsed value and unit.
|
|
6
|
+
* @private
|
|
7
|
+
*/
|
|
8
|
+
const parseUnit = (v) => {
|
|
9
|
+
if (typeof v === 'number')
|
|
10
|
+
return { value: v, unit: '' };
|
|
11
|
+
const match = String(v).match(/^(-?\d*\.?\d+)(.*)$/);
|
|
12
|
+
if (!match || !match[1])
|
|
13
|
+
return { value: 0, unit: '' };
|
|
14
|
+
const parsed = Number.parseFloat(match[1]);
|
|
15
|
+
if (!Number.isFinite(parsed))
|
|
16
|
+
return { value: 0, unit: '' };
|
|
17
|
+
const unit = match[2] ?? '';
|
|
18
|
+
return { value: parsed, unit };
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Formats a numeric value with a unit.
|
|
22
|
+
* @param {number} n Numeric value.
|
|
23
|
+
* @param {string} unit Unit suffix.
|
|
24
|
+
* @returns {number|string} Number or string with unit.
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
const formatUnit = (n, unit) => (unit ? `${n}${unit}` : n);
|
|
28
|
+
/**
|
|
29
|
+
* Creates a spring-animated readable store. The store exposes `set` to
|
|
30
|
+
* animate towards a target, or `jump` to immediately set the value without
|
|
31
|
+
* animation. When constructed with another readable store, the spring
|
|
32
|
+
* automatically follows it.
|
|
33
|
+
*
|
|
34
|
+
* This is SSR-safe: On the server it returns a static store and no timers run.
|
|
35
|
+
*
|
|
36
|
+
* @template T
|
|
37
|
+
* @param {number|string|Readable<number|string>} source Initial value or a source store to follow.
|
|
38
|
+
* @param {SpringOptions=} options Spring configuration.
|
|
39
|
+
* @returns {Readable<number|string> & { set: (v: number|string) => void; jump: (v: number|string) => void; }}
|
|
40
|
+
*/
|
|
41
|
+
export const useSpring = (source, options = {}) => {
|
|
42
|
+
if (typeof window === 'undefined') {
|
|
43
|
+
// Derive best-effort initial value for SSR to avoid hydration mismatch
|
|
44
|
+
let initial = 0;
|
|
45
|
+
if (typeof source === 'number' || typeof source === 'string') {
|
|
46
|
+
initial = source;
|
|
47
|
+
}
|
|
48
|
+
else if (source && typeof source === 'object') {
|
|
49
|
+
const anySource = source;
|
|
50
|
+
if (typeof anySource.get === 'function') {
|
|
51
|
+
const v = anySource.get();
|
|
52
|
+
if (typeof v === 'number' || typeof v === 'string')
|
|
53
|
+
initial = v;
|
|
54
|
+
}
|
|
55
|
+
else if (typeof anySource.value === 'number' || typeof anySource.value === 'string') {
|
|
56
|
+
initial = anySource.value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const store = readable(initial, () => { });
|
|
60
|
+
store.set = () => { };
|
|
61
|
+
store.jump = () => { };
|
|
62
|
+
return store;
|
|
63
|
+
}
|
|
64
|
+
const { stiffness = 170, damping = 26, mass = 1, restDelta = 0.01, restSpeed = 0.01 } = options;
|
|
65
|
+
const state = {
|
|
66
|
+
current: parseUnit(typeof source === 'object' ? 0 : source),
|
|
67
|
+
target: parseUnit(typeof source === 'object' ? 0 : source)
|
|
68
|
+
};
|
|
69
|
+
const unit = state.current.unit || state.target.unit;
|
|
70
|
+
const store = writable(formatUnit(state.current.value, unit));
|
|
71
|
+
let raf = 0;
|
|
72
|
+
let lastTime = 0;
|
|
73
|
+
let velocity = 0;
|
|
74
|
+
const step = (t) => {
|
|
75
|
+
if (!lastTime)
|
|
76
|
+
lastTime = t;
|
|
77
|
+
// Clamp dt to a safe range to avoid instability across large time gaps
|
|
78
|
+
const dt = Math.min(0.1, Math.max(0.001, (t - lastTime) / 1000));
|
|
79
|
+
lastTime = t;
|
|
80
|
+
const displacement = state.current.value - state.target.value;
|
|
81
|
+
// Spring force based on Hooke's Law: F = -k x; damping force: -c v
|
|
82
|
+
const spring = -stiffness * displacement;
|
|
83
|
+
const damper = -damping * velocity;
|
|
84
|
+
const accel = (spring + damper) / mass;
|
|
85
|
+
velocity += accel * dt;
|
|
86
|
+
state.current.value += velocity * dt;
|
|
87
|
+
const isNoVelocity = Math.abs(velocity) <= restSpeed;
|
|
88
|
+
const isNoDisplacement = Math.abs(state.current.value - state.target.value) <= restDelta;
|
|
89
|
+
const done = isNoVelocity && isNoDisplacement;
|
|
90
|
+
if (done) {
|
|
91
|
+
state.current.value = state.target.value;
|
|
92
|
+
store.set(formatUnit(state.current.value, unit));
|
|
93
|
+
raf = 0;
|
|
94
|
+
lastTime = 0;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
store.set(formatUnit(state.current.value, unit));
|
|
98
|
+
raf = requestAnimationFrame(step);
|
|
99
|
+
};
|
|
100
|
+
const start = () => {
|
|
101
|
+
if (raf)
|
|
102
|
+
return;
|
|
103
|
+
raf = requestAnimationFrame(step);
|
|
104
|
+
};
|
|
105
|
+
const api = {
|
|
106
|
+
set: (v) => {
|
|
107
|
+
state.target = parseUnit(v);
|
|
108
|
+
start();
|
|
109
|
+
},
|
|
110
|
+
jump: (v) => {
|
|
111
|
+
state.current = parseUnit(v);
|
|
112
|
+
state.target = parseUnit(v);
|
|
113
|
+
velocity = 0;
|
|
114
|
+
store.set(formatUnit(state.current.value, state.current.unit || state.target.unit));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
// If following another store, subscribe and forward values to set()
|
|
118
|
+
if (typeof source === 'object' && 'subscribe' in source) {
|
|
119
|
+
let followSource = true;
|
|
120
|
+
const unsub = source.subscribe((v) => api.set(v));
|
|
121
|
+
const wrapped = readable(formatUnit(state.current.value, unit), (set) => {
|
|
122
|
+
const sub = store.subscribe(set);
|
|
123
|
+
return () => {
|
|
124
|
+
sub();
|
|
125
|
+
unsub();
|
|
126
|
+
followSource = false;
|
|
127
|
+
if (raf)
|
|
128
|
+
cancelAnimationFrame(raf);
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
wrapped.set = (v) => {
|
|
132
|
+
if (followSource)
|
|
133
|
+
unsub();
|
|
134
|
+
followSource = false;
|
|
135
|
+
api.set(v);
|
|
136
|
+
};
|
|
137
|
+
wrapped.jump = (v) => {
|
|
138
|
+
if (followSource)
|
|
139
|
+
unsub();
|
|
140
|
+
followSource = false;
|
|
141
|
+
api.jump(v);
|
|
142
|
+
};
|
|
143
|
+
return wrapped;
|
|
144
|
+
}
|
|
145
|
+
// Standard readable wrapping internal writable
|
|
146
|
+
const wrapped = readable(formatUnit(state.current.value, unit), (set) => {
|
|
147
|
+
const sub = store.subscribe(set);
|
|
148
|
+
return () => {
|
|
149
|
+
sub();
|
|
150
|
+
if (raf)
|
|
151
|
+
cancelAnimationFrame(raf);
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
wrapped.set = api.set;
|
|
155
|
+
wrapped.jump = api.jump;
|
|
156
|
+
return wrapped;
|
|
157
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Readable } from 'svelte/store';
|
|
2
|
+
/**
|
|
3
|
+
* Returns a time store that ticks once per animation frame.
|
|
4
|
+
*
|
|
5
|
+
* - Without an `id`, returns a fresh timeline per call.
|
|
6
|
+
* - With an `id`, callers sharing the same id receive the same store/timeline,
|
|
7
|
+
* ensuring synchronized reads across components.
|
|
8
|
+
* - SSR-safe: Returns a static 0-valued store when `window` is unavailable.
|
|
9
|
+
*
|
|
10
|
+
* @param {string=} id Optional timeline identifier for sharing across calls.
|
|
11
|
+
* @returns {Readable<number>} A readable store of elapsed milliseconds.
|
|
12
|
+
* @see https://motion.dev/docs/react-use-time?platform=react
|
|
13
|
+
*/
|
|
14
|
+
export declare const useTime: (id?: string) => Readable<number>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readable } from 'svelte/store';
|
|
2
|
+
const SSR_ZERO = readable(0, () => { });
|
|
3
|
+
const sharedStores = new Map();
|
|
4
|
+
// Clear shared timelines on HMR dispose to avoid stale entries across hot reloads
|
|
5
|
+
if (import.meta &&
|
|
6
|
+
import.meta.hot) {
|
|
7
|
+
;
|
|
8
|
+
import.meta.hot.dispose(() => {
|
|
9
|
+
sharedStores.clear();
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new time store that updates once per animation frame.
|
|
14
|
+
*
|
|
15
|
+
* The store value represents elapsed milliseconds since the store was created.
|
|
16
|
+
* In SSR environments (no `window`), a static 0-valued store is returned.
|
|
17
|
+
*
|
|
18
|
+
* @returns {Readable<number>} A readable store of elapsed milliseconds.
|
|
19
|
+
* @see https://motion.dev/docs/react-use-time?platform=react
|
|
20
|
+
* @private
|
|
21
|
+
*/
|
|
22
|
+
const createTimeStore = () => {
|
|
23
|
+
if (typeof window === 'undefined')
|
|
24
|
+
return SSR_ZERO;
|
|
25
|
+
return readable(0, (set) => {
|
|
26
|
+
const start = performance.now();
|
|
27
|
+
let raf = 0;
|
|
28
|
+
/* c8 ignore start */
|
|
29
|
+
const loop = (t) => {
|
|
30
|
+
set(t - start);
|
|
31
|
+
raf = requestAnimationFrame(loop);
|
|
32
|
+
};
|
|
33
|
+
/* c8 ignore stop */
|
|
34
|
+
raf = requestAnimationFrame(loop);
|
|
35
|
+
return () => cancelAnimationFrame(raf);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Returns a time store that ticks once per animation frame.
|
|
40
|
+
*
|
|
41
|
+
* - Without an `id`, returns a fresh timeline per call.
|
|
42
|
+
* - With an `id`, callers sharing the same id receive the same store/timeline,
|
|
43
|
+
* ensuring synchronized reads across components.
|
|
44
|
+
* - SSR-safe: Returns a static 0-valued store when `window` is unavailable.
|
|
45
|
+
*
|
|
46
|
+
* @param {string=} id Optional timeline identifier for sharing across calls.
|
|
47
|
+
* @returns {Readable<number>} A readable store of elapsed milliseconds.
|
|
48
|
+
* @see https://motion.dev/docs/react-use-time?platform=react
|
|
49
|
+
*/
|
|
50
|
+
export const useTime = (id) => {
|
|
51
|
+
if (!id)
|
|
52
|
+
return createTimeStore();
|
|
53
|
+
if (typeof window === 'undefined')
|
|
54
|
+
return SSR_ZERO;
|
|
55
|
+
const existing = sharedStores.get(id);
|
|
56
|
+
if (existing)
|
|
57
|
+
return existing;
|
|
58
|
+
const base = createTimeStore();
|
|
59
|
+
const store = readable(0, (set) => {
|
|
60
|
+
const unsub = base.subscribe(set);
|
|
61
|
+
return () => {
|
|
62
|
+
unsub();
|
|
63
|
+
sharedStores.delete(id);
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
sharedStores.set(id, store);
|
|
67
|
+
return store;
|
|
68
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Readable } from 'svelte/store';
|
|
2
|
+
/**
|
|
3
|
+
* Options for range-mapping transform.
|
|
4
|
+
*
|
|
5
|
+
* - clamp: If true, clamps the input to the active segment bounds.
|
|
6
|
+
* - ease: A single easing function or one per segment to shape interpolation.
|
|
7
|
+
* - mixer: Custom mixer factory to interpolate non-numeric outputs.
|
|
8
|
+
*
|
|
9
|
+
* @see https://motion.dev/docs/react-use-transform?platform=react
|
|
10
|
+
*/
|
|
11
|
+
export type TransformOptions = {
|
|
12
|
+
clamp?: boolean;
|
|
13
|
+
ease?: ((t: number) => number) | Array<(t: number) => number>;
|
|
14
|
+
mixer?: (from: unknown, to: unknown) => (t: number) => unknown;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Clamps a numeric value between two bounds, irrespective of their order.
|
|
18
|
+
*
|
|
19
|
+
* @param val Current value.
|
|
20
|
+
* @param a First bound.
|
|
21
|
+
* @param b Second bound.
|
|
22
|
+
* @returns Value clamped to [min(a,b), max(a,b)].
|
|
23
|
+
*/
|
|
24
|
+
export declare const clampBidirectional: (val: number, a: number, b: number) => number;
|
|
25
|
+
/**
|
|
26
|
+
* Creates a derived Svelte store that transforms values.
|
|
27
|
+
*
|
|
28
|
+
* Two supported forms (API parity with Motion's useTransform):
|
|
29
|
+
* - Mapping form: Map a numeric source across input/output ranges.
|
|
30
|
+
* Example: `useTransform(src, [0, 100], [0, 1], { clamp: true })`
|
|
31
|
+
* - Function form: Recompute from a function based on dependency stores.
|
|
32
|
+
* Example: `useTransform(() => compute(), [depA, depB])`
|
|
33
|
+
*
|
|
34
|
+
* @template T
|
|
35
|
+
* @param {Readable<number>|(() => T)} sourceOrCompute Numeric source store (mapping form), or compute function (function form).
|
|
36
|
+
* @param {number[]|Readable<unknown>[]} inputOrDeps Input stops (mapping) or dependency stores (function form).
|
|
37
|
+
* @param {T[]=} output Output stops (mapping form only). Must match input length.
|
|
38
|
+
* @param {TransformOptions=} options Mapping options (mapping form only).
|
|
39
|
+
* @returns {Readable<T>} A derived Svelte readable store.
|
|
40
|
+
* @see https://motion.dev/docs/react-use-transform?platform=react
|
|
41
|
+
*/
|
|
42
|
+
export declare const useTransform: <T = number>(sourceOrCompute: Readable<number> | (() => T), inputOrDeps: number[] | Readable<unknown>[], output?: T[], options?: TransformOptions) => Readable<T>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { derived, readable } from 'svelte/store';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a linear mixer function for numeric values.
|
|
4
|
+
*
|
|
5
|
+
* @param from Starting numeric value.
|
|
6
|
+
* @param to Ending numeric value.
|
|
7
|
+
* @returns Function that linearly interpolates between from→to for progress t∈[0,1].
|
|
8
|
+
* @private
|
|
9
|
+
*/
|
|
10
|
+
const linearMix = (from, to) => (t) => from + (to - from) * t;
|
|
11
|
+
/**
|
|
12
|
+
* Clamps a numeric value between two bounds, irrespective of their order.
|
|
13
|
+
*
|
|
14
|
+
* @param val Current value.
|
|
15
|
+
* @param a First bound.
|
|
16
|
+
* @param b Second bound.
|
|
17
|
+
* @returns Value clamped to [min(a,b), max(a,b)].
|
|
18
|
+
*/
|
|
19
|
+
export const clampBidirectional = (val, a, b) => {
|
|
20
|
+
const lower = a < b ? a : b;
|
|
21
|
+
const upper = a < b ? b : a;
|
|
22
|
+
return Math.min(Math.max(val, lower), upper);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Finds the segment index i such that x lies between input[i] and input[i+1].
|
|
26
|
+
* Handles both ascending and descending input ranges.
|
|
27
|
+
*
|
|
28
|
+
* @param input Monotonic list of input stops.
|
|
29
|
+
* @param x Current input value.
|
|
30
|
+
* @returns Segment index in range [0, input.length - 2].
|
|
31
|
+
* @private
|
|
32
|
+
*/
|
|
33
|
+
const findSegment = (input, x) => {
|
|
34
|
+
if (input.length < 2)
|
|
35
|
+
return 0;
|
|
36
|
+
const first = input[0];
|
|
37
|
+
const second = input[1];
|
|
38
|
+
const ascending = second > first;
|
|
39
|
+
if (ascending) {
|
|
40
|
+
if (x <= first)
|
|
41
|
+
return 0;
|
|
42
|
+
for (let i = 1; i < input.length; i++) {
|
|
43
|
+
const curr = input[i];
|
|
44
|
+
if (x <= curr)
|
|
45
|
+
return i - 1;
|
|
46
|
+
}
|
|
47
|
+
return input.length - 2;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (x >= first)
|
|
51
|
+
return 0;
|
|
52
|
+
for (let i = 1; i < input.length; i++) {
|
|
53
|
+
const curr = input[i];
|
|
54
|
+
if (x >= curr)
|
|
55
|
+
return i - 1;
|
|
56
|
+
}
|
|
57
|
+
return input.length - 2;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Creates a derived Svelte store that transforms values.
|
|
62
|
+
*
|
|
63
|
+
* Two supported forms (API parity with Motion's useTransform):
|
|
64
|
+
* - Mapping form: Map a numeric source across input/output ranges.
|
|
65
|
+
* Example: `useTransform(src, [0, 100], [0, 1], { clamp: true })`
|
|
66
|
+
* - Function form: Recompute from a function based on dependency stores.
|
|
67
|
+
* Example: `useTransform(() => compute(), [depA, depB])`
|
|
68
|
+
*
|
|
69
|
+
* @template T
|
|
70
|
+
* @param {Readable<number>|(() => T)} sourceOrCompute Numeric source store (mapping form), or compute function (function form).
|
|
71
|
+
* @param {number[]|Readable<unknown>[]} inputOrDeps Input stops (mapping) or dependency stores (function form).
|
|
72
|
+
* @param {T[]=} output Output stops (mapping form only). Must match input length.
|
|
73
|
+
* @param {TransformOptions=} options Mapping options (mapping form only).
|
|
74
|
+
* @returns {Readable<T>} A derived Svelte readable store.
|
|
75
|
+
* @see https://motion.dev/docs/react-use-transform?platform=react
|
|
76
|
+
*/
|
|
77
|
+
export const useTransform = (sourceOrCompute, inputOrDeps, output, options = {}) => {
|
|
78
|
+
// Function form: (compute, deps)
|
|
79
|
+
if (typeof sourceOrCompute === 'function') {
|
|
80
|
+
const compute = sourceOrCompute;
|
|
81
|
+
const deps = inputOrDeps;
|
|
82
|
+
if (!deps || deps.length === 0)
|
|
83
|
+
return readable(compute());
|
|
84
|
+
return derived(deps, () => compute());
|
|
85
|
+
}
|
|
86
|
+
// Mapping form: (source, input, output, options)
|
|
87
|
+
const source = sourceOrCompute;
|
|
88
|
+
const input = inputOrDeps;
|
|
89
|
+
const out = (output ?? []);
|
|
90
|
+
const { clamp = true, ease, mixer } = options;
|
|
91
|
+
if (input.length !== out.length) {
|
|
92
|
+
throw new Error(`useTransform: input and output arrays must be the same length (input: ${input.length}, output: ${out.length})`);
|
|
93
|
+
}
|
|
94
|
+
const easings = Array.isArray(ease)
|
|
95
|
+
? ease
|
|
96
|
+
: ease
|
|
97
|
+
? new Array(Math.max(0, out.length - 1)).fill(ease)
|
|
98
|
+
: [];
|
|
99
|
+
return derived(source, (x) => {
|
|
100
|
+
if (input.length === 0)
|
|
101
|
+
return out[0];
|
|
102
|
+
if (input.length === 1)
|
|
103
|
+
return out[0];
|
|
104
|
+
const seg = findSegment(input, x);
|
|
105
|
+
const i0 = input[seg];
|
|
106
|
+
const i1 = input[seg + 1];
|
|
107
|
+
const o0 = out[seg];
|
|
108
|
+
const o1 = out[seg + 1];
|
|
109
|
+
// Runtime validation to avoid non-null assertions
|
|
110
|
+
if (i0 === undefined || i1 === undefined || o0 === undefined || o1 === undefined) {
|
|
111
|
+
console.warn('useTransform: Invalid segment bounds', {
|
|
112
|
+
seg,
|
|
113
|
+
inputLength: input.length,
|
|
114
|
+
outputLength: out.length
|
|
115
|
+
});
|
|
116
|
+
return out[0];
|
|
117
|
+
}
|
|
118
|
+
const localClamp = clamp ? clampBidirectional : (val) => val;
|
|
119
|
+
const progress = i0 === i1 ? 0 : (localClamp(x, i0, i1) - i0) / (i1 - i0);
|
|
120
|
+
const e = easings[seg];
|
|
121
|
+
const p = e ? e(progress) : progress;
|
|
122
|
+
const mix = mixer
|
|
123
|
+
? mixer(o0, o1)
|
|
124
|
+
: typeof o0 === 'number' && typeof o1 === 'number'
|
|
125
|
+
? linearMix(o0, o1)
|
|
126
|
+
: (_t) => (p < 0.5 ? o0 : o1);
|
|
127
|
+
return mix(p);
|
|
128
|
+
});
|
|
129
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-motion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A lightweight animation library for Svelte 5 that provides smooth, hardware-accelerated animations. Features include spring physics, custom easing, and fluid transitions. Built on top of the motion library, it offers a simple API for creating complex animations with minimal code. Perfect for interactive UIs, micro-interactions, and engaging user experiences.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|