@humanspeak/svelte-motion 0.1.1 → 0.1.2

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 CHANGED
@@ -79,6 +79,7 @@ This package carefully selects its dependencies to provide a robust and maintain
79
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
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
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) |
82
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) |
83
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) |
84
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) |
@@ -111,6 +112,13 @@ Svelte Motion now supports hover interactions via the `whileHover` prop, similar
111
112
  <motion.button whileTap={{ scale: 0.95 }} />
112
113
  ```
113
114
 
115
+ - Callbacks: `onTapStart`, `onTap`, `onTapCancel` are supported.
116
+ - Accessibility: Elements with `whileTap` are keyboard-accessible (Enter and Space).
117
+ - Enter or Space down → fires `onTapStart` and applies `whileTap` (Space prevents default scrolling)
118
+ - Enter or Space up → fires `onTap`
119
+ - Blur while key is held → fires `onTapCancel`
120
+ - `MotionContainer` sets `tabindex="0"` automatically when `whileTap` is present and no `tabindex`/`tabIndex` is provided.
121
+
114
122
  ### Animation lifecycle
115
123
 
116
124
  ```svelte
@@ -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
- let mergedTransition = $derived<MotionTransition>(
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 transitionAmimate: MotionTransition = mergedTransition ?? {}
92
+ const transitionAnimate: MotionTransition = mergedTransition ?? {}
64
93
  const payload = $state.snapshot(animateProp)
65
94
  animateWithLifecycle(
66
95
  element,
67
- payload as unknown as import('motion').DOMKeyframesDefinition,
68
- transitionAmimate as unknown as import('motion').AnimationOptions,
69
- (def) =>
70
- onAnimationStartProp?.(
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 import('motion').AnimationOptions,
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/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 = ((definition: DOMKeyframesDefinition | undefined) => void) | undefined;
62
- export type MotionAnimationComplete = ((definition: DOMKeyframesDefinition | undefined) => void) | undefined;
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,2 @@
1
+ import type { SvelteHTMLElements } from 'svelte/elements';
2
+ export declare const isNativelyFocusable: (tag: keyof SvelteHTMLElements, attrs?: Record<string, unknown>) => boolean;
@@ -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
+ };
@@ -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>) => (() => void);
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
- const handlePointerDown = () => {
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 handlePointerUp = () => {
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', handlePointerUp);
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', handlePointerUp);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-motion",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",