@humanspeak/svelte-motion 0.5.2 → 0.5.4

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.
@@ -5,6 +5,8 @@
5
5
 
6
6
  <script lang="ts">
7
7
  import { getMotionConfig } from '../components/motionConfig.context'
8
+ import { getLazyMotionContext } from '../components/lazyMotion.context'
9
+ import { domMax } from '../features/domMax'
8
10
  import {
9
11
  filterReducedMotionKeyframes,
10
12
  useReducedMotionConfig
@@ -39,10 +41,11 @@
39
41
  computeFlipTransforms,
40
42
  runFlipAnimation,
41
43
  setCompositorHints,
42
- observeLayoutChanges
44
+ observeLayoutChanges,
45
+ type RectLike
43
46
  } from '../utils/layout'
44
47
  import type { SvelteHTMLElements } from 'svelte/elements'
45
- import { mergeInlineStyles } from '../utils/style'
48
+ import { mergeInlineStyles, extractTransform } from '../utils/style'
46
49
  import { isNativelyFocusable } from '../utils/a11y'
47
50
  import {
48
51
  getAnimatePresenceContext,
@@ -53,7 +56,15 @@
53
56
  import { getInitialKeyframes } from '../utils/initial'
54
57
  import { attachDrag } from '../utils/drag'
55
58
  import { attachPan, type AttachPanCleanup } from '../utils/pan'
56
- import { resolveInitial, resolveAnimate, resolveExit, resolveWhile } from '../utils/variants'
59
+ import { ProjectionNode } from '../utils/projection'
60
+ import { getProjectionParent, setProjectionParent } from '../components/projection.context'
61
+ import {
62
+ resolveInitial,
63
+ resolveAnimate,
64
+ resolveExit,
65
+ resolveWhile,
66
+ resolveRestingValues
67
+ } from '../utils/variants'
57
68
  import {
58
69
  setVariantContext,
59
70
  getVariantContext,
@@ -134,12 +145,24 @@
134
145
  layout: layoutProp,
135
146
  layoutId: layoutIdProp,
136
147
  layoutScroll: layoutScrollProp,
148
+ onProjectionUpdate: onProjectionUpdateProp,
137
149
  ref: element = $bindable(null),
138
150
  ...rest
139
151
  }: Props = $props()
140
152
  let isLoaded = $state<'mounting' | 'initial' | 'ready' | 'animated'>('mounting')
153
+ // True once the enter/animate animation has COMPLETED. Until then the
154
+ // WAAPI animation owns the transform; flipping the inline baseline to
155
+ // the target mid-run causes a one-frame snap (the target shows through
156
+ // for the frame the inline changes). We therefore only apply the target
157
+ // as the inline style once settled — see the style derivation. (#377)
158
+ let enterAnimationSettled = $state(false)
141
159
  let dataPath = $state<number>(-1)
142
160
  const motionConfig = $derived(getMotionConfig())
161
+ const lazyMotion = getLazyMotionContext()
162
+ const activeFeatures = $derived(lazyMotion?.getFeatures() ?? domMax)
163
+ const hasGestureFeatures = $derived(!!activeFeatures.gestures)
164
+ const hasDragFeatures = $derived(!!activeFeatures.drag)
165
+ const hasLayoutFeatures = $derived(!!activeFeatures.layout)
143
166
  const reducedMotionState = useReducedMotionConfig()
144
167
  // `.current` is $state-backed inside reducedMotionState; tracking it via
145
168
  // $derived makes `reducedMotion` re-evaluate whenever the OS preference
@@ -191,6 +214,46 @@
191
214
  return refs.filter((el): el is HTMLElement => Boolean(el))
192
215
  }
193
216
 
