@humanspeak/svelte-motion 0.5.2 → 0.5.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.
@@ -0,0 +1,22 @@
1
+ import type { ProjectionNode } from '../utils/projection';
2
+ /**
3
+ * Publish a `ProjectionNode` as the projection parent for descendant
4
+ * motion components.
5
+ *
6
+ * @param node The current component's ProjectionNode, or `null` to
7
+ * explicitly clear (rarely needed — Svelte context auto-clears on
8
+ * component unmount).
9
+ */
10
+ export declare const setProjectionParent: (node: ProjectionNode | null) => void;
11
+ /**
12
+ * Read the ancestor `ProjectionNode` published by the nearest motion
13
+ * ancestor. Returns `null` when this component is at the root of the
14
+ * projection tree (or when no motion ancestor exists).
15
+ *
16
+ * Must be called BEFORE the current component publishes its own node
17
+ * via `setProjectionParent` — see the order-of-operations note in this
18
+ * file's header.
19
+ *
20
+ * @returns The parent ProjectionNode, or `null`.
21
+ */
22
+ export declare const getProjectionParent: () => ProjectionNode | null;
@@ -0,0 +1,56 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ /**
3
+ * Svelte context plumbing for the projection tree.
4
+ *
5
+ * Every `motion.*` element creates a `ProjectionNode` at setup time and
6
+ * publishes it via `setProjectionParent(node)` so descendant motion
7
+ * elements can pick it up via `getProjectionParent()` and wire themselves
8
+ * as `node.parent` + register in `node.parent.children`. The tree shape
9
+ * mirrors the Svelte component tree exactly because Svelte's
10
+ * `setContext` propagates down through descendants in component-init
11
+ * order.
12
+ *
13
+ * This is the closest analog we have to framer-motion's depth-sorted
14
+ * FlatTree (`projection/node/create-projection-node.ts`), but without
15
+ * the global `root` registry — parent/child pointers are sufficient
16
+ * for the workflows this PR enables (drag↔swap origin compensation,
17
+ * ancestor-zeroing measure). A global root would only be needed once we
18
+ * port shared-element `layoutId` morphing onto projection nodes; the
19
+ * current `layoutId.ts` registry continues to handle that case
20
+ * independently.
21
+ *
22
+ * Order of operations inside a single `motion.*` component (CRITICAL):
23
+ * 1. Read parent via `getProjectionParent()` BEFORE creating own node.
24
+ * 2. Construct own node, set `node.parent = parent`.
25
+ * 3. Publish own node via `setProjectionParent(node)`.
26
+ * If steps 1 and 3 are reversed, the component sees ITS OWN node as
27
+ * the parent (because `setContext` shadows from the call site down)
28
+ * and the tree collapses to a chain of self-references. Same trap
29
+ * `layoutScroll.context.ts` documents — don't repeat it.
30
+ */
31
+ const PROJECTION_CONTEXT_KEY = Symbol('svelte-motion:projection-parent');
32
+ /**
33
+ * Publish a `ProjectionNode` as the projection parent for descendant
34
+ * motion components.
35
+ *
36
+ * @param node The current component's ProjectionNode, or `null` to
37
+ * explicitly clear (rarely needed — Svelte context auto-clears on
38
+ * component unmount).
39
+ */
40
+ export const setProjectionParent = (node) => {
41
+ setContext(PROJECTION_CONTEXT_KEY, node);
42
+ };
43
+ /**
44
+ * Read the ancestor `ProjectionNode` published by the nearest motion
45
+ * ancestor. Returns `null` when this component is at the root of the
46
+ * projection tree (or when no motion ancestor exists).
47
+ *
48
+ * Must be called BEFORE the current component publishes its own node
49
+ * via `setProjectionParent` — see the order-of-operations note in this
50
+ * file's header.
51
+ *
52
+ * @returns The parent ProjectionNode, or `null`.
53
+ */
54
+ export const getProjectionParent = () => {
55
+ return getContext(PROJECTION_CONTEXT_KEY) ?? null;
56
+ };
@@ -39,10 +39,11 @@
39
39
  computeFlipTransforms,
