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,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
+ }