217
+ // Projection tree wiring (#379). Capture the parent node BEFORE
218
+ // publishing our own — same shadowing trap as layoutScroll above.
219
+ // The node measures through `resolveLayoutScrollAncestors` so its
220
+ // boxes share the FLIP coordinate space, and zeros ancestor
221
+ // transforms during measure so nested layout-animated parents don't
222
+ // corrupt a child's delta.
223
+ // The user-authored transform, sourced from the `style` prop rather
224
+ // than the live inline transform — the latter already carries any
225
+ // transform-type `initial`/`animate` keyframe by the time the node
226
+ // measures, which would be mistaken for the user's base.
227
+ const userBaseTransform = $derived(extractTransform(styleProp))
228
+
229
+ const projectionParent = getProjectionParent()
230
+ const projection = new ProjectionNode({
231
+ parent: projectionParent,
232
+ getScrollContainers: resolveLayoutScrollAncestors,
233
+ getBaseTransform: () => userBaseTransform
234
+ })
235
+ setProjectionParent(projection)
236
+
237
+ // Convert a projection `Box` (ancestor chain reset to base, self
238
+ // transform stripped, scroll containers compensated) to the
239
+ // `RectLike` shape `computeFlipTransforms` consumes.
240
+ const boxToRectLike = (box: {
241
+ x: { min: number; max: number }
242
+ y: { min: number; max: number }
243
+ }): RectLike => ({
244
+ left: box.x.min,
245
+ top: box.y.min,
246
+ width: box.x.max - box.x.min,
247
+ height: box.y.max - box.y.min
248
+ })
249
+
250
+ // Ancestor-transform-invariant layout measurement for seeding the
251
+ // FLIP effect's first rect.
252
+ const measureLayoutRect = (): RectLike | null => {
253
+ const box = projection.measure()
254
+ return box ? boxToRectLike(box) : null
255
+ }
256
+
194
257
  // Get current presence depth (0 = direct child of AnimatePresence, undefined = not in AnimatePresence)
195
258
  const presenceDepth = getPresenceDepth()
196
259
 
@@ -478,6 +541,17 @@
478
541
  )
479
542
  )
480
543
 
544
+ // Reduced-motion-filtered animate values used as the inline-style
545
+ // baseline so an animated value (transforms included) persists after
546
+ // the WAAPI animation completes (#377). Filtered exactly like
547
+ // `initialKeyframes` so transforms are stripped under reduced motion.
548
+ const animateKeyframes = $derived(
549
+ filterReducedMotionKeyframes(
550
+ resolvedAnimate as Record<string, unknown> | undefined,
551
+ reducedMotion
552
+ )
553
+ )
554
+
481
555
  // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
482
556
  const derivedAttrs = $derived<Record<string, unknown>>({
483
557
  ...(rest as Record<string, unknown>),
@@ -486,7 +560,8 @@
486
560
  // key, empty array) would otherwise add `tabindex=0` for an
487
561
  // element that never actually receives a tap gesture — an
488
562
  // unintended tab stop. (#349 CR feedback)
489
- ...(isNotEmpty(resolvedWhileTap) &&
563
+ ...(hasGestureFeatures &&
564
+ isNotEmpty(resolvedWhileTap) &&
490
565
  !isNativelyFocusable(tag, rest as Record<string, unknown>) &&
491
566
  ((rest as Record<string, unknown>)?.tabindex ??
492
567
  (rest as Record<string, unknown>)?.tabIndex ??
@@ -516,20 +591,33 @@
516
591
  initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
517
592
  ? `${styleProp || ''};visibility:hidden`
518
593
  : styleProp,
519
- // Apply initialKeyframes as inline styles during mounting and initial phases
520
- // The animation starts in RAF after 'initial' phase, so we need styles until then
521
- // When ready AND we have initialKeyframes: DON'T set any animated properties!
522
- // WAAPI is controlling them and inline styles can override the animation
594
+ // The "from" slot: apply initialKeyframes as inline styles during
595
+ // the mounting/initial phases (before the WAAPI animation locks
596
+ // its from-value and we promote to 'ready' see the lifecycle
597
+ // around the enter rAF). mergeInlineStyles prefers this slot when
598
+ // non-empty, so it wins over the animate slot below in these phases.
523
599
  isLoaded === 'mounting' || isLoaded === 'initial'
524
600
  ? (initialKeyframes as unknown as Record<string, unknown>)
525
601
  : undefined,
526
- // Only use resolvedAnimate as fallback when we DON'T have initialKeyframes
527
- // If we have initialKeyframes, the enter animation is running - setting
528
- // inline styles to the target values will override the WAAPI animation
529
- // Use isNotEmpty to handle empty initial objects (initial: {}) which should fallback
530
- isNotEmpty(initialKeyframes)
531
- ? undefined
532
- : (resolvedAnimate as unknown as Record<string, unknown>)
602
+ // The "target" slot. Only AFTER the enter animation completes does
603
+ // the target become the inline baseline, so the element holds it
604
+ // once WAAPI surrenders the property (default fill:'none' would
605
+ // otherwise leave transform:none). It must NOT be applied during
606
+ // the run: flipping the inline value to the target mid-animation
607
+ // shows the target for the one frame the inline changes (a visible
608
+ // snap), since WAAPI's composite doesn't override that exact frame.
609
+ // While the animation runs we keep the original behavior — initial
610
+ // keyframes own the inline (via the slot above), or, with no
611
+ // initial, the animate values seed the inline as the from. Resting
612
+ // values collapse keyframe arrays to their last element
613
+ // (animate={{x:[0,100,50]}} rests at 50). (#377)
614
+ enterAnimationSettled
615
+ ? (resolveRestingValues(
616
+ animateKeyframes as DOMKeyframesDefinition | undefined
617
+ ) as unknown as Record<string, unknown>)
618
+ : isNotEmpty(initialKeyframes)
619
+ ? undefined
620
+ : (animateKeyframes as unknown as Record<string, unknown>)
533
621
  ),
534
622
  class: classProp
535
623
  })
@@ -546,7 +634,7 @@
546
634
  // after any non-zero duration settle animation.
547
635
  let teardownDrag: (() => void) | null = null
548
636
  $effect(() => {
549
- if (!(element && isLoaded === 'ready')) return
637
+ if (!(element && isLoaded === 'ready' && hasDragFeatures)) return
550
638
  // Only attach if drag enabled
551
639
  if (!dragProp) return
552
640
  // Clean up previous
@@ -707,7 +795,7 @@
707
795
  isLoaded
708
796
  })
709
797
  }
710
- if (!element) return
798
+ if (!element || !hasGestureFeatures) return
711
799
  // Defer attachment until the element has settled out of the enter
712
800
  // animation phase — matches the gate every other gesture effect
713
801
  // in this file uses (drag, whileTap, whileHover, whileFocus,
@@ -807,12 +895,20 @@
807
895
  transitionAnimate
808
896
  })
