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,101 @@
|
|
|
1
|
+
import type { DragControls, DragControlsStartOptions } from "../types"
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// createDragControls — imperative drag-controls factory (Q9).
|
|
5
|
+
//
|
|
6
|
+
// Architecture:
|
|
7
|
+
// - A controls instance has one public method: `start(event, options?)`.
|
|
8
|
+
// - createDrag (when its `opts.dragControls === thisInstance`) registers a
|
|
9
|
+
// handler via the symbol-keyed internal property defined below. The
|
|
10
|
+
// handler synthesizes a drag session from the externally-captured pointer
|
|
11
|
+
// event, bypassing the usual threshold gate.
|
|
12
|
+
// - One controls instance binds to one motion element at a time (Q9d).
|
|
13
|
+
// When a second element registers, the first is silently replaced. On
|
|
14
|
+
// unmount, only unregister if we're still the registered handler — avoid
|
|
15
|
+
// clobbering a later registration.
|
|
16
|
+
//
|
|
17
|
+
// Internal registration:
|
|
18
|
+
// - We attach a `_register(handler)` method to a non-enumerable symbol-keyed
|
|
19
|
+
// property (Q9e), keeping the public `DragControls` type clean. The
|
|
20
|
+
// symbol is exported as `DRAG_CONTROLS_REGISTER` for createDrag to find,
|
|
21
|
+
// but users importing only `DragControls` never see it.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Symbol used to attach the registration method on the controls object.
|
|
26
|
+
* `createDrag` imports this symbol to find/register its handler. Userland
|
|
27
|
+
* code never touches it — exported only for library-internal use.
|
|
28
|
+
*/
|
|
29
|
+
export const DRAG_CONTROLS_REGISTER = Symbol("solidjs-motion.dragControls.register")
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Internal registration signature — the handler that createDrag installs.
|
|
33
|
+
* Returns an unregister function (called on owner disposal).
|
|
34
|
+
*/
|
|
35
|
+
type RegistrationFn = (
|
|
36
|
+
handler: (event: PointerEvent, options: DragControlsStartOptions) => void,
|
|
37
|
+
) => () => void
|
|
38
|
+
|
|
39
|
+
/** Public + internal shape of the controls returned by `createDragControls`. */
|
|
40
|
+
type DragControlsInternal = DragControls & {
|
|
41
|
+
[DRAG_CONTROLS_REGISTER]: RegistrationFn
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a controls instance for imperatively starting a drag on a motion
|
|
46
|
+
* element from a different element (e.g., a drag-handle button).
|
|
47
|
+
*
|
|
48
|
+
* Pattern (Q9):
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* function Card() {
|
|
52
|
+
* const controls = createDragControls()
|
|
53
|
+
* const m = useMotion({ drag: "y", dragControls: controls })
|
|
54
|
+
* return (
|
|
55
|
+
* <div {...m()}>
|
|
56
|
+
* <button onPointerDown={(e) => controls.start(e)}>handle</button>
|
|
57
|
+
* Card body
|
|
58
|
+
* </div>
|
|
59
|
+
* )
|
|
60
|
+
* }
|
|
61
|
+
*
|
|
62
|
+
* The handle's pointerdown fires `controls.start(event)`. createDrag is
|
|
63
|
+
* registered with the controls and translates the call into a pan-session
|
|
64
|
+
* synthesized from the handle's event, bypassing the threshold gate.
|
|
65
|
+
*/
|
|
66
|
+
export function createDragControls(): DragControls {
|
|
67
|
+
let handler: ((event: PointerEvent, options: DragControlsStartOptions) => void) | null = null
|
|
68
|
+
|
|
69
|
+
// Public surface — `start` is the only enumerable property.
|
|
70
|
+
const controls: DragControlsInternal = {
|
|
71
|
+
start(event: PointerEvent, options: DragControlsStartOptions = {}) {
|
|
72
|
+
// No-op when no motion element is currently registered. Matches
|
|
73
|
+
// motion/react's behavior: the user's pointerdown handler can be
|
|
74
|
+
// attached unconditionally, and start() is harmless until binding.
|
|
75
|
+
handler?.(event, options)
|
|
76
|
+
},
|
|
77
|
+
// Placeholder — filled in immediately below via Object.defineProperty
|
|
78
|
+
// so the property is non-enumerable.
|
|
79
|
+
[DRAG_CONTROLS_REGISTER]: undefined as unknown as RegistrationFn,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Q9e — registration internals on a non-enumerable property keyed by
|
|
83
|
+
// Symbol. Type-level the property is on DragControlsInternal, but the
|
|
84
|
+
// public DragControls type omits it — userland sees only `.start`.
|
|
85
|
+
Object.defineProperty(controls, DRAG_CONTROLS_REGISTER, {
|
|
86
|
+
value: ((newHandler) => {
|
|
87
|
+
handler = newHandler
|
|
88
|
+
// Q9d — return an unregister that only nulls out if we're still the
|
|
89
|
+
// active handler. Prevents a stale unmount from clobbering a later
|
|
90
|
+
// registration on the same controls instance.
|
|
91
|
+
return () => {
|
|
92
|
+
if (handler === newHandler) handler = null
|
|
93
|
+
}
|
|
94
|
+
}) satisfies RegistrationFn,
|
|
95
|
+
enumerable: false,
|
|
96
|
+
writable: false,
|
|
97
|
+
configurable: false,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return controls
|
|
101
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { addDomEvent, hover, press } from "motion-dom"
|
|
2
|
+
import { createEffect, onCleanup } from "solid-js"
|
|
3
|
+
import type { MotionElement, MotionOptions } from "../types"
|
|
4
|
+
import { createInView } from "./createInView"
|
|
5
|
+
import type { SetActive } from "./gesture-state"
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// createGestures — wires pointer-driven gestures (hover, press, focus, inView)
|
|
9
|
+
// to the gesture state machine's `setActive`. Phase 2 Commit 2 wires hover and
|
|
10
|
+
// press only; focus and inView land in Commit 3.
|
|
11
|
+
//
|
|
12
|
+
// Q1/C — hover and press are routed through motion-dom's primitives directly,
|
|
13
|
+
// not re-implemented. ADR 0001 documents the dependency.
|
|
14
|
+
//
|
|
15
|
+
// Q13/a — listeners attach unconditionally on mount and clean up on owner
|
|
16
|
+
// disposal. We do NOT re-attach when `opts.hover` / `opts.press` flip between
|
|
17
|
+
// defined and undefined: the state machine's per-key winners memo naturally
|
|
18
|
+
// produces an empty diff when an active state has no target, so the extra
|
|
19
|
+
// DOM listener pair costs nothing in practice.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bind pointer-event-driven gestures (hover, press) to the motion element.
|
|
24
|
+
* Toggles the state machine's `whileHover` / `whilePress` flags and forwards
|
|
25
|
+
* events to the user's `MotionCallbacks`.
|
|
26
|
+
*/
|
|
27
|
+
export function createGestures(
|
|
28
|
+
el: MotionElement,
|
|
29
|
+
getOpts: () => MotionOptions,
|
|
30
|
+
setActive: SetActive,
|
|
31
|
+
): void {
|
|
32
|
+
// ---------- Hover ----------
|
|
33
|
+
// motion-dom's hover(): start callback shape is `(element, event) => onEnd?`.
|
|
34
|
+
// We ignore `element` (always equal to `el` since we pass the element rather
|
|
35
|
+
// than a selector). The optional returned function fires on hover-end.
|
|
36
|
+
//
|
|
37
|
+
// Why motion-dom's hover() rather than addEventListener("pointerenter")?
|
|
38
|
+
// Subtle behaviors handled inside motion-dom we'd otherwise re-derive:
|
|
39
|
+
// - Press-in-progress defers hover-end until pointer-up (mobile UX).
|
|
40
|
+
// - Secondary pointer events filtered via isPrimaryPointer.
|
|
41
|
+
// - pointercancel cleanup parallel to pointerup.
|
|
42
|
+
const stopHover = hover(el, (_element, event) => {
|
|
43
|
+
setActive("whileHover", true)
|
|
44
|
+
getOpts().onHoverStart?.(event)
|
|
45
|
+
return (event) => {
|
|
46
|
+
setActive("whileHover", false)
|
|
47
|
+
getOpts().onHoverEnd?.(event)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
onCleanup(stopHover)
|
|
51
|
+
|
|
52
|
+
// ---------- Press ----------
|
|
53
|
+
// motion-dom's press(): same callback shape, plus the end callback receives
|
|
54
|
+
// `(event, { success: boolean })`. `success === true` when the pointer was
|
|
55
|
+
// still over the element at pointer-up (completed press); `false` when it
|
|
56
|
+
// moved away or was cancelled.
|
|
57
|
+
//
|
|
58
|
+
// Q13/c — branch on `info.success`:
|
|
59
|
+
// onPressStart fires at pointerdown (no success info yet — Q13 tightened
|
|
60
|
+
// the signature to drop the info param).
|
|
61
|
+
// onPress fires on completed press.
|
|
62
|
+
// onPressCancel fires on aborted press.
|
|
63
|
+
const stopPress = press(el, (_element, event) => {
|
|
64
|
+
setActive("whilePress", true)
|
|
65
|
+
getOpts().onPressStart?.(event)
|
|
66
|
+
return (event, info) => {
|
|
67
|
+
setActive("whilePress", false)
|
|
68
|
+
if (info.success) {
|
|
69
|
+
getOpts().onPress?.(event, info)
|
|
70
|
+
} else {
|
|
71
|
+
getOpts().onPressCancel?.(event, info)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
onCleanup(stopPress)
|
|
76
|
+
|
|
77
|
+
// ---------- Focus (Q12) ----------
|
|
78
|
+
// Activation (`whileFocus` state) is gated by `:focus-visible` — mouse
|
|
79
|
+
// clicks that incidentally focus an element should NOT trigger the visual
|
|
80
|
+
// state, only keyboard navigation does. The native `onFocus`/`onBlur`
|
|
81
|
+
// callbacks fire for every focus event regardless (Q12b — programmatic
|
|
82
|
+
// listeners shouldn't be filtered).
|
|
83
|
+
//
|
|
84
|
+
// The `:focus-visible` selector throws in older browsers — we fall back
|
|
85
|
+
// to always-active, matching motion/react's behavior in that scenario.
|
|
86
|
+
//
|
|
87
|
+
// We use motion-dom's `addDomEvent` (rather than el.addEventListener
|
|
88
|
+
// directly) for consistency with the rest of Phase 2's motion-dom usage,
|
|
89
|
+
// and because it returns a tidy cleanup function we can hand to onCleanup.
|
|
90
|
+
let focusActiveByVisible = false
|
|
91
|
+
const stopFocus = addDomEvent(el, "focus", (event) => {
|
|
92
|
+
let isFocusVisible = false
|
|
93
|
+
try {
|
|
94
|
+
isFocusVisible = el.matches(":focus-visible")
|
|
95
|
+
} catch {
|
|
96
|
+
isFocusVisible = true
|
|
97
|
+
}
|
|
98
|
+
if (isFocusVisible) {
|
|
99
|
+
setActive("whileFocus", true)
|
|
100
|
+
focusActiveByVisible = true
|
|
101
|
+
}
|
|
102
|
+
getOpts().onFocus?.(event as FocusEvent)
|
|
103
|
+
})
|
|
104
|
+
const stopBlur = addDomEvent(el, "blur", (event) => {
|
|
105
|
+
if (focusActiveByVisible) {
|
|
106
|
+
setActive("whileFocus", false)
|
|
107
|
+
focusActiveByVisible = false
|
|
108
|
+
}
|
|
109
|
+
getOpts().onBlur?.(event as FocusEvent)
|
|
110
|
+
})
|
|
111
|
+
onCleanup(stopFocus)
|
|
112
|
+
onCleanup(stopBlur)
|
|
113
|
+
|
|
114
|
+
// ---------- inView (Q10/A1) ----------
|
|
115
|
+
// The gesture reuses `createInView` — same observer setup. The `onChange`
|
|
116
|
+
// merges into the options object so the gesture's onViewportEnter/Leave
|
|
117
|
+
// hooks receive the raw entry.
|
|
118
|
+
//
|
|
119
|
+
// The element is wrapped in `() => el` because createInView takes a ref
|
|
120
|
+
// accessor; we pass a constant accessor since `el` is fixed for the
|
|
121
|
+
// gesture's lifetime. The options are function-form so reactive
|
|
122
|
+
// inViewOptions changes (e.g., a reactive `root` accessor) re-attach
|
|
123
|
+
// the observer naturally.
|
|
124
|
+
//
|
|
125
|
+
// A createEffect bridges createInView's `isInView` Accessor to the state
|
|
126
|
+
// machine's setActive. When `once: true`, createInView keeps isInView=true
|
|
127
|
+
// after first intersection (observer disconnected); the gesture's
|
|
128
|
+
// whileInView stays active forever — motion/react parity.
|
|
129
|
+
const view = createInView(
|
|
130
|
+
() => el,
|
|
131
|
+
() => ({
|
|
132
|
+
...getOpts().inViewOptions,
|
|
133
|
+
onChange: (entry: IntersectionObserverEntry) => {
|
|
134
|
+
if (entry.isIntersecting) {
|
|
135
|
+
getOpts().onViewportEnter?.(entry)
|
|
136
|
+
} else {
|
|
137
|
+
getOpts().onViewportLeave?.(entry)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
createEffect(() => {
|
|
143
|
+
setActive("whileInView", view.isInView())
|
|
144
|
+
})
|
|
145
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js"
|
|
2
|
+
import type { ViewportOptions } from "../types"
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// createInView — observe an element's intersection with a viewport.
|
|
6
|
+
//
|
|
7
|
+
// Standalone hook, distinct from the `inView` *gesture* on MotionOptions
|
|
8
|
+
// (which animates a target when in view). Returns a pair of Solid Accessors
|
|
9
|
+
// for the boolean and the raw IntersectionObserverEntry — booleans and
|
|
10
|
+
// objects aren't animate-able, so a MotionValueAccessor would only add weight
|
|
11
|
+
// (compare with createPan, where numeric fields ARE MVs because they're
|
|
12
|
+
// animate-able and composable).
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type CreateInViewOptions = ViewportOptions & {
|
|
16
|
+
/**
|
|
17
|
+
* Fires with the raw {@link IntersectionObserverEntry} on every
|
|
18
|
+
* visibility transition (enter AND leave). Convenience hook for callers
|
|
19
|
+
* who prefer event-driven access to the entry — the entry is also
|
|
20
|
+
* available reactively via the returned `view.entry()` accessor.
|
|
21
|
+
*/
|
|
22
|
+
onChange?: (entry: IntersectionObserverEntry) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Returned by {@link createInView}. Two Solid Accessors — call them to track. */
|
|
26
|
+
export type CreateInViewResult = {
|
|
27
|
+
/** Solid Accessor; `true` while the element intersects the viewport per the configured threshold. */
|
|
28
|
+
isInView: Accessor<boolean>
|
|
29
|
+
/** Solid Accessor; the most recent {@link IntersectionObserverEntry}, or `null` before any. */
|
|
30
|
+
entry: Accessor<IntersectionObserverEntry | null>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Observe an element via {@link IntersectionObserver} and expose its
|
|
35
|
+
* in-view state as a pair of Solid Accessors.
|
|
36
|
+
*
|
|
37
|
+
* Pass a ref-style accessor that returns the element. The observer
|
|
38
|
+
* attaches once the accessor returns a non-null element and re-attaches
|
|
39
|
+
* if it changes. The observer is disconnected on owner disposal.
|
|
40
|
+
*
|
|
41
|
+
* Options can be a static object or a function form (matching `useMotion`
|
|
42
|
+
* and `createPan`'s convention). The function form is tracked inside the
|
|
43
|
+
* effect — option changes (e.g., switching `root`) re-attach the observer.
|
|
44
|
+
*
|
|
45
|
+
* @example Static options
|
|
46
|
+
* const [el, setEl] = createSignal<HTMLElement>()
|
|
47
|
+
* const view = createInView(el, { once: true })
|
|
48
|
+
* createEffect(() => {
|
|
49
|
+
* if (view.isInView()) console.log("now in view")
|
|
50
|
+
* })
|
|
51
|
+
*
|
|
52
|
+
* @example Function-form options (reactive)
|
|
53
|
+
* const [root, setRoot] = createSignal<HTMLElement>()
|
|
54
|
+
* const view = createInView(el, () => ({ root, margin: "100px" }))
|
|
55
|
+
*
|
|
56
|
+
* @example Reading the raw entry reactively
|
|
57
|
+
* const view = createInView(el)
|
|
58
|
+
* createEffect(() => {
|
|
59
|
+
* const e = view.entry()
|
|
60
|
+
* if (e) console.log("ratio:", e.intersectionRatio)
|
|
61
|
+
* })
|
|
62
|
+
*
|
|
63
|
+
* <div ref={setEl}>watch me</div>
|
|
64
|
+
*/
|
|
65
|
+
export function createInView(
|
|
66
|
+
ref: () => Element | null | undefined,
|
|
67
|
+
options: CreateInViewOptions | (() => CreateInViewOptions) = {},
|
|
68
|
+
): CreateInViewResult {
|
|
69
|
+
const [isInView, setIsInView] = createSignal(false)
|
|
70
|
+
const [entry, setEntry] = createSignal<IntersectionObserverEntry | null>(null)
|
|
71
|
+
|
|
72
|
+
// createEffect — Solid-idiomatic for side-effect setup (attaching the
|
|
73
|
+
// IntersectionObserver). First iteration runs in the next microtask,
|
|
74
|
+
// which is harmless: a freshly-mounted element can't be in or out of
|
|
75
|
+
// the viewport before the microtask flushes. Option reads inside the
|
|
76
|
+
// effect's body are tracked — function-form options that read signals
|
|
77
|
+
// will re-run the effect (and re-attach the observer) on change.
|
|
78
|
+
createEffect(() => {
|
|
79
|
+
const el = ref()
|
|
80
|
+
if (!el) return
|
|
81
|
+
const opts = typeof options === "function" ? options() : options
|
|
82
|
+
|
|
83
|
+
const threshold = resolveThreshold(opts.amount)
|
|
84
|
+
const observer = new IntersectionObserver(
|
|
85
|
+
(entries) => {
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
// Fire onChange first so callers can synchronously inspect the
|
|
88
|
+
// entry before any downstream signal-effects see the new state.
|
|
89
|
+
opts.onChange?.(e)
|
|
90
|
+
// Update the entry signal either way — consumers reading
|
|
91
|
+
// `view.entry()` reactively see both enter and leave.
|
|
92
|
+
setEntry(e)
|
|
93
|
+
if (e.isIntersecting) {
|
|
94
|
+
setIsInView(true)
|
|
95
|
+
if (opts.once) observer.disconnect()
|
|
96
|
+
} else if (!opts.once) {
|
|
97
|
+
setIsInView(false)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
root: opts.root?.() ?? null,
|
|
103
|
+
rootMargin: opts.margin ?? "0px",
|
|
104
|
+
threshold,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
observer.observe(el)
|
|
108
|
+
|
|
109
|
+
onCleanup(() => observer.disconnect())
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
return { isInView, entry }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveThreshold(amount: ViewportOptions["amount"]): number | number[] {
|
|
116
|
+
// Pass arrays through unchanged so callers can request continuous
|
|
117
|
+
// `intersectionRatio` updates (the underlying IntersectionObserver fires
|
|
118
|
+
// once per threshold crossing, so a fine array → near-live ratio).
|
|
119
|
+
if (Array.isArray(amount)) return amount
|
|
120
|
+
if (typeof amount === "number") return amount
|
|
121
|
+
if (amount === "all") return 1
|
|
122
|
+
// "some" or undefined → minimal threshold (any pixel intersecting)
|
|
123
|
+
return 0
|
|
124
|
+
}
|