@onlynative/inertia 0.0.1-alpha.0 → 0.0.1-alpha.1

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 (51) hide show
  1. package/README.md +6 -6
  2. package/dist/index.d.mts +2 -2
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +45 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +46 -5
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/motion/Image.d.mts +1 -1
  9. package/dist/motion/Image.d.ts +1 -1
  10. package/dist/motion/Image.js +45 -4
  11. package/dist/motion/Image.js.map +1 -1
  12. package/dist/motion/Image.mjs +46 -5
  13. package/dist/motion/Image.mjs.map +1 -1
  14. package/dist/motion/Pressable.d.mts +1 -1
  15. package/dist/motion/Pressable.d.ts +1 -1
  16. package/dist/motion/Pressable.js +45 -4
  17. package/dist/motion/Pressable.js.map +1 -1
  18. package/dist/motion/Pressable.mjs +46 -5
  19. package/dist/motion/Pressable.mjs.map +1 -1
  20. package/dist/motion/ScrollView.d.mts +1 -1
  21. package/dist/motion/ScrollView.d.ts +1 -1
  22. package/dist/motion/ScrollView.js +45 -4
  23. package/dist/motion/ScrollView.js.map +1 -1
  24. package/dist/motion/ScrollView.mjs +46 -5
  25. package/dist/motion/ScrollView.mjs.map +1 -1
  26. package/dist/motion/Text.d.mts +1 -1
  27. package/dist/motion/Text.d.ts +1 -1
  28. package/dist/motion/Text.js +45 -4
  29. package/dist/motion/Text.js.map +1 -1
  30. package/dist/motion/Text.mjs +46 -5
  31. package/dist/motion/Text.mjs.map +1 -1
  32. package/dist/motion/View.d.mts +1 -1
  33. package/dist/motion/View.d.ts +1 -1
  34. package/dist/motion/View.js +45 -4
  35. package/dist/motion/View.js.map +1 -1
  36. package/dist/motion/View.mjs +46 -5
  37. package/dist/motion/View.mjs.map +1 -1
  38. package/dist/testing/index.d.mts +57 -0
  39. package/dist/testing/index.d.ts +57 -0
  40. package/dist/testing/index.js +19 -0
  41. package/dist/testing/index.js.map +1 -0
  42. package/dist/testing/index.mjs +16 -0
  43. package/dist/testing/index.mjs.map +1 -0
  44. package/dist/{types-CmbXx-G3.d.mts → types-DeZZzE_e.d.mts} +20 -3
  45. package/dist/{types-CmbXx-G3.d.ts → types-DeZZzE_e.d.ts} +20 -3
  46. package/package.json +19 -12
  47. package/src/gestures/focusVisibility.ts +61 -0
  48. package/src/gestures/index.ts +1 -0
  49. package/src/motion/createMotionComponent.tsx +39 -6
  50. package/src/testing/index.ts +78 -0
  51. package/src/types.ts +20 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlynative/inertia",
3
- "version": "0.0.1-alpha.0",
3
+ "version": "0.0.1-alpha.1",
4
4
  "description": "Declarative animation primitives for React Native, built on react-native-reanimated.",
5
5
  "license": "MIT",
6
6
  "author": "OnlyNative",
@@ -72,6 +72,13 @@
72
72
  "import": "./dist/motion/ScrollView.mjs",
73
73
  "require": "./dist/motion/ScrollView.js"
74
74
  },
75
+ "./testing": {
76
+ "types": "./dist/testing/index.d.ts",
77
+ "react-native": "./src/testing/index.ts",
78
+ "source": "./src/testing/index.ts",
79
+ "import": "./dist/testing/index.mjs",
80
+ "require": "./dist/testing/index.js"
81
+ },
75
82
  "./package.json": "./package.json"
76
83
  },
77
84
  "files": [
@@ -84,16 +91,6 @@
84
91
  "!**/__tests__",
85
92
  "!**/*.test.*"
86
93
  ],
