solidjs-motion 0.1.0

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2627 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/default-values.d.ts +6 -0
  8. package/dist/src/index.d.ts +16 -0
  9. package/dist/src/motion-config.d.ts +14 -0
  10. package/dist/src/motion-proxy.d.ts +103 -0
  11. package/dist/src/presence-context.d.ts +4 -0
  12. package/dist/src/presence.d.ts +95 -0
  13. package/dist/src/primitives/createDrag.d.ts +16 -0
  14. package/dist/src/primitives/createDragControls.d.ts +30 -0
  15. package/dist/src/primitives/createGestures.d.ts +8 -0
  16. package/dist/src/primitives/createInView.d.ts +51 -0
  17. package/dist/src/primitives/createMotion.d.ts +82 -0
  18. package/dist/src/primitives/createPan.d.ts +83 -0
  19. package/dist/src/primitives/createScroll.d.ts +40 -0
  20. package/dist/src/primitives/gesture-state.d.ts +108 -0
  21. package/dist/src/primitives/motion-value.d.ts +111 -0
  22. package/dist/src/primitives/value-registry.d.ts +34 -0
  23. package/dist/src/reduced-motion.d.ts +29 -0
  24. package/dist/src/style.d.ts +79 -0
  25. package/dist/src/types.d.ts +374 -0
  26. package/dist/src/use-motion.d.ts +35 -0
  27. package/dist/src/variants.d.ts +64 -0
  28. package/package.json +78 -0
  29. package/src/default-values.ts +52 -0
  30. package/src/index.ts +60 -0
  31. package/src/motion-config.tsx +37 -0
  32. package/src/motion-proxy.tsx +377 -0
  33. package/src/presence-context.ts +32 -0
  34. package/src/presence.tsx +466 -0
  35. package/src/primitives/createDrag.ts +670 -0
  36. package/src/primitives/createDragControls.ts +101 -0
  37. package/src/primitives/createGestures.ts +145 -0
  38. package/src/primitives/createInView.ts +124 -0
  39. package/src/primitives/createMotion.ts +638 -0
  40. package/src/primitives/createPan.ts +338 -0
  41. package/src/primitives/createScroll.ts +101 -0
  42. package/src/primitives/gesture-state.ts +772 -0
  43. package/src/primitives/motion-value.ts +328 -0
  44. package/src/primitives/value-registry.ts +114 -0
  45. package/src/reduced-motion.ts +51 -0
  46. package/src/style.ts +266 -0
  47. package/src/types.ts +538 -0
  48. package/src/use-motion.tsx +412 -0
  49. package/src/variants.ts +134 -0
