@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.
- package/README.md +6 -6
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +45 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +46 -5
- 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 +45 -4
- package/dist/motion/Image.js.map +1 -1
- package/dist/motion/Image.mjs +46 -5
- 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 +45 -4
- package/dist/motion/Pressable.js.map +1 -1
- package/dist/motion/Pressable.mjs +46 -5
- 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 +45 -4
- package/dist/motion/ScrollView.js.map +1 -1
- package/dist/motion/ScrollView.mjs +46 -5
- 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 +45 -4
- package/dist/motion/Text.js.map +1 -1
- package/dist/motion/Text.mjs +46 -5
- 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 +45 -4
- package/dist/motion/View.js.map +1 -1
- package/dist/motion/View.mjs +46 -5
- 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/package.json +19 -12
- package/src/gestures/focusVisibility.ts +61 -0
- package/src/gestures/index.ts +1 -0
- package/src/motion/createMotionComponent.tsx +39 -6
- 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.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
|
|
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
|
|
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: {
|
|
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
|
|
808
|
-
|
|
809
|
-
|
|
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
|
|
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
|
|