87
- "scripts": {
88
- "build": "tsup",
89
- "dev": "tsup --watch",
90
- "typecheck": "tsc --noEmit",
91
- "test": "jest",
92
- "size": "size-limit",
93
- "size:why": "size-limit --why",
94
- "lint": "eslint src",
95
- "clean": "rm -rf dist .turbo *.tsbuildinfo"
96
- },
97
94
  "peerDependencies": {
98
95
  "react": ">=19.0.0",
99
96
  "react-native": ">=0.81.0",
@@ -116,5 +113,15 @@
116
113
  },
117
114
  "publishConfig": {
118
115
  "access": "public"
116
+ },
117
+ "scripts": {
118
+ "build": "tsup",
119
+ "dev": "tsup --watch",
120
+ "typecheck": "tsc --noEmit",
121
+ "test": "jest",
122
+ "size": "size-limit",
123
+ "size:why": "size-limit --why",
124
+ "lint": "eslint src",
125
+ "clean": "rm -rf dist .turbo *.tsbuildinfo"
119
126
  }
120
- }
127
+ }
@@ -0,0 +1,61 @@
1
+ import { Platform } from 'react-native'
2
+
3
+ /**
4
+ * Input-modality tracker for the `focusVisible` gesture sub-state.
5
+ *
6
+ * Implements the W3C `:focus-visible` heuristic: a focus event counts as
7
+ * "visible" only when the most recent user input was keyboard-driven. Mouse,
8
+ * pointer, and touch events flip the modality to `'pointer'`; keyboard events
9
+ * flip it back to `'keyboard'`.
10
+ *
11
+ * On native platforms there is no pointer-vs-keyboard distinction — focus
12
+ * arrives via D-pad, screen reader, or hardware keyboard, all of which are
13
+ * keyboard-equivalent — so `isFocusVisible()` is unconditionally `true`.
14
+ *
15
+ * The web listeners attach lazily on first call (capture phase, so they run
16
+ * before the focus event reaches the focused element) and stay installed for
17
+ * the lifetime of the document. They are passive and idle-cheap; the cost is
18
+ * one boolean read per `onFocus` dispatch.
19
+ */
20
+
21
+ type InputModality = 'keyboard' | 'pointer'
22
+
23
+ // Default to `'keyboard'` so a programmatic / autofocus that happens before
24
+ // any user input still draws a focus ring — matches the W3C polyfill default.
25
+ let modality: InputModality = 'keyboard'
26
+ let installed = false
27
+
28
+ function setKeyboard() {
29
+ modality = 'keyboard'
30
+ }
31
+
32
+ function setPointer() {
33
+ modality = 'pointer'
34
+ }
35
+
36
+ function ensureInstalled(): void {
37
+ if (installed) return
38
+ if (Platform.OS !== 'web') return
39
+ if (typeof document === 'undefined') return
40
+ document.addEventListener('keydown', setKeyboard, true)
41
+ document.addEventListener('mousedown', setPointer, true)
42
+ document.addEventListener('pointerdown', setPointer, true)
43
+ document.addEventListener('touchstart', setPointer, true)
44
+ installed = true
45
+ }
46
+
47
+ /**
48
+ * `true` if the next `onFocus` should be treated as "focus-visible" (keyboard
49
+ * focus). On native, always `true`. On web, reflects the most recent user
50
+ * input modality.
51
+ */
52
+ export function isFocusVisible(): boolean {
53
+ if (Platform.OS !== 'web') return true
54
+ ensureInstalled()
55
+ return modality === 'keyboard'
56
+ }
57
+
58
+ /** @internal — test-only hook to reset module state between cases. */
59
+ export function __resetFocusVisibilityForTests(next: InputModality): void {
60
+ modality = next
61
+ }
@@ -0,0 +1 @@
1
+ export { isFocusVisible } from './focusVisibility'
@@ -13,6 +13,7 @@ import Animated, {
13
13
  type SharedValue,
14
14
  } from 'react-native-reanimated'
15
15
  import { useShouldReduceMotion } from '../config'
16
+ import { isFocusVisible } from '../gestures'
16
17
  import { usePresence } from '../presence'
17
18
  import { resolveAnimatableValue } from '../transitions'
