@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.
- package/README.md +7 -7
- package/dist/index.d.mts +5 -6
- package/dist/index.d.ts +5 -6
- package/dist/index.js +84 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +85 -11
- package/dist/index.mjs.map +1 -1
- package/dist/motion/Image.d.mts +1 -1
- package/dist/motion/Image.d.ts +1 -1
- package/dist/motion/Image.js +84 -10
- package/dist/motion/Image.js.map +1 -1
- package/dist/motion/Image.mjs +85 -11
- package/dist/motion/Image.mjs.map +1 -1
- package/dist/motion/Pressable.d.mts +1 -1
- package/dist/motion/Pressable.d.ts +1 -1
- package/dist/motion/Pressable.js +84 -10
- package/dist/motion/Pressable.js.map +1 -1
- package/dist/motion/Pressable.mjs +85 -11
- package/dist/motion/Pressable.mjs.map +1 -1
- package/dist/motion/ScrollView.d.mts +1 -1
- package/dist/motion/ScrollView.d.ts +1 -1
- package/dist/motion/ScrollView.js +84 -10
- package/dist/motion/ScrollView.js.map +1 -1
- package/dist/motion/ScrollView.mjs +85 -11
- package/dist/motion/ScrollView.mjs.map +1 -1
- package/dist/motion/Text.d.mts +1 -1
- package/dist/motion/Text.d.ts +1 -1
- package/dist/motion/Text.js +84 -10
- package/dist/motion/Text.js.map +1 -1
- package/dist/motion/Text.mjs +85 -11
- package/dist/motion/Text.mjs.map +1 -1
- package/dist/motion/View.d.mts +1 -1
- package/dist/motion/View.d.ts +1 -1
- package/dist/motion/View.js +84 -10
- package/dist/motion/View.js.map +1 -1
- package/dist/motion/View.mjs +85 -11
- package/dist/motion/View.mjs.map +1 -1
- package/dist/testing/index.d.mts +57 -0
- package/dist/testing/index.d.ts +57 -0
- package/dist/testing/index.js +19 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/index.mjs +16 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/{types-CmbXx-G3.d.mts → types-DeZZzE_e.d.mts} +20 -3
- package/dist/{types-CmbXx-G3.d.ts → types-DeZZzE_e.d.ts} +20 -3
- package/llms.txt +5 -1
- package/package.json +19 -12
- package/src/gestures/focusVisibility.ts +61 -0
- package/src/gestures/index.ts +1 -0
- package/src/motion/createMotionComponent.tsx +132 -47
- package/src/testing/index.ts +78 -0
- 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.
|
|
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
|
|
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 = [
|
|
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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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<
|
|
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
|
|
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
|
|
666
|
-
* numbers come through unchanged; sequence arrays use their
|
|
667
|
-
* `{ to }` step objects use `to`.
|
|
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(
|
|
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
|
|
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
|
|
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: {
|
|
728
|
-
|
|
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<
|
|
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
|
|
808
|
-
|
|
809
|
-
|
|
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
|
|
117
|
-
* (
|
|
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
|
|
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
|
|