@humanspeak/svelte-motion 0.5.1 → 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.
@@ -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
  };
@@ -14,8 +14,19 @@ import { type AnimationOptions } from 'motion';
14
14
  *
15
15
  * Pass an empty array (or omit) for viewport-relative behaviour.
16
16
  *
17
+ * `baseTransform` is the value the element's `transform` is set to while
18
+ * measuring (default `'none'`, i.e. all transforms removed). The
19
+ * projection system passes the element's mount-time transform here so
20
+ * that a user-authored static `transform` is preserved in the
21
+ * measurement while only the motion-applied portion (written after
22
+ * mount) is removed — mirroring framer-motion's `removeBoxTransforms`,
23
+ * which only subtracts motion-tracked `latestValues` and leaves
24
+ * user-authored transforms intact. Existing FLIP callers omit it and
25
+ * get the original strip-everything behaviour.
26
+ *
17
27
  * @param el Element to measure.
18
28
  * @param scrollContainers Optional ancestor chain with `layoutScroll` enabled.
29
+ * @param baseTransform Transform string applied during measurement. Defaults to `'none'`.
19
30
  * @returns DOMRect snapshot of the element.
20
31
  *
21
32
  * @example
@@ -30,7 +41,20 @@ import { type AnimationOptions } from 'motion';
30
41
  * const rect = measureRect(node, [innerScroll, outerScroll])
31
42
  * ```
32
43
  */
33
- export declare const measureRect: (el: HTMLElement, scrollContainers?: HTMLElement[]) => DOMRect;
44
+ export declare const measureRect: (el: HTMLElement, scrollContainers?: HTMLElement[], baseTransform?: string) => DOMRect;
45
+ /**
46
+ * Minimal rectangle shape `computeFlipTransforms` reads. A `DOMRect`
47
+ * satisfies it structurally, and so does a projection `Box` converted to
48
+ * `{ left, top, width, height }`. Declared here (rather than importing
49
+ * the projection `Box`) so `layout.ts` stays free of a circular
50
+ * dependency on `projection.ts`, which imports `measureRect` from here.
51
+ */
52
+ export interface RectLike {
53
+ left: number;
54
+ top: number;
55
+ width: number;
56
+ height: number;
57
+ }
34
58
  /**
35
59
  * Compute FLIP transform deltas between two rects.
36
60
  *
@@ -39,7 +63,7 @@ export declare const measureRect: (el: HTMLElement, scrollContainers?: HTMLEleme
39
63
  * @param mode `true` for translate+scale, `'position'` for translate only.
40
64
  * @return Deltas and flags indicating which transforms to apply.
41
65
  */
42
- export declare const computeFlipTransforms: (prev: DOMRect, next: DOMRect, mode: boolean | "position") => {
66
+ export declare const computeFlipTransforms: (prev: RectLike, next: RectLike, mode: boolean | "position") => {
43
67
  dx: number;
44
68
  dy: number;
45
69
  sx: number;
@@ -14,8 +14,19 @@ import { animate } from 'motion';
14
14
  *
15
15
  * Pass an empty array (or omit) for viewport-relative behaviour.
16
16
  *
17
+ * `baseTransform` is the value the element's `transform` is set to while
18
+ * measuring (default `'none'`, i.e. all transforms removed). The
19
+ * projection system passes the element's mount-time transform here so
20
+ * that a user-authored static `transform` is preserved in the
21
+ * measurement while only the motion-applied portion (written after
22
+ * mount) is removed — mirroring framer-motion's `removeBoxTransforms`,
23
+ * which only subtracts motion-tracked `latestValues` and leaves
24
+ * user-authored transforms intact. Existing FLIP callers omit it and
25
+ * get the original strip-everything behaviour.
26
+ *
17
27
  * @param el Element to measure.
18
28
  * @param scrollContainers Optional ancestor chain with `layoutScroll` enabled.
29
+ * @param baseTransform Transform string applied during measurement. Defaults to `'none'`.
19
30
  * @returns DOMRect snapshot of the element.
20
31
  *
21
32
  * @example
@@ -30,10 +41,10 @@ import { animate } from 'motion';
30
41
  * const rect = measureRect(node, [innerScroll, outerScroll])
31
42
  * ```