@@ -0,0 +1,670 @@
1
+ import { type AnimationPlaybackControls, animate } from "motion"
2
+ import { HTMLVisualElement, type MotionValue, time, visualElementStore } from "motion-dom"
3
+ import { createEffect, onCleanup } from "solid-js"
4
+ import type {
5
+ DragConstraints,
6
+ DragControls,
7
+ DragControlsStartOptions,
8
+ MotionOptions,
9
+ PanInfo,
10
+ } from "../types"
11
+ import { DRAG_CONTROLS_REGISTER } from "./createDragControls"
12
+ import { createPan } from "./createPan"
13
+ import type { SetActive } from "./gesture-state"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // createDrag — pointer-driven drag with motion-dom VisualElement composition
17
+ // (Q5/C-lean + Q15).
18
+ //
19
+ // Architecture:
20
+ // - Layered on top of createPan (Q11/D3). Pan handles the pointer session
21
+ // (down → threshold-gated start → moves → up/cancel) and exposes its
22
+ // PanInfo via callbacks. Drag adds the side-effects on top: transform
23
+ // writes, body/touchAction styling, pointer capture, state-machine
24
+ // activation, and (Stage 4) momentum.
25
+ // - Translation flows through motion-dom's VisualElement system: drag writes
26
+ // to the element's `x`/`y` MotionValues, and motion's render pipeline
27
+ // composes the final transform string. This is why `whileDrag: { scale }`
28
+ // works for free — the scale animation writes a sibling MV and the VE
29
+ // composes all transform-class values into one output (Q5/C-lean).
30
+ //
31
+ // State machine integration:
32
+ // - On pan-start (only if drag is enabled), `setActive("whileDrag", true)`
33
+ // fires. The state machine's `winners` memo then claims whileDrag's
34
+ // target keys, EXCEPT x and y which are filtered (drag owns them).
35
+ // - On pan-end, `setActive("whileDrag", false)`. Momentum (Stage 4) runs
36
+ // AFTER whileDrag deactivates — visual gesture state ends with the
37
+ // pointerup, not when the animation settles.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Get or create a motion-dom VisualElement for an HTMLElement. Required
42
+ * because we write to the VE's `x`/`y` MotionValues during drag, and motion
43
+ * only auto-creates the VE inside `animate(el, target)` calls — if a user
44
+ * configures drag without any animate target, no VE would exist.
45
+ *
46
+ * Mirrors framer-motion's `createDOMVisualElement` (which isn't reachable
47
+ * from a non-React context — framer-motion's main entry requires React).
48
+ * The options shape and `mount` + `visualElementStore.set` calls match the
49
+ * upstream implementation. SVG support is omitted for v0.1 — drag on SVG
50
+ * is an unusual case.
51
+ */
52
+ function ensureVisualElement(el: HTMLElement): InstanceType<typeof HTMLVisualElement> {
53
+ const existing = visualElementStore.get(el)
54
+ if (existing) return existing as InstanceType<typeof HTMLVisualElement>
55
+
56
+ // motion-dom's VisualElement options type expects more fields than we
57
+ // can sensibly provide without React-flavored MotionProps. The runtime
58
+ // needs only the visualState shape and an empty props bag.
59
+ const options = {
60
+ presenceContext: null,
61
+ props: {},
62
+ visualState: {
63
+ renderState: {
64
+ transform: {},
65
+ transformOrigin: {},
66
+ style: {},
67
+ vars: {},
68
+ attrs: {},
69
+ },
70
+ latestValues: {},
71
+ },
72
+ }
73
+ // biome-ignore lint/suspicious/noExplicitAny: VisualElement options type expects React-flavored MotionProps we can't supply.
74
+ const ve = new HTMLVisualElement(options as any)
75
+ ve.mount(el)
76
+ visualElementStore.set(el, ve)
77
+ return ve as InstanceType<typeof HTMLVisualElement>
78
+ }
79
+
80
+ /**
81
+ * Compute the `touch-action` CSS value for an element being dragged.
82
+ * Disabling touch-action prevents the browser from interpreting the gesture
83
+ * as a scroll. Axis-locked drags leave the unused axis available for scroll
84
+ * (so a horizontally-draggable card can still be scrolled vertically by the
85
+ * surrounding page).
86
+ */
87
+ function touchActionFor(drag: MotionOptions["drag"]): string {
88
+ if (drag === "x") return "pan-y"
89
+ if (drag === "y") return "pan-x"
90
+ return "none"
91
+ }
92
+
93
+ /**
94
+ * Resolved drag bounds expressed as absolute MotionValue bounds (Q5/C-lean —
95
+ * drag writes absolute values, so we clamp in MV-space, not offset-space).
96
+ * `Infinity` / `-Infinity` represent "unbounded in that direction."
97
+ */
98
+ type ResolvedBounds = {
99
+ minX: number
100
+ maxX: number
101
+ minY: number
102
+ maxY: number
103
+ }
104
+
105
+ /**
106
+ * Resolve a {@link DragConstraints} value into absolute MotionValue bounds at
107
+ * drag-start. Two input shapes (Q8):
108
+ *
109
+ * - **Numeric** (`{ top, left, right, bottom }`): bounds are absolute MV
110
+ * values. `left: -100` means x cannot go below -100.
111
+ * - **HTMLElement or `() => HTMLElement | null`**: container that the
112
+ * dragged element must stay inside. Bounds are computed from the
113
+ * container's bounding rect vs the dragged element's current rect, then
114
+ * re-centered around the current MV values.
115
+ *
116
+ * The element form is resolved ONCE at drag-start (current viewport rects).
117
+ * Reactive constraint changes mid-drag aren't honored in v0.1 — they'd
118
+ * require re-measuring on each pointermove. Acceptable corner case.
119
+ *
120
+ * Returns `null` when constraints are unset or the accessor returns null —
121
+ * caller treats as "no clamping, no elastic resistance."
122
+ */
123
+ function resolveConstraints(
124
+ constraints: DragConstraints | undefined,
125
+ el: HTMLElement,
126
+ dragStartX: number,
127
+ dragStartY: number,
128
+ ): ResolvedBounds | null {
129
+ if (!constraints) return null
130
+
131
+ // Discriminate variants with `instanceof HTMLElement` first — this rules
132
+ // out HTMLElement so the `typeof === "function"` check below narrows
133
+ // cleanly to the accessor variant. (TS gets confused if we test the
134
+ // function form first; HTMLElement instances have many methods on their
135
+ // prototype, which can muddle TS's narrowing logic.)
136
+ let container: HTMLElement | null = null
137
+ if (constraints instanceof HTMLElement) {
138
+ container = constraints
139
+ } else if (typeof constraints === "function") {
140
+ container = constraints()
141
+ }
142
+
143
+ if (container) {
144
+ // Element-form: compute offset bounds from rects, then add dragStart
145
+ // so we get absolute MV bounds. Subtle but important: the element's
146
+ // current rect already includes any applied transform (including the
147
+ // current dragStart translation), so we add dragStart back to convert
148
+ // "allowable offset from current position" to "allowable absolute MV
149
+ // value."
150
+ const containerRect = container.getBoundingClientRect()
151
+ const elementRect = el.getBoundingClientRect()
152
+ return {
153
+ minX: dragStartX + (containerRect.left - elementRect.left),
154
+ maxX: dragStartX + (containerRect.right - elementRect.right),
155
+ minY: dragStartY + (containerRect.top - elementRect.top),
156
+ maxY: dragStartY + (containerRect.bottom - elementRect.bottom),
157
+ }
158
+ }
159
+
160
+ // Numeric form. Missing keys → unbounded on that side.
161
+ const numeric = constraints as { top?: number; left?: number; right?: number; bottom?: number }
162
+ return {
163
+ minX: numeric.left ?? -Infinity,
164
+ maxX: numeric.right ?? Infinity,
165
+ minY: numeric.top ?? -Infinity,
166
+ maxY: numeric.bottom ?? Infinity,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Apply elastic resistance past a boundary (Q15c — linear).
172
+ *
173
+ * Within bounds: `value` passes through unchanged. Past a bound by `Δ`: the
174
+ * displayed value is `boundary + elastic × Δ`. With `elastic: 0` the value
175
+ * clamps hard at the boundary; with `elastic: 1` resistance vanishes
176
+ * (motion's default is `0.5`, halving the overflow).
177
+ *
178
+ * The function is symmetric — overflow on either side resists with the
179
+ * same coefficient.
180
+ */
181
+ function applyElastic(value: number, min: number, max: number, elastic: number): number {
182
+ if (value < min) return min + (value - min) * elastic
183
+ if (value > max) return max + (value - max) * elastic
184
+ return value
185
+ }
186
+
187
+ const DEFAULT_ELASTIC = 0.5
188
+
189
+ /**
190
+ * Default `dragTransition` (Q15d — matches motion's inertia preset).
191
+ *
192
+ * `type: "inertia"` decays from the release point using `velocity`, with
193
+ * spring physics at `min`/`max` boundaries. The defaults are the values
194
+ * the user signed off on during Phase 2 grilling; passing a custom
195
+ * `dragTransition` shallow-merges over these.
196
+ */
197
+ const DEFAULT_DRAG_TRANSITION = {
198
+ type: "inertia" as const,
199
+ power: 0.8,
200
+ timeConstant: 750,
201
+ bounceStiffness: 500,
202
+ bounceDamping: 10,
203
+ restDelta: 1,
204
+ restSpeed: 10,
205
+ }
206
+
207
+ /**
208
+ * Bind pointer-driven drag to an element. Layers on top of createPan for the
209
+ * pointer session; adds transform writes, body styles, pointer capture, and
210
+ * state-machine activation.
211
+ *
212
+ * Drag is enabled when `opts.drag` is truthy (`true`, `"x"`, or `"y"`).
213
+ * createDrag always wires the pointer session — the enable check is per-
214
+ * gesture-start, so toggling `opts.drag` on/off doesn't churn listeners.
215
+ *
216
+ * Phase 2 Commit 6 — Stage 2 scope: VE bootstrap, translation, axis lock,
217
+ * body/pointer styles, callbacks, cleanup. Constraints + elastic resistance
218
+ * land in Stage 3; momentum + dragSnapToOrigin in Stage 4.
219
+ */
220
+ export function createDrag(
221
+ el: HTMLElement,
222
+ getOpts: () => MotionOptions,
223
+ setActive: SetActive,
224
+ ): void {
225
+ // Drag-session state. These are reset on each pan-start; null between
226
+ // sessions. Holding references in the closure is fine — the createPan
227
+ // handlers below close over them.
228
+ let xMV: MotionValue<number> | null = null
229
+ let yMV: MotionValue<number> | null = null
230
+ /** Drag-start position of the x/y MotionValues (the values that existed
231
+ * before the user grabbed). offsets from PanInfo accumulate from this. */
232
+ let dragStartX = 0
233
+ let dragStartY = 0
234
+ /** Resolved bounds for this session — computed once at drag-start and
235
+ * reused across all pointermoves to avoid repeat layout reads. `null`
236
+ * means no constraints. */
237
+ let sessionBounds: ResolvedBounds | null = null
238
+ /** Saved before applying drag's `user-select` / `touch-action` overrides
239
+ * so we can restore them exactly on session end. */
240
+ let savedUserSelect = ""
241
+ let savedTouchAction = ""
242
+ let capturedPointerId: number | null = null
243
+ /** In-flight momentum animations (one per axis when active). Stopped on
244
+ * owner disposal AND on a fresh pointerdown (to interrupt a settling
245
+ * momentum if the user grabs again mid-decay). */
246
+ let momentumControls: AnimationPlaybackControls[] = []
247
+
248
+ function isDragEnabled(): boolean {
249
+ return Boolean(getOpts().drag)
250
+ }
251
+
252
+ function restoreBodyAndElementStyles(): void {
253
+ document.body.style.userSelect = savedUserSelect
254
+ el.style.touchAction = savedTouchAction
255
+ }
256
+
257
+ function releasePointerCaptureSafely(): void {
258
+ if (capturedPointerId === null) return
259
+ try {
260
+ el.releasePointerCapture(capturedPointerId)
261
+ } catch {
262
+ // jsdom doesn't fully implement setPointerCapture; tolerate.
263
+ }
264
+ capturedPointerId = null
265
+ }
266
+
267
+ function stopMomentum(): void {
268
+ for (const ctrl of momentumControls) ctrl.stop()
269
+ momentumControls = []
270
+ }
271
+
272
+ // Stable handler references — they close over getOpts so reactive opts
273
+ // are read at event time. Hoisted out of the createPan call so the
274
+ // function-form options below doesn't re-allocate them per getOpts() call.
275
+ const handlePanStart = (event: PointerEvent, info: PanInfo) => {
276
+ // The pan session always fires onPanStart once movement crosses the
277
+ // threshold. Drag's enable check is here, not at the createPan-setup
278
+ // site, so toggling `drag` off mid-life immediately stops drag
279
+ // engagement without re-attaching pointer listeners.
280
+ if (!isDragEnabled()) return
281
+
282
+ // If momentum from a previous drag is still settling, cancel it now —
283
+ // the user has grabbed again, and they expect the element to follow
284
+ // their pointer from its current position, not continue decaying.
285
+ stopMomentum()
286
+
287
+ const ve = ensureVisualElement(el)
288
+ xMV = ve.getValue("x", 0) as MotionValue<number>
289
+ yMV = ve.getValue("y", 0) as MotionValue<number>
290
+ dragStartX = xMV.get()
291
+ dragStartY = yMV.get()
292
+
293
+ // Resolve constraints once per session. Reading layout rects mid-drag
294
+ // would cost a forced reflow per pointermove; one read at drag-start
295
+ // is enough for v0.1 (reactive constraint changes during a drag are
296
+ // a rare corner case — they re-apply on the NEXT session).
297
+ sessionBounds = resolveConstraints(getOpts().dragConstraints, el, dragStartX, dragStartY)
298
+
299
+ // Body + touch-action overrides — saved so the exact prior values
300
+ // restore on session end (don't assume defaults).
301
+ savedUserSelect = document.body.style.userSelect
302
+ savedTouchAction = el.style.touchAction
303
+ document.body.style.userSelect = "none"
304
+ el.style.touchAction = touchActionFor(getOpts().drag)
305
+
306
+ // Pointer capture keeps move events flowing to the element even when
307
+ // the pointer leaves it during a fast drag. setPointerCapture can
308
+ // throw in some browsers (e.g., already captured); swallow.
309
+ try {
310
+ el.setPointerCapture(event.pointerId)
311
+ capturedPointerId = event.pointerId
312
+ } catch {
313
+ // Safe — window listeners in createPan continue to fire regardless.
314
+ }
315
+
316
+ setActive("whileDrag", true)
317
+ getOpts().onDragStart?.(event, info)
318
+ }
319
+
320
+ const handlePan = (event: PointerEvent, info: PanInfo) => {
321
+ if (!isDragEnabled() || !xMV || !yMV) return
322
+
323
+ const axis = getOpts().drag
324
+ // Axis lock: when drag is "x" or "y", we SKIP writes to the locked axis
325
+ // entirely — matches motion/react's per-axis shouldDrag short-circuit.
326
+ // Writing dragStartY+0 to yMV when y is locked would generate
327
+ // no-op-but-non-empty writes that consumers and tests both observe.
328
+ const writeX = axis !== "y"
329
+ const writeY = axis !== "x"
330
+ const elastic = getOpts().dragElastic ?? DEFAULT_ELASTIC
331
+
332
+ if (writeX) {
333
+ const candidateX = dragStartX + info.offset.x
334
+ const finalX = sessionBounds
335
+ ? applyElastic(candidateX, sessionBounds.minX, sessionBounds.maxX, elastic)
336
+ : candidateX
337
+ xMV.set(finalX)
338
+ }
339
+ if (writeY) {
340
+ const candidateY = dragStartY + info.offset.y
341
+ const finalY = sessionBounds
342
+ ? applyElastic(candidateY, sessionBounds.minY, sessionBounds.maxY, elastic)
343
+ : candidateY
344
+ yMV.set(finalY)
345
+ }
346
+
347
+ getOpts().onDrag?.(event, info)
348
+ }
349
+
350
+ const handlePanEnd = (event: PointerEvent, info: PanInfo) => {
351
+ if (!isDragEnabled() || !xMV || !yMV) return
352
+
353
+ // Visual gesture state ends with the pointerup; momentum is a separate
354
+ // animation that continues after whileDrag deactivates. This matches
355
+ // motion/react semantic: `whileDrag: { scale: 1.05 }` un-scales at
356
+ // release, while the position settles independently.
357
+ setActive("whileDrag", false)
358
+ restoreBodyAndElementStyles()
359
+ releasePointerCaptureSafely()
360
+
361
+ getOpts().onDragEnd?.(event, info)
362
+
363
+ // Capture refs locally — the closure clears xMV/yMV below before the
364
+ // momentum promise can resolve, but the inertia animation needs stable
365
+ // references through its lifetime.
366
+ const xRef = xMV
367
+ const yRef = yMV
368
+ const boundsRef = sessionBounds
369
+ const opts = getOpts()
370
+ const snapToOrigin = opts.dragSnapToOrigin ?? false
371
+ const momentum = opts.dragMomentum ?? true
372
+ const userTransition = opts.dragTransition ?? {}
373
+
374
+ // Axis lock: the release path must mirror the same write-gate the drag
375
+ // loop uses (handlePan). Without this, pointer velocity on the locked
376
+ // axis feeds an inertia animation on that axis's MV — drifting the
377
+ // element along an axis the user explicitly locked. The cursor still
378
+ // has Y velocity when `drag: "x"` (any non-perfectly-horizontal motion
379
+ // moves the pointer through Y), so this matters in practice.
380
+ const dragAxis = opts.drag
381
+ const releaseX = dragAxis !== "y"
382
+ const releaseY = dragAxis !== "x"
383
+
384
+ // Reset the tracked momentum array — we'll push 1 or 2 controls below
385
+ // depending on axis. (handlePanStart's stopMomentum already cleared any
386
+ // prior session's controls, but we re-initialize here for clarity since
387
+ // the per-axis branches below append rather than replace.)
388
+ momentumControls = []
389
+
390
+ // Q15c follow-up: couple bounce physics to `dragElastic`. With elastic
391
+ // 0 (hard clamp), inertia's spring-back at the boundary uses default
392
+ // stiffness/damping that visibly overshoots before settling — even
393
+ // though the drag itself is clamped. Mirror motion-react's pattern
394
+ // (VisualElementDragControls.startAnimation): overdamp the spring with
395
+ // very high stiffness + damping so the snap-back is effectively
396
+ // instantaneous. With elastic > 0 we keep the soft spring so the
397
+ // rubber-band feel is preserved.
398
+ //
399
+ // Numerical choices (200/40 soft, 1e6/1e7 hard) come from motion-react.
400
+ const elastic = opts.dragElastic ?? DEFAULT_ELASTIC
401
+ const bounceParams = elastic
402
+ ? { bounceStiffness: 200, bounceDamping: 40 }
403
+ : { bounceStiffness: 1_000_000, bounceDamping: 10_000_000 }
404
+
405
+ // Flicker fix for elastic=0: motion-react's source acknowledges that
406
+ // overdamping the spring still computes one frame of overshoot before
407
+ // the snap-back. When the user releases AT a boundary with velocity
408
+ // pointing OUT of that boundary, there's nothing for inertia to
409
+ // usefully decay toward — feeding it the outward velocity produces
410
+ // exactly the visible flicker. Zero those release velocities and
411
+ // inertia settles silently at the bound. Inward velocities are
412
+ // preserved so a release moving back toward center still glides.
413
+ const xAtMax = boundsRef !== null && boundsRef.maxX !== Infinity && xRef.get() >= boundsRef.maxX
414
+ const xAtMin =
415
+ boundsRef !== null && boundsRef.minX !== -Infinity && xRef.get() <= boundsRef.minX
416
+ const yAtMax = boundsRef !== null && boundsRef.maxY !== Infinity && yRef.get() >= boundsRef.maxY
417
+ const yAtMin =
418
+ boundsRef !== null && boundsRef.minY !== -Infinity && yRef.get() <= boundsRef.minY
419
+ const xVelocity =
420
+ !elastic && ((xAtMax && info.velocity.x > 0) || (xAtMin && info.velocity.x < 0))
421
+ ? 0
422
+ : info.velocity.x
423
+ const yVelocity =
424
+ !elastic && ((yAtMax && info.velocity.y > 0) || (yAtMin && info.velocity.y < 0))
425
+ ? 0
426
+ : info.velocity.y
427
+
428
+ /** Fire onDragTransitionEnd via getOpts so reactive callback swaps see
429
+ * the latest value (the user may have swapped handlers between pan-end
430
+ * and momentum-settle). */
431
+ const fireTransitionEnd = () => getOpts().onDragTransitionEnd?.()
432
+
433
+ if (snapToOrigin) {
434
+ // Spring back to (0, 0). motion's pattern (Q15e): use the inertia
435
+ // transition but clamp min/max to 0 so the spring physics carries
436
+ // the value home from wherever the user released it.
437
+ const transitionX = {
438
+ ...DEFAULT_DRAG_TRANSITION,
439
+ ...bounceParams,
440
+ ...userTransition,
441
+ velocity: xVelocity,
442
+ min: 0,
443
+ max: 0,
444
+ }
445
+ const transitionY = {
446
+ ...DEFAULT_DRAG_TRANSITION,
447
+ ...bounceParams,
448
+ ...userTransition,
449
+ velocity: yVelocity,
450
+ min: 0,
451
+ max: 0,
452
+ }
453
+ const settles: Array<Promise<unknown> | AnimationPlaybackControls> = []
454
+ if (releaseX) {
455
+ // biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct. Target arg is a placeholder — inertia computes the actual settle point from velocity + min/max.
456
+ const ctrlX = animate(xRef, 0, transitionX as any)
457
+ momentumControls.push(ctrlX)
458
+ settles.push(ctrlX)
459
+ }
460
+ if (releaseY) {
461
+ // biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct.
462
+ const ctrlY = animate(yRef, 0, transitionY as any)
463
+ momentumControls.push(ctrlY)
464
+ settles.push(ctrlY)
465
+ }
466
+ if (settles.length > 0) Promise.all(settles).then(fireTransitionEnd)
467
+ else fireTransitionEnd()
468
+ } else {
469
+ // Inertia release path — runs regardless of `dragMomentum`. When
470
+ // momentum is true, we feed the (heuristic-clamped) release velocity
471
+ // so the element glides naturally. When momentum is false, we feed
472
+ // velocity 0 — there's no decay, but the bounce physics still spring
473
+ // the element back to the bound if elastic let it overshoot during
474
+ // the drag. Skipping the animate entirely (the prior behavior) left
475
+ // the user stranded outside the container with no way to reach the
476
+ // element. Matches motion-react's pattern (VisualElementDragControls
477
+ // .startAnimation always runs inertia, zeroing velocity when
478
+ // dragMomentum is false).
479
+ const releaseVelocityX = momentum ? xVelocity : 0
480
+ const releaseVelocityY = momentum ? yVelocity : 0
481
+ const transitionX = {
482
+ ...DEFAULT_DRAG_TRANSITION,
483
+ ...bounceParams,
484
+ ...userTransition,
485
+ velocity: releaseVelocityX,
486
+ min: boundsRef?.minX,
487
+ max: boundsRef?.maxX,
488
+ }
489
+ const transitionY = {
490
+ ...DEFAULT_DRAG_TRANSITION,
491
+ ...bounceParams,
492
+ ...userTransition,
493
+ velocity: releaseVelocityY,
494
+ min: boundsRef?.minY,
495
+ max: boundsRef?.maxY,
496
+ }
497
+ const settles: Array<Promise<unknown> | AnimationPlaybackControls> = []
498
+ if (releaseX) {
499
+ // biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct.
500
+ const ctrlX = animate(xRef, 0, transitionX as any)
501
+ momentumControls.push(ctrlX)
502
+ settles.push(ctrlX)
503
+ }
504
+ if (releaseY) {
505
+ // biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape; the runtime call is correct.
506
+ const ctrlY = animate(yRef, 0, transitionY as any)
507
+ momentumControls.push(ctrlY)
508
+ settles.push(ctrlY)
509
+ }
510
+ if (settles.length > 0) Promise.all(settles).then(fireTransitionEnd)
511
+ else fireTransitionEnd()
512
+ }
513
+
514
+ xMV = null
515
+ yMV = null
516
+ sessionBounds = null
517
+ }
518
+
519
+ // Function-form options so createPan reads `panThreshold` reactively.
520
+ // Handler references are stable — only the threshold (and the wrapping
521
+ // object) is recreated per call from createPan.
522
+ createPan(
523
+ () => el,
524
+ () => ({
525
+ threshold: getOpts().panThreshold,
526
+ onPanStart: handlePanStart,
527
+ onPan: handlePan,
528
+ onPanEnd: handlePanEnd,
529
+ }),
530
+ )
531
+
532
+ // ---------- External drag (Q9 — createDragControls integration) ----------
533
+ // When the user wires `dragControls: someControls` into MotionOptions, an
534
+ // external pointerdown elsewhere in the UI (a "drag handle" button) can
535
+ // start a drag on this element via `controls.start(event)`. Bypasses the
536
+ // threshold gate — the user explicitly said "drag," no hysteresis needed.
537
+ //
538
+ // We synthesize our own pan session here rather than re-using createPan's
539
+ // because:
540
+ // 1. The originating element is the drag handle, not `el`. createPan's
541
+ // pointerdown listener is bound to `el` and wouldn't see the event.
542
+ // 2. We need to skip threshold; createPan's threshold gate is private.
543
+ //
544
+ // The session-tracking logic (pointerId match, sample buffer for velocity,
545
+ // window listener attach/cleanup) is duplicated from createPan. A future
546
+ // refactor could extract a shared "pan session runner" if a third caller
547
+ // emerges; for v0.1 the duplication is contained.
548
+ function startExternalDrag(event: PointerEvent, options: DragControlsStartOptions): void {
549
+ if (!isDragEnabled()) return
550
+
551
+ // Snap-to-cursor (Q9b): move the element so its center sits under the
552
+ // pointer BEFORE the drag-start info captures dragStartX/Y. Otherwise
553
+ // the offset chain would start from the original position and the
554
+ // visible jump-to-cursor would be lost on first pointermove.
555
+ if (options.snapToCursor) {
556
+ const ve = ensureVisualElement(el)
557
+ const snapXMV = ve.getValue("x", 0) as MotionValue<number>
558
+ const snapYMV = ve.getValue("y", 0) as MotionValue<number>
559
+ const elRect = el.getBoundingClientRect()
560
+ const centerX = elRect.left + elRect.width / 2
561
+ const centerY = elRect.top + elRect.height / 2
562
+ const axis = getOpts().drag
563
+ if (axis !== "y") snapXMV.set(snapXMV.get() + (event.clientX - centerX))
564
+ if (axis !== "x") snapYMV.set(snapYMV.get() + (event.clientY - centerY))
565
+ }
566
+
567
+ // Fire handlePanStart with a synthesized initial PanInfo. This
568
+ // initializes xMV/yMV/dragStartX/Y, resolves bounds, sets body styles,
569
+ // captures pointer, activates whileDrag, and fires onDragStart.
570
+ const initialInfo: PanInfo = {
571
+ point: { x: event.clientX, y: event.clientY },
572
+ delta: { x: 0, y: 0 },
573
+ offset: { x: 0, y: 0 },
574
+ velocity: { x: 0, y: 0 },
575
+ }
576
+ handlePanStart(event, initialInfo)
577
+
578
+ // Track the session locally — these would normally live inside
579
+ // createPan's closure. Velocity samples use motion-dom's `time.now()`
580
+ // to stay frame-synchronous with the rest of the pipeline.
581
+ const sessionStartPoint = { x: event.clientX, y: event.clientY }
582
+ let sessionLastPoint = { ...sessionStartPoint }
583
+ const sessionPointerId = event.pointerId
584
+ const sessionSamples: Array<{ t: number; point: { x: number; y: number } }> = [
585
+ { t: time.now(), point: { ...sessionStartPoint } },
586
+ ]
587
+
588
+ function computeSessionVelocity(): { x: number; y: number } {
589
+ if (sessionSamples.length < 2) return { x: 0, y: 0 }
590
+ const first = sessionSamples[0]
591
+ const last = sessionSamples[sessionSamples.length - 1]
592
+ if (!first || !last) return { x: 0, y: 0 }
593
+ const dt = last.t - first.t
594
+ if (dt <= 0) return { x: 0, y: 0 }
595
+ return {
596
+ x: ((last.point.x - first.point.x) / dt) * 1000,
597
+ y: ((last.point.y - first.point.y) / dt) * 1000,
598
+ }
599
+ }
600
+
601
+ function buildSessionInfo(e: PointerEvent): PanInfo {
602
+ const point = { x: e.clientX, y: e.clientY }
603
+ return {
604
+ point,
605
+ delta: { x: point.x - sessionLastPoint.x, y: point.y - sessionLastPoint.y },
606
+ offset: { x: point.x - sessionStartPoint.x, y: point.y - sessionStartPoint.y },
607
+ velocity: computeSessionVelocity(),
608
+ }
609
+ }
610
+
611
+ function onSessionMove(e: PointerEvent): void {
612
+ if (e.pointerId !== sessionPointerId) return
613
+ const point = { x: e.clientX, y: e.clientY }
614
+ const now = time.now()
615
+ sessionSamples.push({ t: now, point })
616
+ const cutoff = now - 200
617
+ while (sessionSamples.length > 1 && (sessionSamples[0]?.t ?? 0) < cutoff) {
618
+ sessionSamples.shift()
619
+ }
620
+ const info = buildSessionInfo(e)
621
+ sessionLastPoint = point
622
+ handlePan(e, info)
623
+ }
624
+
625
+ function onSessionEnd(e: PointerEvent): void {
626
+ if (e.pointerId !== sessionPointerId) return
627
+ const info = buildSessionInfo(e)
628
+ handlePanEnd(e, info)
629
+ window.removeEventListener("pointermove", onSessionMove)
630
+ window.removeEventListener("pointerup", onSessionEnd)
631
+ window.removeEventListener("pointercancel", onSessionEnd)
632
+ }
633
+
634
+ window.addEventListener("pointermove", onSessionMove)
635
+ window.addEventListener("pointerup", onSessionEnd)
636
+ window.addEventListener("pointercancel", onSessionEnd)
637
+ }
638
+
639
+ // Register with the controls instance whenever opts.dragControls changes.
640
+ // createEffect re-runs on swap; the previous registration unmounts via
641
+ // the symbol-keyed unregister function. Q9d — last mount wins; the
642
+ // unregister only nulls out if we're still the active handler.
643
+ createEffect(() => {
644
+ const controls = getOpts().dragControls as DragControls | undefined
645
+ if (!controls) return
646
+ const internal = controls as DragControls & {
647
+ [DRAG_CONTROLS_REGISTER]?: (
648
+ handler: (event: PointerEvent, options: DragControlsStartOptions) => void,
649
+ ) => () => void
650
+ }
651
+ const register = internal[DRAG_CONTROLS_REGISTER]
652
+ if (!register) return
653
+ const unregister = register(startExternalDrag)
654
+ onCleanup(unregister)
655
+ })
656
+
657
+ // Owner-disposal cleanup. Three layers:
658
+ // 1. Stop any settling momentum animations (they hold MV references that
659
+ // keep ticking after disposal otherwise).
660
+ // 2. Restore the body/touch styles if we're in mid-drag.
661
+ // 3. Release any captured pointer.
662
+ // createPan's own onCleanup handles removing its listeners separately.
663
+ onCleanup(() => {
664
+ stopMomentum()
665
+ if (xMV || yMV) {
666
+ restoreBodyAndElementStyles()
667
+ releasePointerCaptureSafely()
668
+ }
669
+ })
670
+ }