40
40
  runFlipAnimation,
41
41
  setCompositorHints,
42
- observeLayoutChanges
42
+ observeLayoutChanges,
43
+ type RectLike
43
44
  } from '../utils/layout'
44
45
  import type { SvelteHTMLElements } from 'svelte/elements'
45
- import { mergeInlineStyles } from '../utils/style'
46
+ import { mergeInlineStyles, extractTransform } from '../utils/style'
46
47
  import { isNativelyFocusable } from '../utils/a11y'
47
48
  import {
48
49
  getAnimatePresenceContext,
@@ -53,7 +54,15 @@
53
54
  import { getInitialKeyframes } from '../utils/initial'
54
55
  import { attachDrag } from '../utils/drag'
55
56
  import { attachPan, type AttachPanCleanup } from '../utils/pan'
56
- import { resolveInitial, resolveAnimate, resolveExit, resolveWhile } from '../utils/variants'
57
+ import { ProjectionNode } from '../utils/projection'
58
+ import { getProjectionParent, setProjectionParent } from '../components/projection.context'
59
+ import {
60
+ resolveInitial,
61
+ resolveAnimate,
62
+ resolveExit,
63
+ resolveWhile,
64
+ resolveRestingValues
65
+ } from '../utils/variants'
57
66
  import {
58
67
  setVariantContext,
59
68
  getVariantContext,
@@ -134,10 +143,17 @@
134
143
  layout: layoutProp,
135
144
  layoutId: layoutIdProp,
136
145
  layoutScroll: layoutScrollProp,
146
+ onProjectionUpdate: onProjectionUpdateProp,
137
147
  ref: element = $bindable(null),
138
148
  ...rest
139
149
  }: Props = $props()
140
150
  let isLoaded = $state<'mounting' | 'initial' | 'ready' | 'animated'>('mounting')
151
+ // True once the enter/animate animation has COMPLETED. Until then the
152
+ // WAAPI animation owns the transform; flipping the inline baseline to
153
+ // the target mid-run causes a one-frame snap (the target shows through
154
+ // for the frame the inline changes). We therefore only apply the target
155
+ // as the inline style once settled — see the style derivation. (#377)
156
+ let enterAnimationSettled = $state(false)
141
157
  let dataPath = $state<number>(-1)
142
158
  const motionConfig = $derived(getMotionConfig())
143
159
  const reducedMotionState = useReducedMotionConfig()
@@ -191,6 +207,46 @@
191
207
  return refs.filter((el): el is HTMLElement => Boolean(el))
192
208
  }
193
209
 
210
+ // Projection tree wiring (#379). Capture the parent node BEFORE
211
+ // publishing our own — same shadowing trap as layoutScroll above.
212
+ // The node measures through `resolveLayoutScrollAncestors` so its
213
+ // boxes share the FLIP coordinate space, and zeros ancestor
214
+ // transforms during measure so nested layout-animated parents don't
215
+ // corrupt a child's delta.
216
+ // The user-authored transform, sourced from the `style` prop rather
217
+ // than the live inline transform — the latter already carries any
218
+ // transform-type `initial`/`animate` keyframe by the time the node
219
+ // measures, which would be mistaken for the user's base.
220
+ const userBaseTransform = $derived(extractTransform(styleProp))
221
+
222
+ const projectionParent = getProjectionParent()
223
+ const projection = new ProjectionNode({
224
+ parent: projectionParent,
225
+ getScrollContainers: resolveLayoutScrollAncestors,
226
+ getBaseTransform: () => userBaseTransform
227
+ })
228
+ setProjectionParent(projection)
229
+
230
+ // Convert a projection `Box` (ancestor chain reset to base, self
231
+ // transform stripped, scroll containers compensated) to the
232
+ // `RectLike` shape `computeFlipTransforms` consumes.
233
+ const boxToRectLike = (box: {
234
+ x: { min: number; max: number }
235
+ y: { min: number; max: number }
236
+ }): RectLike => ({
237
+ left: box.x.min,
238
+ top: box.y.min,
239
+ width: box.x.max - box.x.min,
240
+ height: box.y.max - box.y.min
241
+ })
242
+
243
+ // Ancestor-transform-invariant layout measurement for seeding the
244
+ // FLIP effect's first rect.
245
+ const measureLayoutRect = (): RectLike | null => {
246
+ const box = projection.measure()
247
+ return box ? boxToRectLike(box) : null
248
+ }
249
+
194
250
  // Get current presence depth (0 = direct child of AnimatePresence, undefined = not in AnimatePresence)
