@rootnative/inertia 0.0.0-alpha.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 (113) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -0
  4. package/dist/gestureLayer/index.d.mts +119 -0
  5. package/dist/gestureLayer/index.d.ts +119 -0
  6. package/dist/gestureLayer/index.js +346 -0
  7. package/dist/gestureLayer/index.js.map +1 -0
  8. package/dist/gestureLayer/index.mjs +344 -0
  9. package/dist/gestureLayer/index.mjs.map +1 -0
  10. package/dist/index.d.mts +507 -0
  11. package/dist/index.d.ts +507 -0
  12. package/dist/index.js +1546 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/index.mjs +1515 -0
  15. package/dist/index.mjs.map +1 -0
  16. package/dist/motion/Image.d.mts +12 -0
  17. package/dist/motion/Image.d.ts +12 -0
  18. package/dist/motion/Image.js +1138 -0
  19. package/dist/motion/Image.js.map +1 -0
  20. package/dist/motion/Image.mjs +1132 -0
  21. package/dist/motion/Image.mjs.map +1 -0
  22. package/dist/motion/Pressable.d.mts +15 -0
  23. package/dist/motion/Pressable.d.ts +15 -0
  24. package/dist/motion/Pressable.js +1138 -0
  25. package/dist/motion/Pressable.js.map +1 -0
  26. package/dist/motion/Pressable.mjs +1132 -0
  27. package/dist/motion/Pressable.mjs.map +1 -0
  28. package/dist/motion/ScrollView.d.mts +12 -0
  29. package/dist/motion/ScrollView.d.ts +12 -0
  30. package/dist/motion/ScrollView.js +1138 -0
  31. package/dist/motion/ScrollView.js.map +1 -0
  32. package/dist/motion/ScrollView.mjs +1132 -0
  33. package/dist/motion/ScrollView.mjs.map +1 -0
  34. package/dist/motion/Text.d.mts +11 -0
  35. package/dist/motion/Text.d.ts +11 -0
  36. package/dist/motion/Text.js +1138 -0
  37. package/dist/motion/Text.js.map +1 -0
  38. package/dist/motion/Text.mjs +1132 -0
  39. package/dist/motion/Text.mjs.map +1 -0
  40. package/dist/motion/View.d.mts +11 -0
  41. package/dist/motion/View.d.ts +11 -0
  42. package/dist/motion/View.js +1138 -0
  43. package/dist/motion/View.js.map +1 -0
  44. package/dist/motion/View.mjs +1132 -0
  45. package/dist/motion/View.mjs.map +1 -0
  46. package/dist/testing/index.d.mts +57 -0
  47. package/dist/testing/index.d.ts +57 -0
  48. package/dist/testing/index.js +19 -0
  49. package/dist/testing/index.js.map +1 -0
  50. package/dist/testing/index.mjs +16 -0
  51. package/dist/testing/index.mjs.map +1 -0
  52. package/dist/touch/index.d.mts +146 -0
  53. package/dist/touch/index.d.ts +146 -0
  54. package/dist/touch/index.js +166 -0
  55. package/dist/touch/index.js.map +1 -0
  56. package/dist/touch/index.mjs +164 -0
  57. package/dist/touch/index.mjs.map +1 -0
  58. package/dist/types-cU43dEmH.d.mts +339 -0
  59. package/dist/types-cU43dEmH.d.ts +339 -0
  60. package/dist/useGesture-BnY65PlQ.d.ts +84 -0
  61. package/dist/useGesture-DxtXdz-K.d.mts +84 -0
  62. package/jest-preset.js +33 -0
  63. package/jest-setup.js +213 -0
  64. package/llms.txt +142 -0
  65. package/package.json +151 -0
  66. package/src/__type-tests__/animate.test-d.tsx +88 -0
  67. package/src/__type-tests__/variants.test-d.tsx +67 -0
  68. package/src/config/MotionConfig.tsx +30 -0
  69. package/src/config/MotionConfigContext.ts +53 -0
  70. package/src/config/index.ts +9 -0
  71. package/src/gestureLayer/index.ts +21 -0
  72. package/src/gestureLayer/useGestureLayer.ts +285 -0
  73. package/src/gestures/focusVisibility.ts +61 -0
  74. package/src/gestures/index.ts +1 -0
  75. package/src/index.ts +72 -0
  76. package/src/layout/index.ts +16 -0
  77. package/src/layout/resolveLayout.ts +54 -0
  78. package/src/layout/sharedRegistry.ts +111 -0
  79. package/src/layout/useSharedLayout.ts +289 -0
  80. package/src/motion/Image.tsx +9 -0
  81. package/src/motion/Pressable.tsx +12 -0
  82. package/src/motion/ScrollView.tsx +9 -0
  83. package/src/motion/Text.tsx +8 -0
  84. package/src/motion/View.tsx +8 -0
  85. package/src/motion/createMotionComponent.tsx +1306 -0
  86. package/src/motion/index.ts +26 -0
  87. package/src/motion/installCheck.ts +65 -0
  88. package/src/presence/Presence.tsx +165 -0
  89. package/src/presence/PresenceContext.ts +28 -0
  90. package/src/presence/index.ts +6 -0
  91. package/src/testing/index.ts +78 -0
  92. package/src/touch/index.ts +18 -0
  93. package/src/touch/useTouchDrag.ts +289 -0
  94. package/src/transitions/easing.ts +53 -0
  95. package/src/transitions/index.ts +7 -0
  96. package/src/transitions/keys.ts +32 -0
  97. package/src/transitions/resolve.ts +169 -0
  98. package/src/transitions/resolveSequence.ts +87 -0
  99. package/src/transitions/runtime.ts +63 -0
  100. package/src/transitions/sig.ts +40 -0
  101. package/src/transitions/spring.ts +42 -0
  102. package/src/types.ts +383 -0
  103. package/src/values/index.ts +26 -0
  104. package/src/values/useAnimation.ts +69 -0
  105. package/src/values/useBooleanSpring.ts +33 -0
  106. package/src/values/useColorTransition.ts +72 -0
  107. package/src/values/useGesture.ts +144 -0
  108. package/src/values/useMotionValue.ts +33 -0
  109. package/src/values/useScroll.ts +72 -0
  110. package/src/values/useShadow.ts +116 -0
  111. package/src/values/useSpring.ts +98 -0
  112. package/src/values/useTransform.ts +132 -0
  113. package/src/values/useVariants.ts +60 -0