809
897
 
898
+ // A fresh run owns the transform again until it completes.
899
+ enterAnimationSettled = false
810
900
  animateWithLifecycle(
811
901
  element,
812
902
  payload as unknown as DOMKeyframesDefinition,
813
903
  transitionAnimate as unknown as AnimationOptions,
814
904
  (def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
815
- (def) => onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
905
+ (def) => {
906
+ // Now the target is the resting state — promote it to the
907
+ // inline baseline so it persists after WAAPI surrenders the
908
+ // property (default fill:'none'). (#377)
909
+ enterAnimationSettled = true
910
+ onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
911
+ }
816
912
  )
817
913
  }
818
914
 
@@ -930,26 +1026,65 @@
930
1026
  : undefined
931
1027
  )
932
1028
 
1029
+ // Projection node lifecycle + the `onProjectionUpdate` listener.
1030
+ // Mount once the element binds; seed the baseline layout; unmount on
1031
+ // cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
1032
+ // subscription lives in its own effect below so a change to the
1033
+ // callback's identity re-subscribes WITHOUT tearing the node down
1034
+ // (an unmount would clear latestLayout/children and re-seed the
1035
+ // first commit instead of emitting a real delta).
1036
+ $effect(() => {
1037
+ if (!element) return
1038
+ projection.mount(element)
1039
+ projection.measure() // seed latestLayout so the first commit can diff
1040
+ return () => {
1041
+ projection.unmount()
1042
+ }
1043
+ })
1044
+
1045
+ // Subscribe the consumer's `onProjectionUpdate` callback. Separate
1046
+ // from the mount effect so re-subscribing on a callback-identity
1047
+ // change never unmounts the node.
1048
+ $effect(() => {
1049
+ if (!(element && onProjectionUpdateProp)) return
1050
+ const off = projection.addEventListener('didUpdate', (data) => onProjectionUpdateProp(data))
1051
+ return () => {
1052
+ off()
1053
+ }
1054
+ })
1055
+
933
1056
  // Minimal layout animation using FLIP when `layout` is enabled.
934
1057
  // When layout === 'position' we only translate.
935
1058
  // When layout === true we also scale to smoothly interpolate size changes.