195
251
  const presenceDepth = getPresenceDepth()
196
252
 
@@ -478,6 +534,17 @@
478
534
  )
479
535
  )
480
536
 
537
+ // Reduced-motion-filtered animate values used as the inline-style
538
+ // baseline so an animated value (transforms included) persists after
539
+ // the WAAPI animation completes (#377). Filtered exactly like
540
+ // `initialKeyframes` so transforms are stripped under reduced motion.
541
+ const animateKeyframes = $derived(
542
+ filterReducedMotionKeyframes(
543
+ resolvedAnimate as Record<string, unknown> | undefined,
544
+ reducedMotion
545
+ )
546
+ )
547
+
481
548
  // Derived attributes to keep both branches in sync (focusability, data flags, style, class)
482
549
  const derivedAttrs = $derived<Record<string, unknown>>({
483
550
  ...(rest as Record<string, unknown>),
@@ -516,20 +583,33 @@
516
583
  initialKeyframes && 'pathLength' in initialKeyframes && isLoaded === 'mounting'
517
584
  ? `${styleProp || ''};visibility:hidden`
518
585
  : 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
586
+ // The "from" slot: apply initialKeyframes as inline styles during
587
+ // the mounting/initial phases (before the WAAPI animation locks
588
+ // its from-value and we promote to 'ready' see the lifecycle
589
+ // around the enter rAF). mergeInlineStyles prefers this slot when
590
+ // non-empty, so it wins over the animate slot below in these phases.
523
591
  isLoaded === 'mounting' || isLoaded === 'initial'
524
592
  ? (initialKeyframes as unknown as Record<string, unknown>)
525
593
  : 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>)
594
+ // The "target" slot. Only AFTER the enter animation completes does
595
+ // the target become the inline baseline, so the element holds it
596
+ // once WAAPI surrenders the property (default fill:'none' would
597
+ // otherwise leave transform:none). It must NOT be applied during
598
+ // the run: flipping the inline value to the target mid-animation
599
+ // shows the target for the one frame the inline changes (a visible
600
+ // snap), since WAAPI's composite doesn't override that exact frame.
601
+ // While the animation runs we keep the original behavior — initial
602
+ // keyframes own the inline (via the slot above), or, with no
603
+ // initial, the animate values seed the inline as the from. Resting
604
+ // values collapse keyframe arrays to their last element
605
+ // (animate={{x:[0,100,50]}} rests at 50). (#377)
606
+ enterAnimationSettled
607
+ ? (resolveRestingValues(
608
+ animateKeyframes as DOMKeyframesDefinition | undefined
609
+ ) as unknown as Record<string, unknown>)
610
+ : isNotEmpty(initialKeyframes)
611
+ ? undefined
612
+ : (animateKeyframes as unknown as Record<string, unknown>)
533
613
  ),
534
614
  class: classProp
535
615
  })
@@ -807,12 +887,20 @@
807
887
  transitionAnimate
808
888
  })
809
889
 
890
+ // A fresh run owns the transform again until it completes.
891
+ enterAnimationSettled = false
810
892
  animateWithLifecycle(
811
893
  element,
812
894
  payload as unknown as DOMKeyframesDefinition,
813
895
  transitionAnimate as unknown as AnimationOptions,
814
896
  (def) => onAnimationStartProp?.(def as unknown as DOMKeyframesDefinition | undefined),
815
- (def) => onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
897
+ (def) => {
898
+ // Now the target is the resting state — promote it to the
899
+ // inline baseline so it persists after WAAPI surrenders the
900
+ // property (default fill:'none'). (#377)
901
+ enterAnimationSettled = true
902
+ onAnimationCompleteProp?.(def as unknown as DOMKeyframesDefinition | undefined)
903
+ }
816
904
  )