@@ -0,0 +1,26 @@
1
+ import { MotionImage } from './Image'
2
+ import { MotionPressable } from './Pressable'
3
+ import { MotionScrollView } from './ScrollView'
4
+ import { MotionText } from './Text'
5
+ import { MotionView } from './View'
6
+
7
+ export { createMotionComponent } from './createMotionComponent'
8
+ export {
9
+ MotionView,
10
+ MotionText,
11
+ MotionImage,
12
+ MotionPressable,
13
+ MotionScrollView,
14
+ }
15
+
16
+ /**
17
+ * The `Motion.*` namespace. Each property is a primitive with its style prop
18
+ * inferred from the underlying RN component. There is no shared style fallback.
19
+ */
20
+ export const Motion = {
21
+ View: MotionView,
22
+ Text: MotionText,
23
+ Image: MotionImage,
24
+ Pressable: MotionPressable,
25
+ ScrollView: MotionScrollView,
26
+ } as const
@@ -0,0 +1,65 @@
1
+ import { reanimatedVersion } from 'react-native-reanimated'
2
+
3
+ declare const __DEV__: boolean
4
+ declare const process: { env?: Record<string, string | undefined> }
5
+
6
+ let alreadyChecked = false
7
+
8
+ /**
9
+ * Surface a clear, actionable error at first `createMotionComponent` call when
10
+ * the consumer's Reanimated install is broken. Production builds, repeat calls,
11
+ * and Jest test runs are all skipped — the check is purely a dev-time
12
+ * paper-cut sander for the two failure modes we can detect from JS:
13
+ *
14
+ * 1. `react-native-reanimated` resolves but is on a v3.x line we don't
15
+ * support (the plugin name and worklet runtime both changed at v4).
16
+ * 2. The worklets babel plugin (`react-native-worklets/plugin` in v4) isn't
17
+ * wired into `babel.config.js`, so `'worklet'` directives are dead strings
18
+ * and the first `withSpring` / `withTiming` call would crash on the UI
19
+ * thread with a generic "non-worklet function called" error.
20
+ *
21
+ * The "Reanimated isn't installed at all" case isn't handled here — Metro
22
+ * fails to resolve the static `import 'react-native-reanimated'` at the top
23
+ * of `createMotionComponent.tsx` long before this check runs.
24
+ */
25
+ export function ensureReanimatedInstalled(): void {
26
+ if (!__DEV__ || alreadyChecked) return
27
+ // The standard `react-native-reanimated/mock` doesn't run the worklets
28
+ // babel plugin, so the marker probe would false-positive every test run.
29
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') {
30
+ return
31
+ }
32
+ alreadyChecked = true
33
+
34
+ // Read the version off Reanimated's own runtime export rather than reaching
35
+ // into its `package.json`. A `require('.../package.json')` here would make
36
+ // esbuild emit a `__require` shim that throws on web bundlers (Expo web), and
37
+ // Reanimated's `exports` field may block the subpath anyway.
38
+ const version: string | undefined = reanimatedVersion
39
+
40
+ if (version) {
41
+ const major = parseInt(version.split('.')[0] ?? '0', 10)
42
+ if (major < 4) {
43
+ console.error(
44
+ `[inertia] react-native-reanimated v${version} is installed, but @rootnative/inertia requires v4.0.0 or later. ` +
45
+ `Upgrade with \`pnpm add react-native-reanimated@^4\` (or your package manager's equivalent).`,
46
+ )
47
+ return
48
+ }
49
+ }
50
+
51
+ // The worklets plugin rewrites any function carrying a top-of-body
52
+ // `'worklet'` directive to expose a `__workletHash` property at runtime.
53
+ // Its absence means the plugin didn't run.
54
+ const probe = function probe() {
55
+ 'worklet'
56
+ return 0
57
+ } as { __workletHash?: number }
58
+ if (typeof probe.__workletHash !== 'number') {
59
+ console.error(
60
+ `[inertia] The Reanimated worklets babel plugin is not configured. ` +
61
+ `Add \`'react-native-worklets/plugin'\` as the LAST entry in the \`plugins\` array of your \`babel.config.js\`, ` +
62
+ `then restart Metro with a fresh cache: \`npx expo start -c\` or \`npx react-native start --reset-cache\`.`,
63
+ )
64
+ }
65
+ }
@@ -0,0 +1,165 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ type Key,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ useCallback,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import { PresenceContext, type PresenceContextValue } from './PresenceContext'
13
+
14
+ interface RenderEntry {
15
+ key: Key
16
+ element: ReactElement
17
+ isPresent: boolean
18
+ }
19
+
20
+ /**
21
+ * Wrap a list of children with mount / unmount transitions. When a child is
22
+ * removed from the incoming list it stays in the snapshot until its exit
23
+ * animation completes; descendants consume the per-child `<PresenceContext>`
24
+ * to coordinate.
25
+ *
26
+ * Children must be `<Motion.*>` primitives (or any component that consumes
27
+ * `usePresence()` and calls `safeToRemove`). Plain elements without that
28
+ * contract will linger in the snapshot once removed; document that and pick
29
+ * the right primitive.
30
+ *
31
+ * Children also need explicit `key`s so removal is detectable across
32
+ * renders. Without a key, React falls back to positional identity and
33
+ * removal looks like a prop change — Presence has nothing to mark exiting.
34
+ */
35
+ export function Presence({ children }: { children: ReactNode }) {
36
+ const incoming = useMemo(() => {
37
+ const out: ReactElement[] = []
38
+ Children.forEach(children, (child) => {
39
+ if (!isValidElement(child)) return
40
+ if (child.key === null) {
41
+ if (__DEV__) {
42
+ console.warn(
43
+ '[inertia] <Presence> children must have a `key`. Skipping a keyless child.',
44
+ )
45
+ }
46
+ return
47
+ }
48
+ out.push(child)
49
+ })
50
+ return out
51
+ }, [children])
52
+
53
+ // Snapshot of elements removed from `incoming` whose exit animation is
54
+ // still in flight. setExiting is called synchronously during render below
55
+ // (the documented pattern for derived-from-prop-change state), so React
56
+ // re-renders with the new snapshot before committing — no visual frame
57
+ // where the departing child has vanished.
58
+ const [exiting, setExiting] = useState<Map<Key, ReactElement>>(
59
+ () => new Map(),
60
+ )
61
+
62
+ // Tracks the previous render's `incoming` so we can diff. Updated
63
+ // synchronously alongside the setState call.
64
+ const prevIncomingRef = useRef<ReactElement[]>(incoming)
65
+
66
+ if (prevIncomingRef.current !== incoming) {
67
+ const prev = prevIncomingRef.current
68
+ prevIncomingRef.current = incoming
69
+ const incomingKeys = new Set(incoming.map((el) => el.key as Key))
70
+ let next: Map<Key, ReactElement> | null = null
71
+ const ensureMutable = () => {
72
+ if (!next) next = new Map(exiting)
73
+ return next
74
+ }
75
+
76
+ // Departures: in prev but not in current → snapshot for exit.
77
+ for (const oldEl of prev) {
78
+ const key = oldEl.key as Key
79
+ if (!incomingKeys.has(key) && !exiting.has(key)) {
80
+ ensureMutable().set(key, oldEl)
81
+ }
82
+ }
83
+ // Returns: was exiting and reappears → drop the snapshot. The live
84
+ // `incoming` entry takes over with the same key, so React reconciles
85
+ // the underlying Motion instance and the in-flight exit animation
86
+ // interrupts back toward `animate` values.
87
+ for (const el of incoming) {
88
+ const key = el.key as Key
89
+ if (exiting.has(key)) {
90
+ ensureMutable().delete(key)
91
+ }
92
+ }
93
+
94
+ if (next) setExiting(next)
95
+ }
96
+
97
+ const handleRemove = useCallback((key: Key) => {
98
+ setExiting((prev) => {
99
+ if (!prev.has(key)) return prev
100
+ const next = new Map(prev)
101
+ next.delete(key)
102
+ return next
103
+ })
104
+ }, [])
105
+
106
+ // Single combined render list. Putting `incoming` and `exiting` entries in
107
+ // one array (rather than two `.map` calls inside a fragment) ensures React
108
+ // reconciles by `key` across positions — when an entry moves from
109
+ // present-list to exiting-list, the component instance persists.
110
+ const renderList: RenderEntry[] = []
111
+ for (const el of incoming) {
112
+ renderList.push({
113
+ key: el.key as Key,
114
+ element: el,
115
+ isPresent: true,
116
+ })
117
+ }
118
+ for (const [key, el] of exiting) {
119
+ if (!renderList.some((entry) => entry.key === key)) {
120
+ renderList.push({ key, element: el, isPresent: false })
121
+ }
122
+ }
123
+
124
+ return (
125
+ <>
126
+ {renderList.map(({ key, element, isPresent }) => (
127
+ <PresenceItem
128
+ key={key}
129
+ itemKey={key}
130
+ isPresent={isPresent}
131
+ onRemove={handleRemove}
132
+ >
133
+ {element}
134
+ </PresenceItem>
135
+ ))}
136
+ </>
137
+ )
138
+ }
139
+
140
+ function PresenceItem({
141
+ itemKey,
142
+ isPresent,
143
+ onRemove,
144
+ children,
145
+ }: {
146
+ itemKey: Key
147
+ isPresent: boolean
148
+ onRemove: (key: Key) => void
149
+ children: ReactNode
150
+ }) {
151
+ const value = useMemo<PresenceContextValue>(
152
+ () => ({
153
+ isPresent,
154
+ safeToRemove: () => onRemove(itemKey),
155
+ }),
156
+ [isPresent, itemKey, onRemove],
157
+ )
158
+ return (
159
+ <PresenceContext.Provider value={value}>
160
+ {children}
161
+ </PresenceContext.Provider>
162
+ )
163
+ }
164
+
165
+ declare const __DEV__: boolean
@@ -0,0 +1,28 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ /**
4
+ * Per-child contract between `<Presence>` and its descendant Motion
5
+ * primitives. `<Presence>` provides a fresh value to each rendered child;
6
+ * Motion primitives consume it to gate exit animations.
7
+ *
8
+ * - `isPresent`: `true` while the child is in the incoming children list.
9
+ * Flips to `false` when the parent removes it; the child remains rendered
10
+ * until `safeToRemove` is called.
11
+ * - `safeToRemove`: callback the child invokes when its exit animation has
12
+ * settled. `<Presence>` then drops the snapshot entry and unmounts.
13
+ */
14
+ export interface PresenceContextValue {
15
+ isPresent: boolean
16
+ safeToRemove: () => void
17
+ }
18
+
19
+ export const PresenceContext = createContext<PresenceContextValue | null>(null)
20
+
21
+ /**
22
+ * Read the surrounding `<Presence>` contract from a child component. Returns
23
+ * `null` when there is no `<Presence>` ancestor — useful for components that
24
+ * want to support both standalone and Presence-wrapped use without branching.
25
+ */
26
+ export function usePresence(): PresenceContextValue | null {
27
+ return useContext(PresenceContext)
28
+ }
@@ -0,0 +1,6 @@
1
+ export { Presence } from './Presence'
2
+ export {
3
+ PresenceContext,
4
+ usePresence,
5
+ type PresenceContextValue,
6
+ } from './PresenceContext'
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Test helpers for Inertia consumers.
3
+ *
4
+ * The Reanimated Jest mock that ships with the library is **static-render**:
5
+ * `useAnimatedStyle` runs the worklet exactly once per call, and shared
6
+ * values are plain `{ value }` refs. After the animation effect fires
7
+ * (`sv.value = withSpring(target) → target` under the mock), the rendered
8
+ * style has already been captured at the at-rest shared-value snapshot —
9
+ * so without intervention, every Inertia component looks frozen at its
10
+ * `initial` values in tests.
11
+ *
12
+ * `renderWithMotion` papers over that by forcing a second render after the
13
+ * first effect pass. The shared values now hold their target values, so
14
+ * `useAnimatedStyle` re-evaluates against the post-animation state and the
15
+ * rendered styles match what a real device would settle on.
16
+ *
17
+ * Use this from `@rootnative/inertia/testing`:
18
+ *
19
+ * ```ts
20
+ * import { renderWithMotion } from '@rootnative/inertia/testing'
21
+ *
22
+ * const { getByTestId } = renderWithMotion(
23
+ * <Motion.View testID="card" initial={{ opacity: 0 }} animate={{ opacity: 1 }} />,
24
+ * )
25
+ * // getByTestId('card') has opacity: 1, not 0.
26
+ * ```
27
+ *
28
+ * For tests that need to re-render with new props and re-flush, use
29
+ * `flushMotion(result, nextUi)`.
30
+ */
31
+ import { render, type RenderOptions } from '@testing-library/react-native'
32
+ import { cloneElement, type ReactElement } from 'react'
33
+
34
+ type RenderResult = ReturnType<typeof render>
35
+
36
+ /**
37
+ * Render a Motion subtree and immediately flush animations to their target
38
+ * values. Returns the standard `@testing-library/react-native` render result.
39
+ *
40
+ * Internally this calls `render(...)`, then re-renders the same element
41
+ * inside `act(...)` so the post-effect shared-value updates flow into the
42
+ * `useAnimatedStyle` re-run. Both passes happen synchronously — the call
43
+ * returns once styles reflect the `animate` target.
44
+ */
45
+ export function renderWithMotion(
46
+ ui: ReactElement,
47
+ options?: RenderOptions,
48
+ ): RenderResult {
49
+ const result = render(ui, options)
50
+ // `cloneElement` produces a fresh element with the same props so React
51
+ // doesn't bail out of the second render via reference-equal short-circuit
52
+ // — that's what causes `useAnimatedStyle` to skip its post-effect re-read
53
+ // when the same element is passed to `rerender`.
54
+ flushMotion(result, cloneElement(ui))
55
+ return result
56
+ }
57
+
58
+ /**
59
+ * Re-render a previously-mounted Motion subtree to flush pending animations
60
+ * to their target values. Pass the same element you originally rendered
61
+ * (or a new one for tests that update props between flushes).
62
+ *
63
+ * The flush is synchronous. `@testing-library/react-native`'s `rerender`
64
+ * already wraps in `act` internally, so no explicit `act(...)` is needed
65
+ * here.
66
+ */
67
+ export function flushMotion(rendered: RenderResult, ui: ReactElement): void {
68
+ // One re-render is enough for non-sequence animations: the mount-effect
69
+ // has run, shared values now hold their target values, and the next
70
+ // `useAnimatedStyle` invocation will read them. Sequence steps chain
71
+ // through `withSpring` / `withTiming` settle callbacks — those are still
72
+ // captured for tests that invoke them manually (see `onAnimationEnd.test`).
73
+ //
74
+ // `cloneElement` defeats React's reference-equal element bail-out so the
75
+ // second render actually reaches `useAnimatedStyle` instead of being
76
+ // short-circuited by the reconciler.
77
+ rendered.rerender(cloneElement(ui))
78
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * PanResponder-backed drag hook. Lives in core because `PanResponder` is
3
+ * built into React Native — no extra peer dependency. Use this when you
4
+ * need keyboard a11y alongside drag, or when you don't want to take
5
+ * `react-native-gesture-handler` as a dependency.
6
+ *
7
+ * For pointer-only drag in a project that already uses gesture-handler,
8
+ * prefer `useDrag` from `@rootnative/inertia-gestures` — its UI-thread
9
+ * release path is more precise.
10
+ */
11
+ export { useTouchDrag } from './useTouchDrag'
12
+ export type {
13
+ TouchReleaseInfo,
14
+ TouchReleaseResult,
15
+ TouchReleaseTransition,
16
+ UseTouchDragOptions,
17
+ UseTouchDragResult,
18
+ } from './useTouchDrag'
@@ -0,0 +1,289 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ PanResponder,
4
+ type PanResponderGestureState,
5
+ type PanResponderInstance,
6
+ } from 'react-native'
7
+ import {
8
+ useAnimatedStyle,
9
+ useSharedValue,
10
+ type SharedValue,
11
+ } from 'react-native-reanimated'
12
+ import { buildReleaseAnimation } from '../transitions'
13
+ import type { TransitionConfig } from '../types'
14
+
15
+ /**
16
+ * Same drag-result shape as `useDrag` from `@rootnative/inertia-gestures`,
17
+ * minus the `gesture` field (PanResponder spreads handlers, no
18
+ * `<GestureDetector>` wrapper). The shared values + animatedStyle are
19
+ * interchangeable across both hooks; consumers can swap implementations
20
+ * without touching their `useAnimatedStyle` consumers.
21
+ */
22
+ export interface UseTouchDragResult {
23
+ /** Spread onto a `View` / `Pressable` to install the pan responder. */
24
+ panHandlers: PanResponderInstance['panHandlers']
25
+ /** Stable animated `transform` style. */
26
+ animatedStyle: ReturnType<typeof useAnimatedStyle>
27
+ /** Live x translation, persistent across gestures. */
28
+ dragX: SharedValue<number>
29
+ /** Live y translation, persistent across gestures. */
30
+ dragY: SharedValue<number>
31
+ /** True while the gesture is active. */
32
+ isDragging: SharedValue<boolean>
33
+ }
34
+
35
+ /**
36
+ * Release transition shape for PanResponder's JS-thread `onRelease`. Mirrors
37
+ * the gesture-handler adapter's `ReleaseTransition` but with `to` typed as
38
+ * required for spring/timing/no-animation (decay omits it).
39
+ */
40
+ export type TouchReleaseTransition =
41
+ | (TransitionConfig & { type: 'spring'; to: number })
42
+ | (TransitionConfig & { type: 'timing'; to: number })
43
+ | (TransitionConfig & { type: 'decay' })
44
+ | (TransitionConfig & { type: 'no-animation'; to: number })
45
+
46
+ export interface TouchReleaseInfo {
47
+ x: number
48
+ y: number
49
+ velocity: { x: number; y: number }
50
+ }
51
+
52
+ export interface TouchReleaseResult {
53
+ x?: TouchReleaseTransition
54
+ y?: TouchReleaseTransition
55
+ }
56
+
57
+ export interface UseTouchDragOptions {
58
+ /**
59
+ * Restrict the drag to one axis. Defaults to `'both'`. When `'x'` is set
60
+ * the y-axis shared value never updates (and vice versa); velocity is
61
+ * still reported on both for `onDragEnd`.
62
+ */
63
+ axis?: 'x' | 'y' | 'both'
64
+ /**
65
+ * Travel bounds (px from resting). Each side is independently optional.
66
+ * Out-of-bounds values clamp to the limit unless `elastic > 0`.
67
+ */
68
+ constraints?: {
69
+ left?: number
70
+ right?: number
71
+ top?: number
72
+ bottom?: number
73
+ }
74
+ /**
75
+ * Rubber-band coefficient applied to overshoot past `constraints`. `0`
76
+ * (default) hard-clamps; `0.2`-`0.4` is a typical Framer-Motion feel.
77
+ */
78
+ elastic?: number
79
+ /**
80
+ * Fires when the user starts dragging. JS thread.
81
+ */
82
+ onDragStart?: () => void
83
+ /**
84
+ * Fires when the user releases or the gesture terminates. JS thread.
85
+ *
86
+ * Velocity is in px/sec to match the `@rootnative/inertia-gestures` API
87
+ * (PanResponder's native `vx` / `vy` are px/ms; the hook normalizes).
88
+ */
89
+ onDragEnd?: (info: TouchReleaseInfo) => void
90
+ /**
91
+ * Optional release-animation callback. Return per-axis release transitions
92
+ * to animate the SVs to a settled position via Inertia's transition
93
+ * resolver — spring snap-to-tick, decay with bounds, timing settle.
94
+ *
95
+ * Unlike the gesture-handler version, this callback runs on the **JS
96
+ * thread** (PanResponder is JS-only). The returned transitions still drive
97
+ * UI-thread animations via Reanimated — only the decision logic is JS-side.
98
+ */
99
+ onRelease?: (info: TouchReleaseInfo) => TouchReleaseResult | void
100
+ }
101
+
102
+ /**
103
+ * PanResponder-backed drag hook. Pointer-equivalent of `useDrag` from
104
+ * `@rootnative/inertia-gestures`, with two differences:
105
+ *
106
+ * 1. No `react-native-gesture-handler` peer dep required — PanResponder is
107
+ * built into React Native, so this lives in core.
108
+ * 2. Returns `panHandlers` to spread on a `View` / `Pressable` instead of
109
+ * a `gesture` to plug into `<GestureDetector>`.
110
+ *
111
+ * Use this when:
112
+ * - You need keyboard a11y alongside drag (a slider with arrow-key step,
113
+ * a scrollbar with `PageUp` / `PageDown`). PanResponder composes
114
+ * cleanly with `onKeyDown`; gesture-handler doesn't surface keyboard.
115
+ * - You don't want to take `react-native-gesture-handler` as a dependency
116
+ * (smaller bundle, simpler install).
117
+ *
118
+ * Skip this when:
119
+ * - You're already using `react-native-gesture-handler` elsewhere (use
120
+ * `useDrag` from `@rootnative/inertia-gestures` for consistency and
121
+ * better worklet-thread fidelity on release velocity).
122
+ * - You need momentum semantics like the gesture-handler `usePan` —
123
+ * PanResponder's release velocity is JS-thread and slightly less precise.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * import { useTouchDrag } from '@rootnative/inertia/touch'
128
+ *
129
+ * function Slider({ ticks }: { ticks: number[] }) {
130
+ * const drag = useTouchDrag({
131
+ * axis: 'x',
132
+ * constraints: { left: 0, right: 280 },
133
+ * onRelease: (e) => {
134
+ * const snap = nearestTick(e.x, ticks)
135
+ * return { x: { type: 'spring', to: snap, velocity: e.velocity.x } }
136
+ * },
137
+ * })
138
+ *
139
+ * return (
140
+ * <Motion.View
141
+ * style={[styles.thumb, drag.animatedStyle]}
142
+ * {...drag.panHandlers}
143
+ * />
144
+ * )
145
+ * }
146
+ * ```
147
+ */
148
+ export function useTouchDrag(
149
+ options: UseTouchDragOptions = {},
150
+ ): UseTouchDragResult {
151
+ const { axis = 'both', constraints, elastic = 0 } = options
152
+
153
+ const dragX = useSharedValue(0)
154
+ const dragY = useSharedValue(0)
155
+ const startX = useSharedValue(0)
156
+ const startY = useSharedValue(0)
157
+ const isDragging = useSharedValue(false)
158
+
159
+ // Snapshot scalars into local consts so the responder callbacks close over
160
+ // primitives, not the `options` literal — a fresh `options` each render
161
+ // would otherwise force the PanResponder identity to change.
162
+ const lockX = axis !== 'y'
163
+ const lockY = axis !== 'x'
164
+ const left = constraints?.left
165
+ const right = constraints?.right
166
+ const top = constraints?.top
167
+ const bottom = constraints?.bottom
168
+ const elasticCoef = elastic
169
+ const { onDragStart, onDragEnd, onRelease } = options
170
+
171
+ const responder = useMemo(
172
+ () => buildResponder(),
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ [
175
+ lockX,
176
+ lockY,
177
+ left,
178
+ right,
179
+ top,
180
+ bottom,
181
+ elasticCoef,
182
+ onDragStart,
183
+ onDragEnd,
184
+ onRelease,
185
+ ],
186
+ )
187
+
188
+ // Hoisted out of the inline `useMemo` factory to keep the dep list readable
189
+ // and avoid re-declaring closure helpers each render.
190
+ function buildResponder(): PanResponderInstance {
191
+ const handleEnd = (g: PanResponderGestureState) => {
192
+ isDragging.value = false
193
+ const x = dragX.value
194
+ const y = dragY.value
195
+ // PanResponder velocity is px/ms; multiply to match the
196
+ // `@rootnative/inertia-gestures` API (px/sec from gesture-handler).
197
+ const vx = g.vx * 1000
198
+ const vy = g.vy * 1000
199
+ if (onRelease) {
200
+ const result = onRelease({ x, y, velocity: { x: vx, y: vy } })
201
+ if (result) {
202
+ if (result.x && lockX) {
203
+ const toX = 'to' in result.x ? result.x.to : x
204
+ dragX.value = buildReleaseAnimation(
205
+ result.x,
206
+ toX,
207
+ ) as unknown as number
208
+ }
209
+ if (result.y && lockY) {
210
+ const toY = 'to' in result.y ? result.y.to : y
211
+ dragY.value = buildReleaseAnimation(
212
+ result.y,
213
+ toY,
214
+ ) as unknown as number
215
+ }
216
+ }
217
+ }
218
+ if (onDragEnd) onDragEnd({ x, y, velocity: { x: vx, y: vy } })
219
+ }
220
+
221
+ return PanResponder.create({
222
+ // Always claim the start so taps that turn into drags don't slip
223
+ // through to a parent ScrollView. Consumers can compose their own
224
+ // capture predicates by wrapping the returned `panHandlers`.
225
+ onStartShouldSetPanResponder: () => true,
226
+ onMoveShouldSetPanResponder: () => true,
227
+ onPanResponderGrant: () => {
228
+ startX.value = dragX.value
229
+ startY.value = dragY.value
230
+ isDragging.value = true
231
+ if (onDragStart) onDragStart()
232
+ },
233
+ onPanResponderMove: (_e, g) => {
234
+ if (lockX) {
235
+ dragX.value = applyBounds(
236
+ startX.value + g.dx,
237
+ left,
238
+ right,
239
+ elasticCoef,
240
+ )
241
+ }
242
+ if (lockY) {
243
+ dragY.value = applyBounds(
244
+ startY.value + g.dy,
245
+ top,
246
+ bottom,
247
+ elasticCoef,
248
+ )
249
+ }
250
+ },
251
+ onPanResponderRelease: (_e, g) => handleEnd(g),
252
+ onPanResponderTerminate: (_e, g) => handleEnd(g),
253
+ })
254
+ }
255
+
256
+ const animatedStyle = useAnimatedStyle(() => ({
257
+ transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
258
+ }))
259
+
260
+ return {
261
+ panHandlers: responder.panHandlers,
262
+ animatedStyle,
263
+ dragX,
264
+ dragY,
265
+ isDragging,
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot past a
271
+ * bound is scaled by `elastic`, giving a rubber-band feel. `min` / `max`
272
+ * may be `undefined` to leave that side unbounded.
273
+ *
274
+ * JS-thread (PanResponder callbacks are JS, not worklets).
275
+ */
276
+ function applyBounds(
277
+ value: number,
278
+ min: number | undefined,
279
+ max: number | undefined,
280
+ elastic: number,
281
+ ): number {
282
+ if (min !== undefined && value < min) {
283
+ return elastic > 0 ? min + (value - min) * elastic : min
284
+ }
285
+ if (max !== undefined && value > max) {
286
+ return elastic > 0 ? max + (value - max) * elastic : max
287
+ }
288
+ return value
289
+ }