936
- let lastRect: DOMRect | null = null
1059
+ let lastRect: RectLike | null = null
937
1060
  $effect(() => {
938
- if (!(element && layoutProp && isLoaded === 'ready')) return
939
-
940
- // Initialize last rect on first ready frame
941
- lastRect = measureRect(element!, resolveLayoutScrollAncestors())
1061
+ if (!(element && layoutProp && isLoaded === 'ready' && hasLayoutFeatures)) return
1062
+
1063
+ // Initialize last rect on first ready frame. We measure through the
1064
+ // projection node rather than `measureRect` directly so the rect is
1065
+ // ancestor-transform-invariant: the node resets the whole ancestor
1066
+ // chain to its mount-time base while reading, which strips any
1067
+ // motion-applied (FLIP/drag) transform up the tree. Without this a
1068
+ // child re-measures and FLIPs whenever an ancestor's transform
1069
+ // animates, even though the child's own layout never moved. (#379)
1070
+ lastRect = measureLayoutRect()
942
1071
  // Hint compositor for smoother FLIP transforms
943
1072
  setCompositorHints(element!, true)
944
1073
 
945
1074
  let rafId: number | null = null
946
1075
  const runFlip = () => {
947
- const scrollContainers = resolveLayoutScrollAncestors()
1076
+ // commitLayoutChange does the ancestor-stripped measure, fans
1077
+ // the delta out to `onProjectionUpdate` listeners, AND returns
1078
+ // the freshly-measured box — so the FLIP reuses that single
1079
+ // measurement as `next` instead of walking + transform-resetting
1080
+ // the ancestor chain a second time per frame. (#379)
1081
+ const nextBox = projection.commitLayoutChange()
1082
+ if (!nextBox) return
1083
+ const next = boxToRectLike(nextBox)
948
1084
  if (!lastRect) {
949
- lastRect = measureRect(element!, scrollContainers)
1085
+ lastRect = next
950
1086
  return
951
1087
  }
952
- const next = measureRect(element!, scrollContainers)
953
1088
  const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
954
1089
  runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
955
1090
  lastRect = next
@@ -978,7 +1113,16 @@
978
1113
  // Shared layout animation via layoutId.
979
1114
  // On mount, consume the previous snapshot and FLIP from its position.
980
1115
  $effect(() => {
981
- if (!(element && scopedLayoutId && layoutIdRegistry && isLoaded === 'ready')) return
1116
+ if (
1117
+ !(
1118
+ element &&
1119
+ scopedLayoutId &&
1120
+ layoutIdRegistry &&
1121
+ isLoaded === 'ready' &&
1122
+ hasLayoutFeatures
1123
+ )
1124
+ )
1125
+ return
982
1126
 
983
1127
  const prev = layoutIdRegistry.consume(scopedLayoutId)
984
1128
  if (!prev) return // First appearance, no animation needed
@@ -996,7 +1140,10 @@
996
1140
 
997
1141
  // whileTap handling via motion-dom's press()
998
1142
  $effect(() => {
999
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileTap))) return
1143
+ if (
1144
+ !(element && isLoaded === 'ready' && hasGestureFeatures && isNotEmpty(resolvedWhileTap))
1145
+ )
1146
+ return
1000
1147
  return attachWhileTap(
1001
1148
  element!,
1002
1149
  (resolvedWhileTap ?? {}) as Record<string, unknown>,
@@ -1016,7 +1163,15 @@
1016
1163
 
1017
1164
  // whileHover handling, gated to true-hover devices to avoid sticky states on touch
1018
1165
  $effect(() => {
1019
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileHover))) return
1166
+ if (
1167
+ !(
1168
+ element &&
1169
+ isLoaded === 'ready' &&
1170
+ hasGestureFeatures &&
1171
+ isNotEmpty(resolvedWhileHover)
1172
+ )
1173
+ )
1174
+ return
1020
1175
  return attachWhileHover(
1021
1176
  element!,
1022
1177
  (resolvedWhileHover ?? {}) as Record<string, unknown>,
@@ -1031,7 +1186,15 @@
1031
1186
 
1032
1187
  // whileFocus handling for keyboard focus interactions
1033
1188
  $effect(() => {
1034
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileFocus))) return
1189
+ if (
1190
+ !(
1191
+ element &&
1192
+ isLoaded === 'ready' &&
1193
+ hasGestureFeatures &&
1194
+ isNotEmpty(resolvedWhileFocus)
1195
+ )
1196
+ )
1197
+ return
1035
1198
  return attachWhileFocus(
1036
1199
  element!,
1037
1200
  (resolvedWhileFocus ?? {}) as Record<string, unknown>,
@@ -1046,7 +1209,15 @@
1046
1209
 
1047
1210
  // whileInView handling for viewport intersection
1048
1211
  $effect(() => {
1049
- if (!(element && isLoaded === 'ready' && isNotEmpty(resolvedWhileInView))) return
1212
+ if (
1213
+ !(
1214
+ element &&
1215
+ isLoaded === 'ready' &&
1216
+ hasGestureFeatures &&
1217
+ isNotEmpty(resolvedWhileInView)
1218
+ )
1219
+ )
1220
+ return
1050
1221
  return attachWhileInView(
1051
1222
  element!,
1052
1223
  (resolvedWhileInView ?? {}) as Record<string, unknown>,
package/dist/index.d.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  import AnimatePresence from './components/AnimatePresence.svelte';
2
2
  import LayoutGroup from './components/LayoutGroup.svelte';
3
+ import LazyMotion from './components/LazyMotion.svelte';
3
4
  import MotionConfig from './components/MotionConfig.svelte';
4
5
  import PresenceChild from './components/PresenceChild.svelte';
6
+ export type { FeatureBundle, LazyFeatureBundle } from './features';
7
+ export { domAnimation } from './features/domAnimation';
8
+ export { domMax } from './features/domMax';
9
+ export { domMin } from './features/domMin';
10
+ export { m } from './m';
5
11
  export { motion } from './motion';
6
12
  export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
7
13
  export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
8
14
  export { clamp, distance, distance2D, interpolate, mix, pipe, progress, wrap } from 'motion';
9
- export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ReducedMotionConfig, Variants } from './types';
15
+ export type { DragAxis, DragConstraints, DragControls, DragInfo, DragTransition, MotionAnimate, MotionInitial, MotionOnDirectionLock, MotionOnDragTransitionEnd, MotionOnInViewEnd, MotionOnInViewStart, MotionOnProjectionUpdate, MotionTransition, MotionWhileDrag, MotionWhileFocus, MotionWhileHover, MotionWhileInView, MotionWhileTap, ProjectionUpdatePayload, ReducedMotionConfig, Variants } from './types';
10
16
  export { useAnimate } from './utils/animate.svelte';
11
17
  export type { AnimationScope } from './utils/animate.svelte';
12
18
  export { useAnimationFrame } from './utils/animationFrame';
@@ -42,7 +48,7 @@ export { styleString } from './utils/styleObject.svelte';
42
48
  export { useTime } from './utils/time.svelte';
43
49
  export { useTransform } from './utils/transform.svelte';
44
50
  export type { MultiTransformer, SingleTransformer, TransformOptions, TransformOutputMap, TransformSource } from './utils/transform.svelte';
45
- export { AnimatePresence, LayoutGroup, MotionConfig, PresenceChild };
51
+ export { AnimatePresence, LayoutGroup, LazyMotion, MotionConfig, PresenceChild };
46
52
  export { default as MotionA } from './html/A.svelte';
47
53
  export { default as MotionAbbr } from './html/Abbr.svelte';
48
54
  export { default as MotionAddress } from './html/Address.svelte';
package/dist/index.js CHANGED
@@ -1,7 +1,12 @@
1
1
  import AnimatePresence from './components/AnimatePresence.svelte';
2
2
  import LayoutGroup from './components/LayoutGroup.svelte';
3
+ import LazyMotion from './components/LazyMotion.svelte';
3
4
  import MotionConfig from './components/MotionConfig.svelte';
4
5
  import PresenceChild from './components/PresenceChild.svelte';
6
+ export { domAnimation } from './features/domAnimation';
7
+ export { domMax } from './features/domMax';
8
+ export { domMin } from './features/domMin';
9
+ export { m } from './m';
5
10
  export { motion } from './motion';
6
11
  // Re-export core animation functions from motion
7
12
  export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
@@ -31,7 +36,7 @@ export { stringifyStyleObject } from './utils/styleObject';
31
36
  export { styleString } from './utils/styleObject.svelte';
32
37
  export { useTime } from './utils/time.svelte';
33
38
  export { useTransform } from './utils/transform.svelte';
34
- export { AnimatePresence, LayoutGroup, MotionConfig, PresenceChild };
39
+ export { AnimatePresence, LayoutGroup, LazyMotion, MotionConfig, PresenceChild };
35
40
  // Named component exports — tree-shakeable alternative to the `motion` object
36
41
  export { default as MotionA } from './html/A.svelte';
37
42
  export { default as MotionAbbr } from './html/Abbr.svelte';
package/dist/m.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { MotionComponents } from './html/index';
2
+ /**
3
+ * Lazy motion component namespace used with `<LazyMotion>`.
4
+ *
5
+ * The namespace mirrors the default `motion` object API (`m.div`, `m.button`,
6
+ * `m.svg`, etc.) while reading feature availability from the nearest
7
+ * LazyMotion provider.
8
+ */
9
+ export declare const m: MotionComponents;
package/dist/m.js ADDED
@@ -0,0 +1,9 @@
1
+ import * as html from './html/index';
2
+ /**
3
+ * Lazy motion component namespace used with `<LazyMotion>`.
4
+ *
5
+ * The namespace mirrors the default `motion` object API (`m.div`, `m.button`,
6
+ * `m.svg`, etc.) while reading feature availability from the nearest
7
+ * LazyMotion provider.
8
+ */
9
+ export const m = Object.fromEntries(Object.entries(html).map(([key, component]) => [key.toLowerCase(), component]));
package/dist/types.d.ts CHANGED
@@ -255,6 +255,59 @@ export type MotionOnPanSessionStart = ((event: PointerEvent, info: DragInfo) =>
255
255
  export type MotionOnPanStart = ((event: PointerEvent, info: DragInfo) => void) | undefined;
256
256
  export type MotionOnPan = ((event: PointerEvent, info: DragInfo) => void) | undefined;
257
257
  export type MotionOnPanEnd = ((event: PointerEvent, info: DragInfo) => void) | undefined;
258
+ /**
259
+ * Payload delivered to `onProjectionUpdate`. Re-declared structurally
260
+ * here (rather than imported from `projection.ts`) so `types.ts`
261
+ * stays dependency-free at the type layer. `delta.x/y.translate` is the
262
+ * px shift the element's layout box moved between the pre-change
263
+ * snapshot and the post-change measurement; `hasLayoutChanged` is false
264
+ * only when the delta is within a tight float epsilon (±0.01px
265
+ * translate), so even a sub-pixel real move is reported as a change.
266
+ */
267
+ export type ProjectionUpdatePayload = {
268
+ layout: {
269
+ x: {
270
+ min: number;
271
+ max: number;
272
+ };
273
+ y: {
274
+ min: number;
275
+ max: number;
276
+ };
277
+ };
278
+ snapshot: {
279
+ x: {
280
+ min: number;
281
+ max: number;
282
+ };
283
+ y: {
284
+ min: number;
285
+ max: number;
286
+ };
287
+ };
288
+ delta: {
289
+ x: {
290
+ translate: number;
291
+ scale: number;
292
+ origin: number;
293
+ originPoint: number;
294
+ };
295
+ y: {
296
+ translate: number;
297
+ scale: number;
298
+ origin: number;
299
+ originPoint: number;
300
+ };
301
+ };
302
+ hasLayoutChanged: boolean;
303
+ };
304
+ /**
305
+ * Fires after each layout change to a `motion.*` element that has
306
+ * `layout` enabled, with the FLIP delta between the pre- and
307
+ * post-change layout boxes. Mirrors framer-motion's `onLayoutMeasure`
308
+ * surface. Wired through the element's internal `ProjectionNode`.
309
+ */
310
+ export type MotionOnProjectionUpdate = ((data: ProjectionUpdatePayload) => void) | undefined;
258
311
  export type DragAxis = boolean | 'x' | 'y';
259
312
  export type DragConstraints = {
260
313
  top?: number;
@@ -380,6 +433,12 @@ export type MotionProps = {
380
433
  class?: string;
381
434
  /** Enable FLIP layout animations; "position" limits to translation only */
382
435
  layout?: boolean | 'position';
436
+ /**
437
+ * Fires after each `layout`-driven change with the FLIP delta from
438
+ * the element's internal projection node. Mirrors framer-motion's
439
+ * `onLayoutMeasure`. Requires `layout` to be enabled.
440
+ */
441
+ onProjectionUpdate?: MotionOnProjectionUpdate;
383
442
  /** Shared layout animation identifier. Elements with matching layoutId animate between positions. */
384
443
  layoutId?: string;
385
444
  /**
@@ -66,24 +66,55 @@ export declare const resolveConstraints: (el: HTMLElement | null, constraints: D
66
66
  */
67
67
  export declare const applyElastic: (value: number, min: number, max: number, elastic: number) => number;
68
68
  /**
69
- * Attach a drag gesture to an element.
69
+ * Cleanup handle returned by {@link attachDrag}. Invoke it to detach the
70
+ * gesture's pointer/resize listeners. It does NOT cancel an in-flight
71
+ * momentum/settle animation — matching framer-motion, whose drag teardown
72
+ * removes listeners only and deliberately lets motion continue across an
73
+ * unmount/remount (e.g. reorder reconciliation); the animation is cleaned
74
+ * up by the element/motion-value lifecycle.
70
75
  *
71
- * Captures the pointer, updates x/y transforms with axis and optional direction lock,
72
- * applies elastic overflow against constraints, emits lifecycle callbacks with DragInfo,
73
- * and runs a momentum animation on release when enabled.
76
+ * The handle also carries `adjustOrigin(dx, dy)`, which shifts the LIVE
77
+ * gesture's origin + visual offset by a layout-shift delta mid-drag — used
78
+ * for projection-driven cursor pinning when a layout slot moves under the
79
+ * dragged element (#379 / #310). It is a no-op when not currently dragging
80
+ * and compensates on both axes (mirroring upstream's per-axis `eachAxis`
81
+ * compensation).
74
82
  */
83
+ export type AttachDragCleanup = (() => void) & {
84
+ adjustOrigin: (dx: number, dy: number) => void;
85
+ };
75
86
  /**
76
- * Attach a drag gesture to an HTMLElement.
87
+ * Attach a drag gesture to an element.
88
+ *
89
+ * Captures the pointer and updates x/y transforms with axis and optional
90
+ * direction lock, applies elastic overflow against constraints, emits
91
+ * lifecycle callbacks with `DragInfo`, and runs a momentum animation on
92
+ * release when enabled.
77
93
  *
78
94
  * Lifecycle:
79
95
  * - pointerdown → capture pointer, snapshot origin, start velocity history, enter whileDrag
80
96
  * - pointermove → compute deltas, direction lock, apply constraints + elastic, write x/y
81
97
  * - pointerup/cancel → either run momentum decay to a target or settle/clamp instantly
82
98
  *
83
- * Important invariants:
84
- * - `applied` tracks the currently applied transform (x/y). Always keep it in sync when
85
- * writing transforms or finishing animations so a second drag starts from the right origin.
86
- * - If you see a "jump" at the start of a second drag, it usually means `applied` wasn't
87
- * updated after a non-0-duration settle animation.
99
+ * Invariant: `applied` tracks the currently applied x/y transform — it
100
+ * must stay in sync when writing transforms or finishing animations, or a
101
+ * second drag "jumps" from a stale origin (commonly a missed update after
102
+ * a non-zero-duration settle animation).
103
+ *
104
+ * @param el The element to make draggable.
105
+ * @param opts Drag options — `axis`, `constraints`, `elastic`,
106
+ * `momentum`, `whileDrag`, and the `onDrag*` lifecycle callbacks.
107
+ * @returns A callable cleanup handle ({@link AttachDragCleanup}): call it
108
+ * to detach the gesture's listeners (in-flight momentum is not
109
+ * cancelled — see the type docs), or call its `adjustOrigin(dx, dy)` to
110
+ * reposition the live gesture mid-drag.
111
+ * @example
112
+ * ```ts
113
+ * const cleanup = attachDrag(el, { axis: 'x', momentum: true })
114
+ * // …when a layout swap shifts the slot under the cursor mid-drag:
115
+ * cleanup.adjustOrigin(10, -5)
116
+ * // on teardown:
117
+ * cleanup()
118
+ * ```
88
119
  */
89
- export declare const attachDrag: (el: HTMLElement, opts: AttachDragOptions) => (() => void);
120
+ export declare const attachDrag: (el: HTMLElement, opts: AttachDragOptions) => AttachDragCleanup;