817
905
  }
818
906
 
@@ -930,26 +1018,65 @@
930
1018
  : undefined
931
1019
  )
932
1020
 
1021
+ // Projection node lifecycle + the `onProjectionUpdate` listener.
1022
+ // Mount once the element binds; seed the baseline layout; unmount on
1023
+ // cleanup. Depends ONLY on `element` — the `onProjectionUpdate`
1024
+ // subscription lives in its own effect below so a change to the
1025
+ // callback's identity re-subscribes WITHOUT tearing the node down
1026
+ // (an unmount would clear latestLayout/children and re-seed the
1027
+ // first commit instead of emitting a real delta).
1028
+ $effect(() => {
1029
+ if (!element) return
1030
+ projection.mount(element)
1031
+ projection.measure() // seed latestLayout so the first commit can diff
1032
+ return () => {
1033
+ projection.unmount()
1034
+ }
1035
+ })
1036
+
1037
+ // Subscribe the consumer's `onProjectionUpdate` callback. Separate
1038
+ // from the mount effect so re-subscribing on a callback-identity
1039
+ // change never unmounts the node.
1040
+ $effect(() => {
1041
+ if (!(element && onProjectionUpdateProp)) return
1042
+ const off = projection.addEventListener('didUpdate', (data) => onProjectionUpdateProp(data))
1043
+ return () => {
1044
+ off()
1045
+ }
1046
+ })
1047
+
933
1048
  // Minimal layout animation using FLIP when `layout` is enabled.
934
1049
  // When layout === 'position' we only translate.
935
1050
  // When layout === true we also scale to smoothly interpolate size changes.
