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

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 (52) hide show
  1. package/README.md +7 -7
  2. package/dist/index.d.mts +5 -6
  3. package/dist/index.d.ts +5 -6
  4. package/dist/index.js +84 -10
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +85 -11
  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 +84 -10
  11. package/dist/motion/Image.js.map +1 -1
  12. package/dist/motion/Image.mjs +85 -11
  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 +84 -10
  17. package/dist/motion/Pressable.js.map +1 -1
  18. package/dist/motion/Pressable.mjs +85 -11
  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 +84 -10
  23. package/dist/motion/ScrollView.js.map +1 -1
  24. package/dist/motion/ScrollView.mjs +85 -11
  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 +84 -10
  29. package/dist/motion/Text.js.map +1 -1
  30. package/dist/motion/Text.mjs +85 -11
  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 +84 -10
  35. package/dist/motion/View.js.map +1 -1
  36. package/dist/motion/View.mjs +85 -11
  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/llms.txt +5 -1
  47. package/package.json +19 -12
  48. package/src/gestures/focusVisibility.ts +61 -0
  49. package/src/gestures/index.ts +1 -0
  50. package/src/motion/createMotionComponent.tsx +132 -47
  51. package/src/testing/index.ts +78 -0
  52. 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.2",
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 {
@@ -43,7 +44,28 @@ const TRANSFORM_KEYS = [
43
44
  'rotate',
44
45
  ] as const
45
46
 
46
- const TOP_LEVEL_KEYS = ['opacity', 'width', 'height', 'borderRadius'] as const
47
+ const NUMERIC_TOP_LEVEL_KEYS = [
48
+ 'opacity',
49
+ 'width',
50
+ 'height',
51
+ 'borderRadius',
52
+ ] as const
53
+
54
+ // Color-valued keys. Reanimated's value setter detects color strings and
55
+ // interpolates between their packed RGBA representations natively in
56
+ // `withSpring` / `withTiming` — so the resolver path is identical to numeric
57
+ // keys; only the shared-value seed and the resting default differ.
58
+ //
59
+ // `tintColor` is Image-only, but allocated unconditionally here: the
60
+ // per-primitive type system (`AnimateStyle<C>`) is what gates which keys
61
+ // `animate` accepts at compile time. An unused shared value is a single ref;
62
+ // allocating it everywhere keeps hook order stable and the factory generic.
63
+ const COLOR_KEYS = [
64
+ 'backgroundColor',
65
+ 'borderColor',
66
+ 'color',
67
+ 'tintColor',
68
+ ] as const
47
69
 
48
70
  /**
49
71
  * Per-effect transform-group coordinator. Counts how many transform-axis
@@ -53,7 +75,11 @@ const TOP_LEVEL_KEYS = ['opacity', 'width', 'height', 'borderRadius'] as const
53
75
  */
54
76
  type TransformGroup = { remaining: number }
55
77
 
56
- const ALL_KEYS = [...TRANSFORM_KEYS, ...TOP_LEVEL_KEYS] as const
78
+ const ALL_KEYS = [
79
+ ...TRANSFORM_KEYS,
80
+ ...NUMERIC_TOP_LEVEL_KEYS,
81
+ ...COLOR_KEYS,
82
+ ] as const
57
83
  type AnimatableKey = (typeof ALL_KEYS)[number]
58
84
  type TransformKey = (typeof TRANSFORM_KEYS)[number]
59
85
 
@@ -64,7 +90,7 @@ const TRANSFORM_KEY_SET = new Set<AnimatableKey>(TRANSFORM_KEYS)
64
90
  // Reanimated's style merging treats it as a no-op when present.
65
91
  const EXITING_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
66
92
 
67
- const DEFAULT_RESTING: Record<AnimatableKey, number> = {
93
+ const DEFAULT_RESTING: Record<AnimatableKey, number | string> = {
68
94
  translateX: 0,
69
95
  translateY: 0,
70
96
  scale: 1,
@@ -75,6 +101,14 @@ const DEFAULT_RESTING: Record<AnimatableKey, number> = {
75
101
  width: 0,
76
102
  height: 0,
77
103
  borderRadius: 0,
104
+ // 'transparent' is the only safe universal default for colors: it works as
105
+ // an initial seed for any color animation (no jarring opaque flash on mount
106
+ // when `initial` is omitted) and rgba(0,0,0,0) interpolates cleanly into
107
+ // any opaque target via Reanimated's color util.
108
+ backgroundColor: 'transparent',
109
+ borderColor: 'transparent',
110
+ color: 'transparent',
111
+ tintColor: 'transparent',
78
112
  }
79
113
 
80
114
  const TRANSITION_KEYS = new Set([
@@ -116,10 +150,9 @@ function transitionFor<S>(
116
150
  * `exit` / `transition` all infer from `C`'s `style` prop. There is no
117
151
  * shared `ViewStyle & TextStyle & ImageStyle` fallback.
118
152
  *
119
- * Alpha scope: numeric properties listed in `ALL_KEYS`, applied via
120
- * Reanimated shared values + `useAnimatedStyle`. Sequences, variants,
121
- * gestures, color animation, and the cross-render memoization optimization
122
- * land in later phases.
153
+ * Alpha scope: numeric properties (transforms, opacity, width, height,
154
+ * borderRadius) and color properties (backgroundColor, borderColor, color,
155
+ * tintColor) applied via Reanimated shared values + `useAnimatedStyle`.
123
156
  */
124
157
  export function createMotionComponent<C extends ComponentType<any>>(
125
158
  Component: C,
@@ -175,22 +208,25 @@ export function createMotionComponent<C extends ComponentType<any>>(
175
208
  )
176
209
 
177
210
  const animateRecord = (resolvedAnimate ?? {}) as Partial<
178
- Record<AnimatableKey, AnimatableValue<number>>
211
+ Record<AnimatableKey, AnimatableValue<number | string>>
179
212
  >
180
213
  const initialRecord =
181
214
  initial && initial !== false
182
- ? (initial as Partial<Record<AnimatableKey, number>>)
215
+ ? (initial as Partial<Record<AnimatableKey, number | string>>)
183
216
  : undefined
184
217
  const exitRecord = exit
185
- ? (exit as Partial<Record<AnimatableKey, AnimatableValue<number>>>)
218
+ ? (exit as Partial<
219
+ Record<AnimatableKey, AnimatableValue<number | string>>
220
+ >)
186
221
  : undefined
187
222
 
188
223
  // Gesture sub-state activation tracked as JS state so changes invalidate
189
224
  // 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
225
+ // is four useState slots regardless of whether `gesture` is set; that's
191
226
  // tiny and lets us stay rules-of-hooks-clean.
192
227
  const [pressed, setPressed] = useState(false)
193
228
  const [focused, setFocused] = useState(false)
229
+ const [focusVisible, setFocusVisible] = useState(false)
194
230
  const [hovered, setHovered] = useState(false)
195
231
 
196
232
  // The set of keys this instance animates is locked at first render. With
@@ -217,6 +253,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
217
253
  for (const subState of [
218
254
  gesture.pressed,
219
255
  gesture.focused,
256
+ gesture.focusVisible,
220
257
  gesture.hovered,
221
258
  ] as Array<object | undefined>) {
222
259
  if (!subState) continue
@@ -261,6 +298,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
261
298
  : mergeGestureTargets(animateRecord, gesture, {
262
299
  pressed,
263
300
  focused,
301
+ focusVisible,
264
302
  hovered,
265
303
  })
266
304
  const mergedSig =
@@ -383,6 +421,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
383
421
  rest as Record<string, unknown>,
384
422
  setPressed,
385
423
  setFocused,
424
+ setFocusVisible,
386
425
  setHovered,
387
426
  )
388
427
 
@@ -401,7 +440,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
401
440
  return Motion as unknown as MotionComponent<C>
402
441
  }
403
442
 
404
- type SharedValueMap = Record<AnimatableKey, SharedValue<number>>
443
+ type SharedValueMap = Record<AnimatableKey, SharedValue<number | string>>
405
444
 
406
445
  /**
407
446
  * Allocate one shared value per animatable key in `ALL_KEYS` and return a
@@ -417,21 +456,29 @@ type SharedValueMap = Record<AnimatableKey, SharedValue<number>>
417
456
  *
418
457
  * Hooks are called in a stable, lexical order — fine for rules-of-hooks.
419
458
  * Unused shared values are cheap; the worklet skips them via
420
- * `activeKeysRef`.
459
+ * `activeKeysRef`. Color keys are seeded with the initial color string so
460
+ * Reanimated's value setter recognizes the slot as a color from the first
461
+ * `withSpring` / `withTiming` call.
421
462
  */
422
463
  function useAnimatableSharedValues(
423
- init: (key: AnimatableKey) => number,
464
+ init: (key: AnimatableKey) => number | string,
424
465
  ): SharedValueMap {
425
- const translateX = useSharedValue(init('translateX'))
426
- const translateY = useSharedValue(init('translateY'))
427
- const scale = useSharedValue(init('scale'))
428
- const scaleX = useSharedValue(init('scaleX'))
429
- const scaleY = useSharedValue(init('scaleY'))
430
- const rotate = useSharedValue(init('rotate'))
431
- const opacity = useSharedValue(init('opacity'))
432
- const width = useSharedValue(init('width'))
433
- const height = useSharedValue(init('height'))
434
- const borderRadius = useSharedValue(init('borderRadius'))
466
+ const translateX = useSharedValue<number | string>(init('translateX'))
467
+ const translateY = useSharedValue<number | string>(init('translateY'))
468
+ const scale = useSharedValue<number | string>(init('scale'))
469
+ const scaleX = useSharedValue<number | string>(init('scaleX'))
470
+ const scaleY = useSharedValue<number | string>(init('scaleY'))
471
+ const rotate = useSharedValue<number | string>(init('rotate'))
472
+ const opacity = useSharedValue<number | string>(init('opacity'))
473
+ const width = useSharedValue<number | string>(init('width'))
474
+ const height = useSharedValue<number | string>(init('height'))
475
+ const borderRadius = useSharedValue<number | string>(init('borderRadius'))
476
+ const backgroundColor = useSharedValue<number | string>(
477
+ init('backgroundColor'),
478
+ )
479
+ const borderColor = useSharedValue<number | string>(init('borderColor'))
480
+ const color = useSharedValue<number | string>(init('color'))
481
+ const tintColor = useSharedValue<number | string>(init('tintColor'))
435
482
 
436
483
  const ref = useRef<SharedValueMap | null>(null)
437
484
  if (ref.current === null) {
@@ -446,6 +493,10 @@ function useAnimatableSharedValues(
446
493
  width,
447
494
  height,
448
495
  borderRadius,
496
+ backgroundColor,
497
+ borderColor,
498
+ color,
499
+ tintColor,
449
500
  }
450
501
  }
451
502
  return ref.current
@@ -467,7 +518,7 @@ function useAnimatableSharedValues(
467
518
  */
468
519
  function makeKeyCallbackFactory(
469
520
  key: string,
470
- sharedValue: SharedValue<number>,
521
+ sharedValue: SharedValue<number | string>,
471
522
  target: number | string | undefined,
472
523
  onAnimationEndRef: {
473
524
  current: ((info: AnimationCallbackInfo<unknown>) => void) | undefined
@@ -572,7 +623,7 @@ function makeKeyCallbackFactory(
572
623
  * Number of sequence steps in an animatable value. `1` for plain values and
573
624
  * single-step `{ to }` objects; the array length for keyframe arrays.
574
625
  */
575
- function stepCountOf(v: AnimatableValue<number> | undefined): number {
626
+ function stepCountOf(v: AnimatableValue<number | string> | undefined): number {
576
627
  if (Array.isArray(v)) return v.length
577
628
  return 1
578
629
  }
@@ -600,13 +651,13 @@ function totalIterationsOf(cfg: TransitionConfig | undefined): number {
600
651
  * objects use `to`. Returns `undefined` for unrecognized shapes.
601
652
  */
602
653
  function targetEndValue(
603
- v: AnimatableValue<number> | undefined,
654
+ v: AnimatableValue<number | string> | undefined,
604
655
  ): number | string | undefined {
605
656
  if (v === undefined) return undefined
606
657
  if (typeof v === 'number' || typeof v === 'string') return v
607
658
  if (Array.isArray(v)) {
608
659
  return v.length > 0
609
- ? targetEndValue(v[v.length - 1] as AnimatableValue<number>)
660
+ ? targetEndValue(v[v.length - 1] as AnimatableValue<number | string>)
610
661
  : undefined
611
662
  }
612
663
  if (typeof v === 'object' && v !== null && 'to' in v) {
@@ -662,20 +713,24 @@ function resolveAnimateInput(
662
713
  declare const __DEV__: boolean
663
714
 
664
715
  /**
665
- * Pick the resting/initial-frame number out of an `AnimatableValue`. Plain
666
- * numbers come through unchanged; sequence arrays use their first element;
667
- * `{ to }` step objects use `to`. Non-numeric or unresolvable shapes return
716
+ * Pick the resting/initial-frame value out of an `AnimatableValue`. Plain
717
+ * numbers and color strings come through unchanged; sequence arrays use their
718
+ * first element; `{ to }` step objects use `to`. Unresolvable shapes return
668
719
  * `undefined` so the caller can fall back to `DEFAULT_RESTING`.
669
720
  */
670
- function restValue(v: AnimatableValue<number> | undefined): number | undefined {
721
+ function restValue(
722
+ v: AnimatableValue<number | string> | undefined,
723
+ ): number | string | undefined {
671
724
  if (v === undefined) return undefined
672
- if (typeof v === 'number') return v
725
+ if (typeof v === 'number' || typeof v === 'string') return v
673
726
  if (Array.isArray(v)) {
674
- return v.length > 0 ? restValue(v[0] as AnimatableValue<number>) : undefined
727
+ return v.length > 0
728
+ ? restValue(v[0] as AnimatableValue<number | string>)
729
+ : undefined
675
730
  }
676
731
  if (typeof v === 'object' && v !== null && 'to' in v) {
677
732
  const to = (v as { to: unknown }).to
678
- return typeof to === 'number' ? to : undefined
733
+ return typeof to === 'number' || typeof to === 'string' ? to : undefined
679
734
  }
680
735
  return undefined
681
736
  }
@@ -719,23 +774,32 @@ function stableStringify(v: unknown): string {
719
774
  * by any declared sub-state are always present in the result so releasing a
720
775
  * gesture animates the property back to a defined value (the base `animate`
721
776
  * value when present, otherwise `DEFAULT_RESTING`). Sub-states layer in
722
- * priority order: `hovered` < `focused` < `pressed`.
777
+ * priority order (lowest first):
778
+ * `hovered` < `focused` < `focusVisible` < `pressed`.
723
779
  */
724
780
  function mergeGestureTargets(
725
- base: Partial<Record<AnimatableKey, AnimatableValue<number>>>,
781
+ base: Partial<Record<AnimatableKey, AnimatableValue<number | string>>>,
726
782
  gesture: GestureSubStates<unknown> | undefined,
727
- active: { pressed: boolean; focused: boolean; hovered: boolean },
728
- ): Partial<Record<AnimatableKey, AnimatableValue<number>>> {
783
+ active: {
784
+ pressed: boolean
785
+ focused: boolean
786
+ focusVisible: boolean
787
+ hovered: boolean
788
+ },
789
+ ): Partial<Record<AnimatableKey, AnimatableValue<number | string>>> {
729
790
  if (!gesture) return base
730
- const merged: Partial<Record<AnimatableKey, AnimatableValue<number>>> = {
791
+ const merged: Partial<
792
+ Record<AnimatableKey, AnimatableValue<number | string>>
793
+ > = {
731
794
  ...base,
732
795
  }
733
796
  const subStates = [
734
797
  gesture.hovered,
735
798
  gesture.focused,
799
+ gesture.focusVisible,
736
800
  gesture.pressed,
737
801
  ] as Array<
738
- Partial<Record<AnimatableKey, AnimatableValue<number>>> | undefined
802
+ Partial<Record<AnimatableKey, AnimatableValue<number | string>>> | undefined
739
803
  >
740
804
  for (const sub of subStates) {
741
805
  if (!sub) continue
@@ -749,7 +813,7 @@ function mergeGestureTargets(
749
813
  Object.assign(
750
814
  merged,
751
815
  gesture.hovered as Partial<
752
- Record<AnimatableKey, AnimatableValue<number>>
816
+ Record<AnimatableKey, AnimatableValue<number | string>>
753
817
  >,
754
818
  )
755
819
  }
@@ -757,7 +821,15 @@ function mergeGestureTargets(
757
821
  Object.assign(
758
822
  merged,
759
823
  gesture.focused as Partial<
760
- Record<AnimatableKey, AnimatableValue<number>>
824
+ Record<AnimatableKey, AnimatableValue<number | string>>
825
+ >,
826
+ )
827
+ }
828
+ if (active.focusVisible && gesture.focusVisible) {
829
+ Object.assign(
830
+ merged,
831
+ gesture.focusVisible as Partial<
832
+ Record<AnimatableKey, AnimatableValue<number | string>>
761
833
  >,
762
834
  )
763
835
  }
@@ -765,7 +837,7 @@ function mergeGestureTargets(
765
837
  Object.assign(
766
838
  merged,
767
839
  gesture.pressed as Partial<
768
- Record<AnimatableKey, AnimatableValue<number>>
840
+ Record<AnimatableKey, AnimatableValue<number | string>>
769
841
  >,
770
842
  )
771
843
  }
@@ -788,6 +860,7 @@ function useGestureHandlers(
788
860
  rest: Record<string, unknown>,
789
861
  setPressed: (next: boolean) => void,
790
862
  setFocused: (next: boolean) => void,
863
+ setFocusVisible: (next: boolean) => void,
791
864
  setHovered: (next: boolean) => void,
792
865
  ): GestureHandlers {
793
866
  return useMemo(() => {
@@ -804,9 +877,20 @@ function useGestureHandlers(
804
877
  handlers.onPressIn = compose(rest.onPressIn, () => setPressed(true))
805
878
  handlers.onPressOut = compose(rest.onPressOut, () => setPressed(false))
806
879
  }
807
- if (gesture.focused) {
808
- handlers.onFocus = compose(rest.onFocus, () => setFocused(true))
809
- handlers.onBlur = compose(rest.onBlur, () => setFocused(false))
880
+ // Mount onFocus/onBlur if either focus sub-state is declared. The two flags
881
+ // are independent: `focused` always tracks focus; `focusVisible` only
882
+ // engages when the most recent input was keyboard (W3C `:focus-visible`
883
+ // semantics). On native the modality is always `'keyboard'`, so the two
884
+ // flags move together.
885
+ if (gesture.focused || gesture.focusVisible) {
886
+ handlers.onFocus = compose(rest.onFocus, () => {
887
+ if (gesture.focused) setFocused(true)
888
+ if (gesture.focusVisible && isFocusVisible()) setFocusVisible(true)
889
+ })
890
+ handlers.onBlur = compose(rest.onBlur, () => {
891
+ if (gesture.focused) setFocused(false)
892
+ if (gesture.focusVisible) setFocusVisible(false)
893
+ })
810
894
  }
811
895
  if (gesture.hovered) {
812
896
  // Web-only events. RN-Web 0.72+ accepts these on View; native ignores
@@ -821,6 +905,7 @@ function useGestureHandlers(
821
905
  }, [
822
906
  gesture?.pressed ? 1 : 0,
823
907
  gesture?.focused ? 1 : 0,
908
+ gesture?.focusVisible ? 1 : 0,
824
909
  gesture?.hovered ? 1 : 0,
825
910
  rest.onTouchStart,
826
911
  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