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.
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/dist/src/default-values.d.ts +6 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/motion-config.d.ts +14 -0
- package/dist/src/motion-proxy.d.ts +103 -0
- package/dist/src/presence-context.d.ts +4 -0
- package/dist/src/presence.d.ts +95 -0
- package/dist/src/primitives/createDrag.d.ts +16 -0
- package/dist/src/primitives/createDragControls.d.ts +30 -0
- package/dist/src/primitives/createGestures.d.ts +8 -0
- package/dist/src/primitives/createInView.d.ts +51 -0
- package/dist/src/primitives/createMotion.d.ts +82 -0
- package/dist/src/primitives/createPan.d.ts +83 -0
- package/dist/src/primitives/createScroll.d.ts +40 -0
- package/dist/src/primitives/gesture-state.d.ts +108 -0
- package/dist/src/primitives/motion-value.d.ts +111 -0
- package/dist/src/primitives/value-registry.d.ts +34 -0
- package/dist/src/reduced-motion.d.ts +29 -0
- package/dist/src/style.d.ts +79 -0
- package/dist/src/types.d.ts +374 -0
- package/dist/src/use-motion.d.ts +35 -0
- package/dist/src/variants.d.ts +64 -0
- package/package.json +78 -0
- package/src/default-values.ts +52 -0
- package/src/index.ts +60 -0
- package/src/motion-config.tsx +37 -0
- package/src/motion-proxy.tsx +377 -0
- package/src/presence-context.ts +32 -0
- package/src/presence.tsx +466 -0
- package/src/primitives/createDrag.ts +670 -0
- package/src/primitives/createDragControls.ts +101 -0
- package/src/primitives/createGestures.ts +145 -0
- package/src/primitives/createInView.ts +124 -0
- package/src/primitives/createMotion.ts +638 -0
- package/src/primitives/createPan.ts +338 -0
- package/src/primitives/createScroll.ts +101 -0
- package/src/primitives/gesture-state.ts +772 -0
- package/src/primitives/motion-value.ts +328 -0
- package/src/primitives/value-registry.ts +114 -0
- package/src/reduced-motion.ts +51 -0
- package/src/style.ts +266 -0
- package/src/types.ts +538 -0
- package/src/use-motion.tsx +412 -0
- package/src/variants.ts +134 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { isPrimaryPointer, time } from "motion-dom"
|
|
2
|
+
import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js"
|
|
3
|
+
import type { MotionValueAccessor, PanInfo } from "../types"
|
|
4
|
+
import { createMotionValue } from "./motion-value"
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// createPan — standalone pan-session primitive (Q11/c).
|
|
8
|
+
//
|
|
9
|
+
// Phase 2 Commit 5 (Q11/D3): pointer-session machinery that createDrag uses
|
|
10
|
+
// as its underlying event source. Drag IS a pan that owns the element's
|
|
11
|
+
// transform; pan on its own is callback-only (no `whilePan` state).
|
|
12
|
+
//
|
|
13
|
+
// Return shape — a SEMANTIC split between animate-able numeric values
|
|
14
|
+
// (MotionValueAccessors) and non-animate-able state (a plain Accessor):
|
|
15
|
+
//
|
|
16
|
+
// - `point.x/y`, `delta.x/y`, `offset.x/y`, `velocity.x/y` → each is a
|
|
17
|
+
// {@link MotionValueAccessor}<number>. Calling them (`pan.point.x()`) is
|
|
18
|
+
// Solid-tracked; the full MotionValue surface (`.get`, `.set`, `.on`,
|
|
19
|
+
// `.getVelocity`) is available; they compose directly with `animate()`,
|
|
20
|
+
// `createTransform`, `useMotion` targets, and JSX reactivity.
|
|
21
|
+
// - `isPanning` → a plain `Accessor<boolean>`. Booleans aren't animate-able,
|
|
22
|
+
// so wrapping in an MV would only add weight.
|
|
23
|
+
//
|
|
24
|
+
// Why MotionValues for the numeric fields? Composability. Users can pipe
|
|
25
|
+
// `pan.point.x` straight into `createTransform`, `animate()`, or use it as
|
|
26
|
+
// a target in `useMotion({ animate: { x: pan.point.x } })` — same surface
|
|
27
|
+
// Phase 1 established for every animate-able value in the library.
|
|
28
|
+
//
|
|
29
|
+
// The session:
|
|
30
|
+
// pointerdown → reset per-session MVs to start point / zeros, attach
|
|
31
|
+
// window listeners
|
|
32
|
+
// pointermove(s) → update MVs every move (Option X — pre-threshold too,
|
|
33
|
+
// so consumers can render threshold-progress or
|
|
34
|
+
// early-detect fast pans); once cumulative offset
|
|
35
|
+
// crosses `threshold`, isPanning flips true and
|
|
36
|
+
// onPanStart fires; subsequent moves fire onPan
|
|
37
|
+
// pointerup → flip isPanning false; if pan happened, fire onPanEnd;
|
|
38
|
+
// point/delta/offset/velocity MVs RETAINED (useful for
|
|
39
|
+
// snap-to-end animations)
|
|
40
|
+
// pointercancel → same as pointerup, but the user's gesture was aborted
|
|
41
|
+
//
|
|
42
|
+
// Velocity tracking (Q15a): sliding window of pointer samples, 200ms wide.
|
|
43
|
+
// Velocity = (latest.point − oldest.point) / Δt × 1000 (px/sec). Uses
|
|
44
|
+
// motion-dom's `time.now()` so timestamps stay frame-synchronous with the
|
|
45
|
+
// rest of motion's pipeline.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Sliding-window width for velocity computation (Q15a). */
|
|
49
|
+
const VELOCITY_WINDOW_MS = 200
|
|
50
|
+
/** Default movement threshold before onPanStart fires (Q11a, matches motion). */
|
|
51
|
+
const DEFAULT_THRESHOLD = 3
|
|
52
|
+
|
|
53
|
+
type Point = { x: number; y: number }
|
|
54
|
+
|
|
55
|
+
export type CreatePanOptions = {
|
|
56
|
+
/** Fires once after pointer movement crosses the threshold. */
|
|
57
|
+
onPanStart?: (event: PointerEvent, info: PanInfo) => void
|
|
58
|
+
/** Fires on every pointermove after onPanStart, until pointerup/cancel. */
|
|
59
|
+
onPan?: (event: PointerEvent, info: PanInfo) => void
|
|
60
|
+
/**
|
|
61
|
+
* Fires on pointerup OR pointercancel after onPanStart has fired.
|
|
62
|
+
* If the pointer was released before the threshold was crossed, onPanEnd
|
|
63
|
+
* is NOT fired (no pan ever happened).
|
|
64
|
+
*/
|
|
65
|
+
onPanEnd?: (event: PointerEvent, info: PanInfo) => void
|
|
66
|
+
/**
|
|
67
|
+
* Minimum cumulative offset (in px) before onPanStart fires. Distinguishes
|
|
68
|
+
* pan from click. Default: 3px (motion's default).
|
|
69
|
+
*/
|
|
70
|
+
threshold?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Per-axis pair of {@link MotionValueAccessor}s — `pan.point`, `pan.delta`, etc. */
|
|
74
|
+
export type PanAxisPair = {
|
|
75
|
+
x: MotionValueAccessor<number>
|
|
76
|
+
y: MotionValueAccessor<number>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returned by {@link createPan}. `isPanning` is a plain Accessor (booleans
|
|
81
|
+
* aren't animate-able). The four numeric pairs are MotionValueAccessors,
|
|
82
|
+
* each composable with `animate()`, `createTransform`, and `useMotion`.
|
|
83
|
+
*/
|
|
84
|
+
export type CreatePanResult = {
|
|
85
|
+
isPanning: Accessor<boolean>
|
|
86
|
+
point: PanAxisPair
|
|
87
|
+
delta: PanAxisPair
|
|
88
|
+
offset: PanAxisPair
|
|
89
|
+
velocity: PanAxisPair
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Observe pointer-driven pan gestures on an element.
|
|
94
|
+
*
|
|
95
|
+
* Returns `{ isPanning, point, delta, offset, velocity }`:
|
|
96
|
+
*
|
|
97
|
+
* - `pan.isPanning()` — Solid Accessor; `true` between onPanStart and onPanEnd.
|
|
98
|
+
* - `pan.point.x`, `pan.point.y` — current pointer position in client coords.
|
|
99
|
+
* Each is a {@link MotionValueAccessor}: call `pan.point.x()` for a tracked
|
|
100
|
+
* read, `pan.point.x.get()` for an untracked snapshot, and pass it directly
|
|
101
|
+
* to `animate()`, `createTransform`, or `useMotion` targets.
|
|
102
|
+
* - `pan.delta.x/y` — delta since last pointermove.
|
|
103
|
+
* - `pan.offset.x/y` — cumulative offset since the current pointerdown.
|
|
104
|
+
* - `pan.velocity.x/y` — sliding-window velocity in px/sec.
|
|
105
|
+
*
|
|
106
|
+
* Fields update from `pointerdown` forward (including pre-threshold moves)
|
|
107
|
+
* — gate reads on `pan.isPanning()` if you only care about real pans.
|
|
108
|
+
*
|
|
109
|
+
* The `options` argument accepts either a static object or a function form
|
|
110
|
+
* (matching `useMotion`'s convention). The function form is read INSIDE
|
|
111
|
+
* each pointer-event handler, so reactive option changes apply on the next
|
|
112
|
+
* relevant event without re-attaching listeners.
|
|
113
|
+
*
|
|
114
|
+
* @example Static options
|
|
115
|
+
* const pan = createPan(el, {
|
|
116
|
+
* onPanStart: (e, info) => console.log("start", info.point),
|
|
117
|
+
* threshold: 3,
|
|
118
|
+
* })
|
|
119
|
+
*
|
|
120
|
+
* @example Reactive options (function form — signals tracked)
|
|
121
|
+
* const [threshold, setThreshold] = createSignal(3)
|
|
122
|
+
* const pan = createPan(el, () => ({
|
|
123
|
+
* threshold: threshold(),
|
|
124
|
+
* onPanStart: (e, info) => console.log(info),
|
|
125
|
+
* }))
|
|
126
|
+
*
|
|
127
|
+
* @example Composing pan.point.x with createTransform
|
|
128
|
+
* const pan = createPan(el)
|
|
129
|
+
* const rotation = createTransform(pan.point.x, [0, 300], [0, 90])
|
|
130
|
+
* <div ref={setEl} style={{ transform: `rotate(${rotation()}deg)` }} />
|
|
131
|
+
*
|
|
132
|
+
* @example Reading reactively in JSX
|
|
133
|
+
* const pan = createPan(el)
|
|
134
|
+
* <Show when={pan.isPanning()}>
|
|
135
|
+
* Position: {pan.point.x()}, {pan.point.y()}
|
|
136
|
+
* </Show>
|
|
137
|
+
*/
|
|
138
|
+
export function createPan(
|
|
139
|
+
ref: () => HTMLElement | null | undefined,
|
|
140
|
+
options: CreatePanOptions | (() => CreatePanOptions) = {},
|
|
141
|
+
): CreatePanResult {
|
|
142
|
+
// Normalize to a function form. All option reads inside event handlers
|
|
143
|
+
// call this so the latest reactive values are seen on each event.
|
|
144
|
+
const getOpts: () => CreatePanOptions = typeof options === "function" ? options : () => options
|
|
145
|
+
|
|
146
|
+
// ---- State surface ----
|
|
147
|
+
// isPanning is a plain signal — booleans aren't animate-able, so a full
|
|
148
|
+
// MotionValue would be dead weight.
|
|
149
|
+
const [isPanning, setIsPanning] = createSignal(false)
|
|
150
|
+
// Eight MVs for the four numeric pairs. Each becomes a callable hybrid via
|
|
151
|
+
// createMotionValue: invokable as a tracked Accessor AND has the full
|
|
152
|
+
// MotionValue surface so consumers can pipe them into `animate()`,
|
|
153
|
+
// `createTransform`, or `useMotion` targets.
|
|
154
|
+
const pointX = createMotionValue(0)
|
|
155
|
+
const pointY = createMotionValue(0)
|
|
156
|
+
const deltaX = createMotionValue(0)
|
|
157
|
+
const deltaY = createMotionValue(0)
|
|
158
|
+
const offsetX = createMotionValue(0)
|
|
159
|
+
const offsetY = createMotionValue(0)
|
|
160
|
+
const velocityX = createMotionValue(0)
|
|
161
|
+
const velocityY = createMotionValue(0)
|
|
162
|
+
|
|
163
|
+
// createEffect — Solid-idiomatic for side-effect setup (DOM listeners).
|
|
164
|
+
// First iteration runs in the next microtask, which is harmless here: a
|
|
165
|
+
// freshly-mounted element can't receive pointer events between the ref
|
|
166
|
+
// callback firing and the next microtask. Re-runs (ref changes) carry the
|
|
167
|
+
// same harmless delay.
|
|
168
|
+
createEffect(() => {
|
|
169
|
+
const el = ref()
|
|
170
|
+
if (!el) return
|
|
171
|
+
|
|
172
|
+
// NOTE: threshold and callbacks are read INSIDE the event handlers via
|
|
173
|
+
// getOpts(), not captured here. That way reactive opts changes apply on
|
|
174
|
+
// the next relevant event without re-attaching listeners (which would
|
|
175
|
+
// require this effect to depend on getOpts and re-run on opt changes).
|
|
176
|
+
|
|
177
|
+
// Session state — reset on each pointerdown. Scoped per effect iteration;
|
|
178
|
+
// cleanup below reaches all listeners regardless of phase.
|
|
179
|
+
let startPoint: Point | null = null
|
|
180
|
+
let lastPoint: Point | null = null
|
|
181
|
+
let pointerId: number | null = null
|
|
182
|
+
let panning = false
|
|
183
|
+
let samples: Array<{ t: number; point: Point }> = []
|
|
184
|
+
|
|
185
|
+
function pointOf(event: PointerEvent): Point {
|
|
186
|
+
return { x: event.clientX, y: event.clientY }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function computeVelocity(): Point {
|
|
190
|
+
if (samples.length < 2) return { x: 0, y: 0 }
|
|
191
|
+
// biome-ignore lint/style/noNonNullAssertion: length >= 2 guarantees both indices exist
|
|
192
|
+
const first = samples[0]!
|
|
193
|
+
// biome-ignore lint/style/noNonNullAssertion: length >= 2 guarantees both indices exist
|
|
194
|
+
const last = samples[samples.length - 1]!
|
|
195
|
+
const dt = last.t - first.t
|
|
196
|
+
if (dt <= 0) return { x: 0, y: 0 }
|
|
197
|
+
return {
|
|
198
|
+
x: ((last.point.x - first.point.x) / dt) * 1000,
|
|
199
|
+
y: ((last.point.y - first.point.y) / dt) * 1000,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildInfo(event: PointerEvent): PanInfo {
|
|
204
|
+
const point = pointOf(event)
|
|
205
|
+
const delta = lastPoint
|
|
206
|
+
? { x: point.x - lastPoint.x, y: point.y - lastPoint.y }
|
|
207
|
+
: { x: 0, y: 0 }
|
|
208
|
+
const offset = startPoint
|
|
209
|
+
? { x: point.x - startPoint.x, y: point.y - startPoint.y }
|
|
210
|
+
: { x: 0, y: 0 }
|
|
211
|
+
const velocity = computeVelocity()
|
|
212
|
+
return { point, delta, offset, velocity }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Push a freshly-computed info snapshot into the MVs. Each `.set` fires
|
|
216
|
+
* the MV's change subscription, which the callable-hybrid bridge
|
|
217
|
+
* forwards to Solid; consumers reading e.g. only `pan.velocity.x()` only
|
|
218
|
+
* re-run when velocity.x actually changes — pre-existing MotionValue
|
|
219
|
+
* granularity, not Store path-tracking. */
|
|
220
|
+
function writeInfo(info: PanInfo): void {
|
|
221
|
+
pointX.set(info.point.x)
|
|
222
|
+
pointY.set(info.point.y)
|
|
223
|
+
deltaX.set(info.delta.x)
|
|
224
|
+
deltaY.set(info.delta.y)
|
|
225
|
+
offsetX.set(info.offset.x)
|
|
226
|
+
offsetY.set(info.offset.y)
|
|
227
|
+
velocityX.set(info.velocity.x)
|
|
228
|
+
velocityY.set(info.velocity.y)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function onPointerDown(event: PointerEvent): void {
|
|
232
|
+
// motion-dom's isPrimaryPointer filters secondary buttons (mouse) and
|
|
233
|
+
// secondary touch points. Same gating Q13c established for press.
|
|
234
|
+
if (!isPrimaryPointer(event)) return
|
|
235
|
+
|
|
236
|
+
startPoint = pointOf(event)
|
|
237
|
+
lastPoint = startPoint
|
|
238
|
+
pointerId = event.pointerId
|
|
239
|
+
panning = false
|
|
240
|
+
samples = [{ t: time.now(), point: startPoint }]
|
|
241
|
+
|
|
242
|
+
// Reset per-session fields. Point goes to start; delta/offset/velocity
|
|
243
|
+
// zero. isPanning false (threshold not crossed yet).
|
|
244
|
+
setIsPanning(false)
|
|
245
|
+
pointX.set(startPoint.x)
|
|
246
|
+
pointY.set(startPoint.y)
|
|
247
|
+
deltaX.set(0)
|
|
248
|
+
deltaY.set(0)
|
|
249
|
+
offsetX.set(0)
|
|
250
|
+
offsetY.set(0)
|
|
251
|
+
velocityX.set(0)
|
|
252
|
+
velocityY.set(0)
|
|
253
|
+
|
|
254
|
+
// Listen on window so events keep firing even when the pointer leaves
|
|
255
|
+
// the element (e.g., during a fast drag). Mirrors motion-dom's press.
|
|
256
|
+
window.addEventListener("pointermove", onPointerMove)
|
|
257
|
+
window.addEventListener("pointerup", onPointerEnd)
|
|
258
|
+
window.addEventListener("pointercancel", onPointerEnd)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function onPointerMove(event: PointerEvent): void {
|
|
262
|
+
// Multi-touch / unrelated pointers ignored.
|
|
263
|
+
if (event.pointerId !== pointerId) return
|
|
264
|
+
|
|
265
|
+
const point = pointOf(event)
|
|
266
|
+
const now = time.now()
|
|
267
|
+
|
|
268
|
+
// Append sample, drop everything outside the 200ms window.
|
|
269
|
+
samples.push({ t: now, point })
|
|
270
|
+
const cutoff = now - VELOCITY_WINDOW_MS
|
|
271
|
+
while (samples.length > 1 && (samples[0]?.t ?? 0) < cutoff) {
|
|
272
|
+
samples.shift()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const info = buildInfo(event)
|
|
276
|
+
// Option X — info updates on EVERY move, including pre-threshold.
|
|
277
|
+
// Consumers gate on `isPanning()` for "real pan" semantics.
|
|
278
|
+
writeInfo(info)
|
|
279
|
+
|
|
280
|
+
if (!panning) {
|
|
281
|
+
// Threshold gate: pan hasn't started yet. Read threshold fresh from
|
|
282
|
+
// getOpts() so reactive changes apply (a session in progress sticks
|
|
283
|
+
// with the threshold it saw when this branch first crossed).
|
|
284
|
+
const threshold = getOpts().threshold ?? DEFAULT_THRESHOLD
|
|
285
|
+
const distance = Math.hypot(info.offset.x, info.offset.y)
|
|
286
|
+
if (distance >= threshold) {
|
|
287
|
+
panning = true
|
|
288
|
+
setIsPanning(true)
|
|
289
|
+
getOpts().onPanStart?.(event, info)
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
getOpts().onPan?.(event, info)
|
|
293
|
+
}
|
|
294
|
+
lastPoint = point
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function onPointerEnd(event: PointerEvent): void {
|
|
298
|
+
if (event.pointerId !== pointerId) return
|
|
299
|
+
// onPanEnd only fires if onPanStart fired — pan-cancelled-before-start
|
|
300
|
+
// (mere clicks) shouldn't emit lifecycle callbacks.
|
|
301
|
+
if (panning) {
|
|
302
|
+
getOpts().onPanEnd?.(event, buildInfo(event))
|
|
303
|
+
}
|
|
304
|
+
panning = false
|
|
305
|
+
// Flip isPanning. Point/delta/offset/velocity MVs are RETAINED
|
|
306
|
+
// (option Q5/3) so consumers can read the final state for
|
|
307
|
+
// snap-to-end animations. Next pointerdown will reset them.
|
|
308
|
+
setIsPanning(false)
|
|
309
|
+
startPoint = null
|
|
310
|
+
lastPoint = null
|
|
311
|
+
pointerId = null
|
|
312
|
+
samples = []
|
|
313
|
+
window.removeEventListener("pointermove", onPointerMove)
|
|
314
|
+
window.removeEventListener("pointerup", onPointerEnd)
|
|
315
|
+
window.removeEventListener("pointercancel", onPointerEnd)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
el.addEventListener("pointerdown", onPointerDown)
|
|
319
|
+
|
|
320
|
+
// Iteration-scoped cleanup: fires when the ref changes (effect re-runs)
|
|
321
|
+
// AND when the owner disposes. Removes all listeners regardless of
|
|
322
|
+
// whether a session was in progress.
|
|
323
|
+
onCleanup(() => {
|
|
324
|
+
el.removeEventListener("pointerdown", onPointerDown)
|
|
325
|
+
window.removeEventListener("pointermove", onPointerMove)
|
|
326
|
+
window.removeEventListener("pointerup", onPointerEnd)
|
|
327
|
+
window.removeEventListener("pointercancel", onPointerEnd)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
isPanning,
|
|
333
|
+
point: { x: pointX, y: pointY },
|
|
334
|
+
delta: { x: deltaX, y: deltaY },
|
|
335
|
+
offset: { x: offsetX, y: offsetY },
|
|
336
|
+
velocity: { x: velocityX, y: velocityY },
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { scroll as motionScroll } from "motion"
|
|
2
|
+
import { createEffect, onCleanup } from "solid-js"
|
|
3
|
+
import type { MotionValueAccessor } from "../types"
|
|
4
|
+
import { createMotionValue } from "./motion-value"
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Scroll progress info — motion's `OnScrollWithInfo` callback receives this
|
|
8
|
+
// shape per scroll tick. Sticking to the small subset we wire into our
|
|
9
|
+
// MotionValues to keep our public types narrow.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
type ScrollAxisInfo = {
|
|
13
|
+
current: number
|
|
14
|
+
progress: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type MotionScrollInfo = {
|
|
18
|
+
x: ScrollAxisInfo
|
|
19
|
+
y: ScrollAxisInfo
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: motion's offset format is internal; we re-expose it as opaque
|
|
23
|
+
type ScrollOffset = any[]
|
|
24
|
+
|
|
25
|
+
export type CreateScrollOptions = {
|
|
26
|
+
/** Accessor returning the scroll container element. Defaults to window. */
|
|
27
|
+
container?: () => Element | null
|
|
28
|
+
/** Accessor returning the scroll target. Defaults to the container itself. */
|
|
29
|
+
target?: () => Element | null
|
|
30
|
+
/** Primary scroll axis (both axes are still populated regardless). */
|
|
31
|
+
axis?: "x" | "y"
|
|
32
|
+
/** Intersection offsets controlling when progress reaches 0/1. */
|
|
33
|
+
offset?: ScrollOffset
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CreateScrollResult = {
|
|
37
|
+
/** Current scroll-x position in px. Callable: `scrollX()` for reactive read. */
|
|
38
|
+
scrollX: MotionValueAccessor<number>
|
|
39
|
+
/** Current scroll-y position in px. Callable: `scrollY()` for reactive read. */
|
|
40
|
+
scrollY: MotionValueAccessor<number>
|
|
41
|
+
/** Normalized scroll-x progress in `[0, 1]` (or `[0, n]` for multi-offset). */
|
|
42
|
+
scrollXProgress: MotionValueAccessor<number>
|
|
43
|
+
/** Normalized scroll-y progress in `[0, 1]`. */
|
|
44
|
+
scrollYProgress: MotionValueAccessor<number>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bind four {@link MotionValueAccessor}s to a scroll source. Mirrors
|
|
49
|
+
* motion/react's `useScroll`; defaults to the window when no container is
|
|
50
|
+
* supplied. Each returned value is callable as a Solid Accessor AND has the
|
|
51
|
+
* full MotionValue surface, so it composes with `useMotion`'s target,
|
|
52
|
+
* `animate()`, `createTransform`, and direct JSX reactivity.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* const { scrollY, scrollYProgress } = createScroll()
|
|
56
|
+
* const opacity = createTransform(scrollYProgress, [0, 1], [1, 0])
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* const [el, setEl] = createSignal<HTMLElement>()
|
|
60
|
+
* const { scrollY } = createScroll({ container: el })
|
|
61
|
+
* <div ref={setEl} style={{ overflow: "auto" }}>...</div>
|
|
62
|
+
*/
|
|
63
|
+
export function createScroll(options?: CreateScrollOptions): CreateScrollResult {
|
|
64
|
+
const scrollX = createMotionValue(0)
|
|
65
|
+
const scrollY = createMotionValue(0)
|
|
66
|
+
const scrollXProgress = createMotionValue(0)
|
|
67
|
+
const scrollYProgress = createMotionValue(0)
|
|
68
|
+
|
|
69
|
+
// motion's scroll callback signature has two forms (OnScrollProgress and
|
|
70
|
+
// OnScrollWithInfo). With two parameters, info is passed.
|
|
71
|
+
const handler = (_progress: number, info?: MotionScrollInfo) => {
|
|
72
|
+
if (!info) return
|
|
73
|
+
scrollX.set(info.x.current)
|
|
74
|
+
scrollY.set(info.y.current)
|
|
75
|
+
scrollXProgress.set(info.x.progress)
|
|
76
|
+
scrollYProgress.set(info.y.progress)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// createEffect — Solid-idiomatic for side-effect setup (attaching the
|
|
80
|
+
// motion scroll subscription). First iteration runs in the next
|
|
81
|
+
// microtask, which is harmless: scroll events can't fire before the
|
|
82
|
+
// microtask flushes after mount. Accessors inside the body (container,
|
|
83
|
+
// target) are tracked — re-init happens when refs change.
|
|
84
|
+
//
|
|
85
|
+
// Each iteration's onCleanup is scoped to that iteration — Solid fires
|
|
86
|
+
// it when the effect re-runs (tearing down the previous subscription)
|
|
87
|
+
// and again when the outer owner disposes (tearing down the final one).
|
|
88
|
+
createEffect(() => {
|
|
89
|
+
const container = options?.container?.() ?? undefined
|
|
90
|
+
const target = options?.target?.() ?? undefined
|
|
91
|
+
const cleanup = motionScroll(handler, {
|
|
92
|
+
container: container as HTMLElement | undefined,
|
|
93
|
+
target: target as HTMLElement | undefined,
|
|
94
|
+
axis: options?.axis,
|
|
95
|
+
offset: options?.offset,
|
|
96
|
+
} as Parameters<typeof motionScroll>[1])
|
|
97
|
+
onCleanup(cleanup)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return { scrollX, scrollY, scrollXProgress, scrollYProgress }
|
|
101
|
+
}
|