18
19
  import {
@@ -187,10 +188,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
187
188
 
188
189
  // Gesture sub-state activation tracked as JS state so changes invalidate
189
190
  // the merged-target signature and re-run the animation effect. The cost
190
- // is three useState slots regardless of whether `gesture` is set; that's
191
+ // is four useState slots regardless of whether `gesture` is set; that's
191
192
  // tiny and lets us stay rules-of-hooks-clean.
192
193
  const [pressed, setPressed] = useState(false)
193
194
  const [focused, setFocused] = useState(false)
195
+ const [focusVisible, setFocusVisible] = useState(false)
194
196
  const [hovered, setHovered] = useState(false)
195
197
 
196
198
  // The set of keys this instance animates is locked at first render. With
@@ -217,6 +219,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
217
219
  for (const subState of [
218
220
  gesture.pressed,
219
221
  gesture.focused,
222
+ gesture.focusVisible,
220
223
  gesture.hovered,
221
224
  ] as Array<object | undefined>) {
222
225
  if (!subState) continue
@@ -261,6 +264,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
261
264
  : mergeGestureTargets(animateRecord, gesture, {
262
265
  pressed,
263
266
  focused,
267
+ focusVisible,
264
268
  hovered,
265
269
  })
266
270
  const mergedSig =
@@ -383,6 +387,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
383
387
  rest as Record<string, unknown>,
384
388
  setPressed,
385
389
  setFocused,
390
+ setFocusVisible,
386
391
  setHovered,
387
392
  )
388
393
 
@@ -719,12 +724,18 @@ function stableStringify(v: unknown): string {
719
724
  * by any declared sub-state are always present in the result so releasing a
720
725
  * gesture animates the property back to a defined value (the base `animate`
721
726
  * value when present, otherwise `DEFAULT_RESTING`). Sub-states layer in
722
- * priority order: `hovered` < `focused` < `pressed`.
727
+ * priority order (lowest first):
728
+ * `hovered` < `focused` < `focusVisible` < `pressed`.
723
729
  */
724
730
  function mergeGestureTargets(
725
731
  base: Partial<Record<AnimatableKey, AnimatableValue<number>>>,
726
732
  gesture: GestureSubStates<unknown> | undefined,
727
- active: { pressed: boolean; focused: boolean; hovered: boolean },
733
+ active: {
734
+ pressed: boolean
735
+ focused: boolean
736
+ focusVisible: boolean
737
+ hovered: boolean
738
+ },
728
739
  ): Partial<Record<AnimatableKey, AnimatableValue<number>>> {
729
740
  if (!gesture) return base
730
741
  const merged: Partial<Record<AnimatableKey, AnimatableValue<number>>> = {
@@ -733,6 +744,7 @@ function mergeGestureTargets(
733
744
  const subStates = [
734
745
  gesture.hovered,
735
746
  gesture.focused,
747
+ gesture.focusVisible,
736
748
  gesture.pressed,
737
749
  ] as Array<
738
750
  Partial<Record<AnimatableKey, AnimatableValue<number>>> | undefined
@@ -761,6 +773,14 @@ function mergeGestureTargets(
761
773
  >,
762
774
  )
763
775
  }
776
+ if (active.focusVisible && gesture.focusVisible) {
777
+ Object.assign(
778
+ merged,
779
+ gesture.focusVisible as Partial<
780
+ Record<AnimatableKey, AnimatableValue<number>>
781
+ >,
782
+ )
783
+ }
764
784
  if (active.pressed && gesture.pressed) {
765
785
  Object.assign(
766
786
  merged,
@@ -788,6 +808,7 @@ function useGestureHandlers(
788
808
  rest: Record<string, unknown>,
789
809
  setPressed: (next: boolean) => void,
790
810
  setFocused: (next: boolean) => void,
811
+ setFocusVisible: (next: boolean) => void,
791
812
  setHovered: (next: boolean) => void,
792
813
  ): GestureHandlers {
793
814
  return useMemo(() => {
@@ -804,9 +825,20 @@ function useGestureHandlers(
804
825
  handlers.onPressIn = compose(rest.onPressIn, () => setPressed(true))
805
826
  handlers.onPressOut = compose(rest.onPressOut, () => setPressed(false))
806
827
  }
807
- if (gesture.focused) {
808
- handlers.onFocus = compose(rest.onFocus, () => setFocused(true))
809
- handlers.onBlur = compose(rest.onBlur, () => setFocused(false))
828
+ // Mount onFocus/onBlur if either focus sub-state is declared. The two flags
829
+ // are independent: `focused` always tracks focus; `focusVisible` only
830
+ // engages when the most recent input was keyboard (W3C `:focus-visible`
831
+ // semantics). On native the modality is always `'keyboard'`, so the two
832
+ // flags move together.
833
+ if (gesture.focused || gesture.focusVisible) {
834
+ handlers.onFocus = compose(rest.onFocus, () => {
835
+ if (gesture.focused) setFocused(true)
836
+ if (gesture.focusVisible && isFocusVisible()) setFocusVisible(true)
837
+ })
838
+ handlers.onBlur = compose(rest.onBlur, () => {
839
+ if (gesture.focused) setFocused(false)
840
+ if (gesture.focusVisible) setFocusVisible(false)
841
+ })
810
842
  }
811
843
  if (gesture.hovered) {
812
844
  // Web-only events. RN-Web 0.72+ accepts these on View; native ignores
@@ -821,6 +853,7 @@ function useGestureHandlers(
821
853
  }, [
822
854
  gesture?.pressed ? 1 : 0,
823
855
  gesture?.focused ? 1 : 0,
856
+ gesture?.focusVisible ? 1 : 0,
824
857
  gesture?.hovered ? 1 : 0,
825
858
  rest.onTouchStart,
826
859
  rest.onTouchEnd,
@@ -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 `@onlynative/inertia/testing`:
18
+ *
19
+ * ```ts
20
+ * import { renderWithMotion } from '@onlynative/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
+ }
package/src/types.ts CHANGED
@@ -113,17 +113,34 @@ export type VariantsMap<C> = Record<string, AnimateStyle<C>>
113
113
  *
114
114
  * - `pressed` — active while the user is touching the component (touch start
115
115
  * to touch end / cancel).
116
- * - `focused` — active while a focusable component owns keyboard focus
117
- * (no-op for non-focusable underlying components).
116
+ * - `focused` — active while a focusable component owns focus, regardless of
117
+ * how focus arrived (mouse, touch, or keyboard). No-op for non-focusable
118
+ * underlying components.
119
+ * - `focusVisible` — active only when focus arrived from the keyboard
120
+ * (W3C `:focus-visible` semantics). Use this for focus rings to avoid
121
+ * flashing them on click-focus on web. On native — where focus always
122
+ * arrives via D-pad, screen reader, or hardware keyboard — this behaves
123
+ * identically to `focused`.
118
124
  * - `hovered` — web-only. Typed for cross-platform call sites; the runtime is
119
125
  * a no-op on native.
120
126
  *
121
127
  * When a sub-state is active, its values override the base `animate` target
122
- * per-property. Priority on overlap: `pressed` > `focused` > `hovered`.
128
+ * per-property. Priority on overlap (highest first):
129
+ * `pressed` > `focusVisible` > `focused` > `hovered`. `focusVisible` layers
130
+ * above `focused` so declaring both yields a state-layer on any focus and a
131
+ * ring on keyboard focus only.
132
+ *
133
+ * Sub-states stack as **single-state selection**, not blended interpolation:
134
+ * the highest-priority active key's value wins per-property, with one
135
+ * transition between target values. Mid-transition cross-fades between
136
+ * sub-states (e.g. release-while-still-hovered) follow the standard `transition`
137
+ * for that property — the resolver does not run multiple parallel
138
+ * interpolations the way a hand-rolled chained-`interpolateColor` would.
123
139
  */
124
140
  export interface GestureSubStates<C> {
125
141
  pressed?: AnimateStyle<C>
126
142
  focused?: AnimateStyle<C>
143
+ focusVisible?: AnimateStyle<C>
127
144
  hovered?: AnimateStyle<C>
128
145
  }
129
146