936
- let lastRect: DOMRect | null = null
1051
+ let lastRect: RectLike | null = null
937
1052
  $effect(() => {
938
1053
  if (!(element && layoutProp && isLoaded === 'ready')) return
939
1054
 
940
- // Initialize last rect on first ready frame
941
- lastRect = measureRect(element!, resolveLayoutScrollAncestors())
1055
+ // Initialize last rect on first ready frame. We measure through the
1056
+ // projection node rather than `measureRect` directly so the rect is
1057
+ // ancestor-transform-invariant: the node resets the whole ancestor
1058
+ // chain to its mount-time base while reading, which strips any
1059
+ // motion-applied (FLIP/drag) transform up the tree. Without this a
1060
+ // child re-measures and FLIPs whenever an ancestor's transform
1061
+ // animates, even though the child's own layout never moved. (#379)
1062
+ lastRect = measureLayoutRect()
942
1063
  // Hint compositor for smoother FLIP transforms
943
1064
  setCompositorHints(element!, true)
944
1065
 
945
1066
  let rafId: number | null = null
946
1067
  const runFlip = () => {
947
- const scrollContainers = resolveLayoutScrollAncestors()
1068
+ // commitLayoutChange does the ancestor-stripped measure, fans
1069
+ // the delta out to `onProjectionUpdate` listeners, AND returns
1070
+ // the freshly-measured box — so the FLIP reuses that single
1071
+ // measurement as `next` instead of walking + transform-resetting
1072
+ // the ancestor chain a second time per frame. (#379)
1073
+ const nextBox = projection.commitLayoutChange()
1074
+ if (!nextBox) return
1075
+ const next = boxToRectLike(nextBox)
948
1076
  if (!lastRect) {
949
- lastRect = measureRect(element!, scrollContainers)
1077
+ lastRect = next
950
1078
  return
951
1079
  }
952
- const next = measureRect(element!, scrollContainers)
953
1080
  const transforms = computeFlipTransforms(lastRect, next, layoutProp ?? false)
954
1081
  runFlipAnimation(element!, transforms, (mergedTransition ?? {}) as AnimationOptions)
955
1082
  lastRect = next
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { motion } from './motion';
6
6
  export { animate, delay, hover, inView, press, resize, scroll, stagger, transform } from 'motion';
7
7
  export { anticipate, backIn, backInOut, backOut, circIn, circInOut, circOut, cubicBezier, easeIn, easeInOut, easeOut } from 'motion';
8
8
  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';
9
+ 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
10
  export { useAnimate } from './utils/animate.svelte';
11
11
  export type { AnimationScope } from './utils/animate.svelte';
12
12
  export { useAnimationFrame } from './utils/animationFrame';
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;
@@ -137,23 +137,36 @@ const computeReleaseVelocity = (history, nowMs) => {
137
137
  /**
138
138
  * Attach a drag gesture to an element.
139
139
  *
140
- * Captures the pointer, updates x/y transforms with axis and optional direction lock,
141
- * applies elastic overflow against constraints, emits lifecycle callbacks with DragInfo,
142
- * and runs a momentum animation on release when enabled.
143
- */
144
- /**
145
- * Attach a drag gesture to an HTMLElement.
140
+ * Captures the pointer and updates x/y transforms with axis and optional
141
+ * direction lock, applies elastic overflow against constraints, emits
142
+ * lifecycle callbacks with `DragInfo`, and runs a momentum animation on
143
+ * release when enabled.
146
144
  *
147
145
  * Lifecycle:
148
146
  * - pointerdown → capture pointer, snapshot origin, start velocity history, enter whileDrag
149
147
  * - pointermove → compute deltas, direction lock, apply constraints + elastic, write x/y
150
148
  * - pointerup/cancel → either run momentum decay to a target or settle/clamp instantly
151
149
  *
152
- * Important invariants:
153
- * - `applied` tracks the currently applied transform (x/y). Always keep it in sync when
154
- * writing transforms or finishing animations so a second drag starts from the right origin.
155
- * - If you see a "jump" at the start of a second drag, it usually means `applied` wasn't
156
- * updated after a non-0-duration settle animation.
150
+ * Invariant: `applied` tracks the currently applied x/y transform — it
151
+ * must stay in sync when writing transforms or finishing animations, or a
152
+ * second drag "jumps" from a stale origin (commonly a missed update after
153
+ * a non-zero-duration settle animation).
154
+ *
155
+ * @param el The element to make draggable.
156
+ * @param opts Drag options — `axis`, `constraints`, `elastic`,
157
+ * `momentum`, `whileDrag`, and the `onDrag*` lifecycle callbacks.
158
+ * @returns A callable cleanup handle ({@link AttachDragCleanup}): call it
159
+ * to detach the gesture's listeners (in-flight momentum is not
160
+ * cancelled — see the type docs), or call its `adjustOrigin(dx, dy)` to
161
+ * reposition the live gesture mid-drag.
162
+ * @example
163
+ * ```ts
164
+ * const cleanup = attachDrag(el, { axis: 'x', momentum: true })
165
+ * // …when a layout swap shifts the slot under the cursor mid-drag:
166
+ * cleanup.adjustOrigin(10, -5)
167
+ * // on teardown:
168
+ * cleanup()
169
+ * ```
157
170
  */
158
171
  export const attachDrag = (el, opts) => {
159
172
  const EL_ID = el.getAttribute('data-testid') || el.id || el.tagName;
@@ -249,6 +262,83 @@ export const attachDrag = (el, opts) => {
249
262
  }
250
263
  }
251
264
  };
265
+ /**
266
+ * Write absolute element-space translation by mutating
267
+ * `el.style.transform` DIRECTLY — no `animate()`, no epsilon skip,
268
+ * no Playwright retry. The translate channel is rewritten while any
269
+ * non-translate transform the element already carries (e.g. a
270
+ * `whileDrag` scale) is preserved as a suffix.
271
+ *
272
+ * Why this exists separately from `setXY`: routing through
273
+ * `animate(el, _, { duration: 0 })` defers the write to Motion's
274
+ * scheduler, costing ~1 frame before the new position paints. For
275
+ * the projection-driven origin compensation (where a layout swap
276
+ * must keep the dragged element under the cursor in the SAME frame
277
+ * the swap commits), that frame of lag manifests as a visible
278
+ * wobble. This path lands synchronously. See #379 / the
279
+ * `adjustOrigin` hook below.
280
+ */
281
+ const setXYImmediate = (x, y) => {
282
+ const parts = [];
283
+ if (axis === true || axis === 'x')
284
+ parts.push(`translateX(${x}px)`);
285
+ if (axis === true || axis === 'y')
286
+ parts.push(`translateY(${y}px)`);
287
+ // Strip existing translate channels, keep the rest (scale/rotate/etc.).
288
+ const nonTranslate = el.style.transform.replace(/translate[XYZ3d]*\([^)]*\)/g, '').trim();
289
+ el.style.transform = [...parts, nonTranslate].filter(Boolean).join(' ');
290
+ if (axis === true || axis === 'x')
291
+ applied.x = x;
292
+ if (axis === true || axis === 'y')
293
+ applied.y = y;
294
+ };
295
+ /**
296
+ * Adjust the drag origin + visual offset by a layout-shift delta,
297
+ * mid-gesture, keeping the dragged element pinned under the cursor
298
+ * while its underlying layout slot moves.
299
+ *
300
+ * Direct port of framer-motion's projection `didUpdate` handler in
301
+ * `VisualElementDragControls.ts:742-758`:
302
+ *
303
+ * ```ts
304
+ * this.originPoint[axis] += delta[axis].translate
305
+ * motionValue.set(motionValue.get() + delta[axis].translate)
306
+ * ```
307
+ *
308
+ * We do the same two-write dance: shift `origin` (the gesture's
309
+ * reference zero) AND the applied visual transform by the same
310
+ * delta, so `lastPoint - startPoint + origin` continues to resolve
311
+ * to the correct on-screen position after the layout slot moved.
312
+ * Uses `setXYImmediate` so the compensation is visible the same
313
+ * frame as the layout change.
314
+ *
315
+ * Not wired to any projection node in this PR — exposed on the
316
+ * `attachDrag` return handle for the Reorder PR (#310) to call from
317
+ * its `ProjectionNode.didUpdate` listener.
318
+ *
319
+ * @param dx Layout delta on the x axis (px).
320
+ * @param dy Layout delta on the y axis (px).
321
+ */
322
+ const adjustOrigin = (dx, dy) => {
323
+ if (!dragging)
324
+ return;
325
+ // Compensate the origin on BOTH axes unconditionally — upstream's
326
+ // didUpdate handler applies the delta per-axis via `eachAxis`
327
+ // regardless of the drag axis or direction lock, because a layout
328
+ // slot can shift on either axis.
329
+ origin.x += dx;
330
+ origin.y += dy;
331
+ // The VISUAL write is `setXYImmediate`, which only writes the axis
332
+ // this drag manages (`opts.axis`). For the dragged axis that pins
333
+ // the element same-frame; the cross-axis case (e.g. drag="x" + a
334
+ // y-shift) only updates `origin`, not the transform. Fully
335
+ // rendering cross-axis compensation needs to route through the
336
+ // Motion value the move path uses (a direct write here would be
337
+ // wiped by the next `setXY`), so it's finalized when this hook is
338
+ // wired in #310. The common Reorder case (drag axis === the shift
339
+ // axis) is fully compensated today.
340
+ setXYImmediate(applied.x + dx, applied.y + dy);
341
+ };
252
342
  const startWhileDrag = () => {
253
343
  if (!opts.whileDrag)
254
344
  return;
@@ -758,7 +848,7 @@ export const attachDrag = (el, opts) => {
758
848
  }
759
849
  el.addEventListener('pointerdown', onPointerDown);
760
850
  pwLog('[drag] pointerdown listener attached', { el: EL_ID });
761
- return () => {
851
+ const teardown = () => {
762
852
  pwLog('[drag] detach', { el: EL_ID });
763
853
  el.removeEventListener('pointerdown', onPointerDown);
764
854
  el.removeEventListener('pointermove', onPointerMove);
@@ -768,4 +858,5 @@ export const attachDrag = (el, opts) => {
768
858
  window.removeEventListener('pointerup', onPointerUp);
769
859
  window.removeEventListener('pointercancel', onPointerCancel);
770
860
  };
861
+ return Object.assign(teardown, { adjustOrigin });
771
862
  };