32
43
  */
33
- export const measureRect = (el, scrollContainers) => {
44
+ export const measureRect = (el, scrollContainers, baseTransform = 'none') => {
34
45
  const prev = el.style.transform;
35
46
  try {
36
- el.style.transform = 'none';
47
+ el.style.transform = baseTransform;
37
48
  const rect = el.getBoundingClientRect();
38
49
  if (!scrollContainers || scrollContainers.length === 0)
39
50
  return rect;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pan gesture session.
3
+ *
4
+ * Direct port of framer-motion's `PanSession`
5
+ * (`packages/framer-motion/src/gestures/pan/PanSession.ts`) and `PanGesture`
6
+ * (`packages/framer-motion/src/gestures/pan/index.ts`). Pan is the
7
+ * primitive that powers swipe-to-dismiss drawers, swipe-to-delete rows,
8
+ * carousels, and any gesture that tracks pointer offset/velocity without
9
+ * the constraint/momentum/snap-to-origin baggage of `drag`.
10
+ *
11
+ * Critical design notes (mirrors upstream):
12
+ *
13
+ * - `pointermove`, `pointerup`, `pointercancel` subscribe on the
14
+ * `contextWindow` (defaults to `window`), NOT the source element. This
15
+ * keeps the gesture alive even when the pointer leaves the element's
16
+ * bounds during a fast swipe — the original element is only used for
17
+ * the initial `pointerdown` and for scroll-compensation tracking.
18
+ *
19
+ * - `distanceThreshold` (default `3`px) gates the `onStart` callback so
20
+ * a steady press without movement doesn't fire a pan. `onSessionStart`
21
+ * fires immediately on pointerdown for setup work.
22
+ *
23
+ * - Per-frame throttling via `frame.update(updatePoint, true)` so a flood
24
+ * of pointermove events doesn't run handlers more than once per render
25
+ * frame. On top of that, individual handlers are routed onto motion-dom's
26
+ * step lanes (see `wrapUpdate` / `wrapPostRender` above):
27
+ * `onSessionStart` / `onStart` / `onMove` land on `update`,
28
+ * `onEnd` / `onSessionEnd` on `postRender`. Matches upstream's
29
+ * `asyncHandler` + `frame.postRender` split byte-for-byte.
30
+ *
31
+ * - `getPanInfo` returns `{ point, delta, offset, velocity }` — identical
32
+ * shape to motion-dom's `DragInfo` / framer-motion's `PanInfo`.
33
+ *
34
+ * - Velocity uses a 100ms history window with the "skip the pointer-down
35
+ * origin if it's too stale" tweak upstream added for hold-then-flick
36
+ * gestures.
37
+ */
38
+ import type { DragInfo } from '../types';
39
+ export interface PanHandlers {
40
+ /** Fires on `pointerdown` regardless of whether movement follows. */
41
+ onSessionStart?: (event: PointerEvent, info: DragInfo) => void;
42
+ /** Fires the first time pointer offset crosses `distanceThreshold`. */
43
+ onStart?: (event: PointerEvent, info: DragInfo) => void;
44
+ /** Fires on every per-frame-throttled pointermove past threshold. */
45
+ onMove?: (event: PointerEvent, info: DragInfo) => void;
46
+ /** Fires on `pointerup` / `pointercancel` if `onStart` ever fired. */
47
+ onEnd?: (event: PointerEvent, info: DragInfo) => void;
48
+ /** Fires on `pointerup` / `pointercancel` always (paired with `onSessionStart`). */
49
+ onSessionEnd?: (event: PointerEvent, info: DragInfo) => void;
50
+ }
51
+ export interface AttachPanOptions {
52
+ /**
53
+ * Movement distance (in pixels) required before `onStart`/`onMove`
54
+ * fire. Default `3` — same as framer-motion. A steady press with
55
+ * sub-threshold drift is reported via `onSessionStart` / `onSessionEnd`
56
+ * only.
57
+ */
58
+ distanceThreshold?: number;
59
+ /**
60
+ * Window to attach the move/up/cancel listeners to. Defaults to the
61
+ * source element's owner window. Override for iframe / shadow-root
62
+ * scenarios.
63
+ */
64
+ contextWindow?: Window | null;
65
+ }
66
+ /**
67
+ * Cleanup function returned by `attachPan`. Carries an `update` method
68
+ * that hot-swaps the live handler set without tearing down the active
69
+ * `PanSession` — call this when a consumer's `onPan` reference changes
70
+ * mid-gesture (the canonical Svelte 5 pattern of inline arrow handlers
71
+ * passes a fresh closure every render). Without this, the host
72
+ * `$effect` would have to teardown + re-attach and the user's in-flight
73
+ * pan would silently die.
74
+ */
75
+ export type AttachPanCleanup = (() => void) & {
76
+ update: (next: PanHandlers) => void;
77
+ };
78
+ /**
79
+ * Attach a pan gesture session to `el`. Returns a cleanup function that
80
+ * tears down the pointerdown listener and ends any in-flight session,
81
+ * with a `.update(next)` method for hot-swapping handlers mid-gesture.
82
+ *
83
+ * Internally a fresh `PanSession` spawns on each pointerdown — the
84
+ * outer attachment just keeps the pointerdown listener alive across the
85
+ * element's lifetime.
86
+ *
87
+ * SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
88
+ * `$effect` consumer never fires on the server anyway, but defending the
89
+ * boundary lets the module load cleanly in node-only test runners.
90
+ *
91
+ * Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
92
+ * session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
93
+ * BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
94
+ * (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
95
+ * revert logic inside the user-supplied `onEnd` and rely on it firing
96
+ * exactly once per gesture — whether the user released or the host
97
+ * forced teardown.
98
+ *
99
+ * @param el Target element to bind `pointerdown` on. Move/up/cancel
100
+ * events are listened for on the element's owning window so a fast
101
+ * swipe past the element's bounds keeps the gesture alive.
102
+ * @param handlers Pan lifecycle handlers. Any subset of
103
+ * `onSessionStart` (fires on pointerdown), `onStart` (fires the first
104
+ * time the cumulative offset crosses `distanceThreshold`), `onMove`
105
+ * (per-frame-throttled on every pointermove past threshold), `onEnd`
106
+ * (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
107
+ * (fires on every pointerup/cancel where a pointermove occurred).
108
+ * @param options Per-session config. `distanceThreshold` (default 3px)
109
+ * gates the start callback; `contextWindow` overrides the owning
110
+ * window (use for shadow-root / iframe scenarios).
111
+ * @returns A cleanup function with an attached `.update(next)` method.
112
+ * Calling the cleanup ends the session + removes the pointerdown
113
+ * listener. Calling `.update(next)` swaps handlers in place on the
114
+ * live session without rebuilding it — the canonical Svelte pattern
115
+ * for inline arrow handlers that change identity each render.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const cleanup = attachPan(node, {
120
+ * onStart: (_event, info) => console.log('start', info.offset),
121
+ * onMove: (_event, info) => x.set(info.offset.x),
122
+ * onEnd: (_event, info) => {
123
+ * if (Math.abs(info.velocity.x) > 600) commit()
124
+ * else animate(x, 0, { type: 'spring' })
125
+ * }
126
+ * })
127
+ *
128
+ * // Later, swap handlers without ending the live gesture:
129
+ * cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
130
+ *
131
+ * // On unmount:
132
+ * cleanup()
133
+ * ```
134
+ */
135
+ export declare const attachPan: (el: HTMLElement, handlers: PanHandlers, options?: AttachPanOptions) => AttachPanCleanup;