@oxyhq/bloom 0.54.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/tab-bar/TabBarBase.js +101 -16
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/shared.js +15 -1
- package/lib/commonjs/tab-bar/shared.js.map +1 -1
- package/lib/commonjs/toast/ToastRow.js +35 -10
- package/lib/commonjs/toast/ToastRow.js.map +1 -1
- package/lib/commonjs/toast/ToastSwipeHandler.js +15 -5
- package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
- package/lib/commonjs/toast/constants.js +18 -1
- package/lib/commonjs/toast/constants.js.map +1 -1
- package/lib/commonjs/toast/toast-store.js +39 -1
- package/lib/commonjs/toast/toast-store.js.map +1 -1
- package/lib/commonjs/toast/use-stack-hover.js +175 -0
- package/lib/commonjs/toast/use-stack-hover.js.map +1 -0
- package/lib/commonjs/toast/use-stack-hover.native.js +42 -0
- package/lib/commonjs/toast/use-stack-hover.native.js.map +1 -0
- package/lib/module/tab-bar/TabBarBase.js +103 -18
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/shared.js +14 -0
- package/lib/module/tab-bar/shared.js.map +1 -1
- package/lib/module/toast/ToastRow.js +35 -10
- package/lib/module/toast/ToastRow.js.map +1 -1
- package/lib/module/toast/ToastSwipeHandler.js +16 -4
- package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
- package/lib/module/toast/constants.js +18 -1
- package/lib/module/toast/constants.js.map +1 -1
- package/lib/module/toast/toast-store.js +39 -1
- package/lib/module/toast/toast-store.js.map +1 -1
- package/lib/module/toast/use-stack-hover.js +168 -0
- package/lib/module/toast/use-stack-hover.js.map +1 -0
- package/lib/module/toast/use-stack-hover.native.js +35 -0
- package/lib/module/toast/use-stack-hover.native.js.map +1 -0
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/shared.d.ts +13 -0
- package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +12 -0
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +4 -0
- package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/toast-store.d.ts +15 -0
- package/lib/typescript/commonjs/toast/toast-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/use-stack-hover.d.ts +55 -0
- package/lib/typescript/commonjs/toast/use-stack-hover.d.ts.map +1 -0
- package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts +26 -0
- package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts.map +1 -0
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/shared.d.ts +13 -0
- package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +12 -0
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
- package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +4 -0
- package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
- package/lib/typescript/module/toast/constants.d.ts.map +1 -1
- package/lib/typescript/module/toast/toast-store.d.ts +15 -0
- package/lib/typescript/module/toast/toast-store.d.ts.map +1 -1
- package/lib/typescript/module/toast/use-stack-hover.d.ts +55 -0
- package/lib/typescript/module/toast/use-stack-hover.d.ts.map +1 -0
- package/lib/typescript/module/toast/use-stack-hover.native.d.ts +26 -0
- package/lib/typescript/module/toast/use-stack-hover.native.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/TabBarNoSelection.test.tsx +458 -0
- package/src/__tests__/TabBarReanimatedDeps.test.ts +28 -4
- package/src/__tests__/Toaster.test.tsx +554 -0
- package/src/__tests__/animated-style-deps.test.ts +30 -6
- package/src/__tests__/toast-position-utils.test.ts +28 -0
- package/src/__tests__/toast-stack-hover.test.ts +92 -0
- package/src/__tests__/toast-store.test.ts +72 -0
- package/src/tab-bar/TabBar.stories.tsx +52 -1
- package/src/tab-bar/TabBarBase.tsx +113 -31
- package/src/tab-bar/shared.ts +12 -0
- package/src/tab-bar/types.ts +12 -0
- package/src/toast/Toast.stories.tsx +10 -0
- package/src/toast/ToastRow.tsx +33 -10
- package/src/toast/ToastSwipeHandler.tsx +14 -2
- package/src/toast/constants.ts +18 -1
- package/src/toast/toast-store.ts +39 -1
- package/src/toast/use-stack-hover.native.ts +34 -0
- package/src/toast/use-stack-hover.ts +182 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a leave waits for a neighbouring row to claim the pointer. One frame is
|
|
3
|
+
* enough for the same-tick leave/enter pair a row crossing produces; this is a
|
|
4
|
+
* little more so a slow frame cannot flash the stack shut. Exported so the suite
|
|
5
|
+
* asserts against the real value rather than a copy of it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const HOVER_LEAVE_GRACE = 80;
|
|
8
|
+
type HoverEvent = {
|
|
9
|
+
nativeEvent: {
|
|
10
|
+
pointerType?: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Whether the pointer is over the stack RIGHT NOW.
|
|
15
|
+
*
|
|
16
|
+
* Two sources, and both are load-bearing:
|
|
17
|
+
*
|
|
18
|
+
* - the `hovered` latch answers "a mouse entered and has not left", which is the
|
|
19
|
+
* only thing available where there is no DOM (jest runs on `testEnvironment:
|
|
20
|
+
* 'node'`), and is a cheap early-out.
|
|
21
|
+
* - the live `:hover` query answers "a row is under the pointer", which the latch
|
|
22
|
+
* CANNOT: a row can stop being hovered with no event whatsoever. Measured — a row
|
|
23
|
+
* culled by `visibleToasts` under a stationary cursor fires no `pointerleave`,
|
|
24
|
+
* `pointerout` or `mouseleave` at all, and the restack then slides the remaining
|
|
25
|
+
* rows out from under the pointer. A latch alone therefore sticks TRUE, which
|
|
26
|
+
* would disable the press toggle for the rest of the session and suppress every
|
|
27
|
+
* later auto-collapse.
|
|
28
|
+
*
|
|
29
|
+
* Requiring both means a stale latch is corrected by the DOM and a stray DOM match
|
|
30
|
+
* (a pen, a touch) is rejected by the latch.
|
|
31
|
+
*
|
|
32
|
+
* COVERAGE, stated plainly: jest cannot see the DOM half at all
|
|
33
|
+
* (`testEnvironment: 'node'`), and a browser cannot isolate it either — its two
|
|
34
|
+
* consequences are a press toggle standing down (unreachable with a mouse, which
|
|
35
|
+
* always hovers what it presses) and a suppressed auto-collapse (which the deferred
|
|
36
|
+
* leave below independently corrects the moment any boundary event fires). Both
|
|
37
|
+
* halves are kept because each is measurably more accurate than the other alone, not
|
|
38
|
+
* because a test proves the composite.
|
|
39
|
+
*
|
|
40
|
+
* `ToastRow` asks so a press does not TOGGLE what hover already opened, and
|
|
41
|
+
* `toastStore` asks before auto-collapsing a stack down to its last row. The press
|
|
42
|
+
* still runs the toast's own `onPress`; only the expansion toggle steps aside.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isStackHovered(): boolean;
|
|
45
|
+
export type StackHoverProps = {
|
|
46
|
+
onPointerEnter: (event: HoverEvent) => void;
|
|
47
|
+
onPointerLeave: (event: HoverEvent) => void;
|
|
48
|
+
};
|
|
49
|
+
export declare function useStackHover({ enableStacking, }: {
|
|
50
|
+
enableStacking: boolean;
|
|
51
|
+
}): StackHoverProps;
|
|
52
|
+
/** Test-only reset: the module state outlives a render tree, so a suite must clear it. */
|
|
53
|
+
export declare function resetStackHoverForTests(): void;
|
|
54
|
+
export {};
|
|
55
|
+
//# sourceMappingURL=use-stack-hover.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-stack-hover.d.ts","sourceRoot":"","sources":["../../../../src/toast/use-stack-hover.ts"],"names":[],"mappings":"AAwCA;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAKpC,KAAK,UAAU,GAAG;IAAE,WAAW,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAc5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAwBD,MAAM,MAAM,eAAe,GAAG;IAC5B,cAAc,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IAC5C,cAAc,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CAC7C,CAAC;AAEF,wBAAgB,aAAa,CAAC,EAC5B,cAAc,GACf,EAAE;IACD,cAAc,EAAE,OAAO,CAAC;CACzB,GAAG,eAAe,CA4ClB;AAED,0FAA0F;AAC1F,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bloom-original — NATIVE. Metro resolves this over `use-stack-hover.ts` on iOS and
|
|
3
|
+
* Android, the same filename split `ToastHost.native.tsx` uses.
|
|
4
|
+
*
|
|
5
|
+
* Native keeps PRESS as the only way to expand a stack, so this hands back no
|
|
6
|
+
* handlers at all. Two reasons it is a fork rather than a runtime `Platform` check:
|
|
7
|
+
* the web file's module-level hover state and its `setTimeout` have no business
|
|
8
|
+
* existing in a native bundle, and `onPointerEnter`/`onPointerLeave` DO fire on
|
|
9
|
+
* native for touch (RN's pointer events unify touch and mouse), which would make
|
|
10
|
+
* every tap expand-then-collapse while the press toggle fired as well.
|
|
11
|
+
*
|
|
12
|
+
* Keep the shape identical to the web file — `ToastSwipeHandler` spreads whatever
|
|
13
|
+
* this returns onto the row box, and `ToastRow` calls `isStackHovered()`
|
|
14
|
+
* unconditionally.
|
|
15
|
+
*/
|
|
16
|
+
export type StackHoverProps = Record<string, never>;
|
|
17
|
+
/** Parity with the web file's export; nothing here waits for anything. */
|
|
18
|
+
export declare const HOVER_LEAVE_GRACE = 0;
|
|
19
|
+
/** Nothing hovers on a touch screen. */
|
|
20
|
+
export declare function isStackHovered(): boolean;
|
|
21
|
+
export declare function useStackHover(_options: {
|
|
22
|
+
enableStacking: boolean;
|
|
23
|
+
}): StackHoverProps;
|
|
24
|
+
/** Parity with the web file so the shared suite can call it on either platform. */
|
|
25
|
+
export declare function resetStackHoverForTests(): void;
|
|
26
|
+
//# sourceMappingURL=use-stack-hover.native.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-stack-hover.native.d.ts","sourceRoot":"","sources":["../../../../src/toast/use-stack-hover.native.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAEpD,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAEnC,wCAAwC;AACxC,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAKD,wBAAgB,aAAa,CAAC,QAAQ,EAAE;IAAE,cAAc,EAAE,OAAO,CAAA;CAAE,GAAG,eAAe,CAEpF;AAED,mFAAmF;AACnF,wBAAgB,uBAAuB,IAAI,IAAI,CAAG"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ReactTestInstance } from 'react-test-renderer';
|
|
3
|
+
import { act, render } from '@testing-library/react-native';
|
|
4
|
+
import { Dimensions } from 'react-native';
|
|
5
|
+
import { withSpring } from 'react-native-reanimated';
|
|
6
|
+
|
|
7
|
+
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
8
|
+
import { TabBar, TabBarButton } from '../tab-bar';
|
|
9
|
+
import { BAR_MARGIN, ROW_PAD_H } from '../tab-bar/shared';
|
|
10
|
+
import type { TabBarItem } from '../tab-bar/types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The bar with NO SELECTION: a controlled `activeIndex` that names no tab.
|
|
14
|
+
*
|
|
15
|
+
* Every consumer whose route set is larger than its tab set produces one — an
|
|
16
|
+
* index derived from `usePathname()` is -1 on every screen that is not a tab.
|
|
17
|
+
* The capsule used to slide one full item-width to the LEFT of the first tab and
|
|
18
|
+
* poke out of the pill, because the bar guarded only `activeIndex === undefined`
|
|
19
|
+
* and `highlightStyle` carried no opacity at all: an out-of-range index is a
|
|
20
|
+
* real POSITION, not an absence.
|
|
21
|
+
*
|
|
22
|
+
* The distinction this file exists to protect: `activeIndex === undefined` is
|
|
23
|
+
* NOT "no selection". It is the focus-driven path, where the router adapter
|
|
24
|
+
* leaves the prop unset and each button supplies `isFocused` instead — that path
|
|
25
|
+
* must keep highlighting exactly as it always has, which is what the last
|
|
26
|
+
* describe block pins down.
|
|
27
|
+
*
|
|
28
|
+
* Two mocks are replaced here, both deliberately:
|
|
29
|
+
*
|
|
30
|
+
* - `react-native-gesture-handler`, with a RECORDING mock (same technique as
|
|
31
|
+
* `TabBarLongPress.test.tsx`), because the shared mock discards every
|
|
32
|
+
* registered callback and the scrub worklets are the only way to reach the
|
|
33
|
+
* arming path a finger takes.
|
|
34
|
+
* - `react-native-reanimated`, wrapping the shared mock so `withSpring` is a
|
|
35
|
+
* spy. Under that mock `withSpring(x)` and a bare assignment of `x` both
|
|
36
|
+
* settle to `x` instantly, so the RESULT cannot distinguish "appeared at the
|
|
37
|
+
* target" from "slid to it" — whether a spring was started is the only
|
|
38
|
+
* observable difference left in jest. The motion itself is verified in a
|
|
39
|
+
* browser.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
interface MockGestureEvent {
|
|
43
|
+
x: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MockGesture {
|
|
47
|
+
kind: string;
|
|
48
|
+
start?: (event: MockGestureEvent) => void;
|
|
49
|
+
update?: (event: MockGestureEvent) => void;
|
|
50
|
+
end?: (event: MockGestureEvent, success: boolean) => void;
|
|
51
|
+
finalize?: () => void;
|
|
52
|
+
members: MockGesture[];
|
|
53
|
+
activeOffsetX: (range: number[]) => MockGesture;
|
|
54
|
+
failOffsetY: (range: number[]) => MockGesture;
|
|
55
|
+
maxDistance: (px: number) => MockGesture;
|
|
56
|
+
maxDuration: (ms: number) => MockGesture;
|
|
57
|
+
minDuration: (ms: number) => MockGesture;
|
|
58
|
+
onStart: (worklet: (event: MockGestureEvent) => void) => MockGesture;
|
|
59
|
+
onUpdate: (worklet: (event: MockGestureEvent) => void) => MockGesture;
|
|
60
|
+
onEnd: (worklet: (event: MockGestureEvent, success: boolean) => void) => MockGesture;
|
|
61
|
+
onFinalize: (worklet: () => void) => MockGesture;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Must be `mock`-prefixed so the hoisted `jest.mock` factory may reference it.
|
|
65
|
+
const mockGestures: MockGesture[] = [];
|
|
66
|
+
|
|
67
|
+
jest.mock('react-native-gesture-handler', () => {
|
|
68
|
+
const build = (kind: string): MockGesture => {
|
|
69
|
+
const gesture: MockGesture = {
|
|
70
|
+
kind,
|
|
71
|
+
members: [],
|
|
72
|
+
activeOffsetX: () => gesture,
|
|
73
|
+
failOffsetY: () => gesture,
|
|
74
|
+
maxDistance: () => gesture,
|
|
75
|
+
maxDuration: () => gesture,
|
|
76
|
+
minDuration: () => gesture,
|
|
77
|
+
onStart: (worklet) => {
|
|
78
|
+
gesture.start = worklet;
|
|
79
|
+
return gesture;
|
|
80
|
+
},
|
|
81
|
+
onUpdate: (worklet) => {
|
|
82
|
+
gesture.update = worklet;
|
|
83
|
+
return gesture;
|
|
84
|
+
},
|
|
85
|
+
onEnd: (worklet) => {
|
|
86
|
+
gesture.end = worklet;
|
|
87
|
+
return gesture;
|
|
88
|
+
},
|
|
89
|
+
onFinalize: (worklet) => {
|
|
90
|
+
gesture.finalize = worklet;
|
|
91
|
+
return gesture;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
mockGestures.push(gesture);
|
|
95
|
+
return gesture;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
Gesture: {
|
|
100
|
+
Pan: () => build('Pan'),
|
|
101
|
+
Tap: () => build('Tap'),
|
|
102
|
+
LongPress: () => build('LongPress'),
|
|
103
|
+
Race: (...members: MockGesture[]) => {
|
|
104
|
+
const race = build('Race');
|
|
105
|
+
race.members.push(...members);
|
|
106
|
+
return race;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
|
110
|
+
GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
jest.mock('react-native-reanimated', () => {
|
|
115
|
+
const actual =
|
|
116
|
+
jest.requireActual<typeof import('react-native-reanimated')>('react-native-reanimated');
|
|
117
|
+
// `__esModule` is non-enumerable on the module object, so a spread drops it —
|
|
118
|
+
// without it the default export (`Animated.View`) would arrive wrapped.
|
|
119
|
+
return { __esModule: true, ...actual, withSpring: jest.fn(actual.withSpring) };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const springs = jest.mocked(withSpring);
|
|
123
|
+
|
|
124
|
+
const HIGHLIGHT = 'rgb(9 9 9)';
|
|
125
|
+
const ACTIVE = 'rgb(1 1 1)';
|
|
126
|
+
const INACTIVE = 'rgb(2 2 2)';
|
|
127
|
+
|
|
128
|
+
const ITEMS: TabBarItem[] = [
|
|
129
|
+
{ name: 'home', label: 'Home', icon: null },
|
|
130
|
+
{ name: 'search', label: 'Search', icon: null },
|
|
131
|
+
{ name: 'you', label: 'You', icon: null },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Where the capsule sits for a given tab, from the same geometry the bar uses
|
|
136
|
+
* (window width as the react-native mock reports it, expanded — progress 0 — so
|
|
137
|
+
* no minimize inset applies).
|
|
138
|
+
*/
|
|
139
|
+
const ITEM_WIDTH =
|
|
140
|
+
(Dimensions.get('window').width - BAR_MARGIN * 2 - ROW_PAD_H * 2) / ITEMS.length;
|
|
141
|
+
const translateXFor = (index: number) => ROW_PAD_H + ITEM_WIDTH * index;
|
|
142
|
+
|
|
143
|
+
/** Touch x positions inside the bar, named by the tab they land on. */
|
|
144
|
+
const NEAR_LEFT_EDGE = 8;
|
|
145
|
+
const NEAR_RIGHT_EDGE = 340;
|
|
146
|
+
|
|
147
|
+
function withTheme(ui: React.ReactElement) {
|
|
148
|
+
return (
|
|
149
|
+
<BloomThemeProvider mode="light" colorPreset="teal">
|
|
150
|
+
{ui}
|
|
151
|
+
</BloomThemeProvider>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hostName(node: ReactTestInstance): string | null {
|
|
156
|
+
const { type } = node;
|
|
157
|
+
return typeof type === 'string' ? type : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function flattenStyle(style: unknown): Record<string, unknown> {
|
|
161
|
+
const out: Record<string, unknown> = {};
|
|
162
|
+
const visit = (s: unknown): void => {
|
|
163
|
+
if (Array.isArray(s)) s.forEach(visit);
|
|
164
|
+
else if (s && typeof s === 'object') Object.assign(out, s);
|
|
165
|
+
};
|
|
166
|
+
visit(style);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Every node painted with the bar's highlight color — the sliding capsule. */
|
|
171
|
+
function highlights(root: ReactTestInstance): Record<string, unknown>[] {
|
|
172
|
+
return root
|
|
173
|
+
.findAll((node) => hostName(node) !== null)
|
|
174
|
+
.map((node) => flattenStyle(node.props?.style))
|
|
175
|
+
.filter((style) => style.backgroundColor === HIGHLIGHT);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function highlight(root: ReactTestInstance): Record<string, unknown> {
|
|
179
|
+
const found = highlights(root);
|
|
180
|
+
if (found.length !== 1) throw new Error(`Expected one highlight, found ${found.length}`);
|
|
181
|
+
return found[0] as Record<string, unknown>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function translateXOf(style: Record<string, unknown>): number {
|
|
185
|
+
const transform = style.transform;
|
|
186
|
+
if (!Array.isArray(transform)) throw new Error('The highlight carries no transform');
|
|
187
|
+
const entry = transform.find(
|
|
188
|
+
(item): item is { translateX: number } =>
|
|
189
|
+
typeof item === 'object' && item !== null && 'translateX' in item,
|
|
190
|
+
);
|
|
191
|
+
if (!entry) throw new Error('The highlight transform has no translateX');
|
|
192
|
+
return entry.translateX;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The opacity of each tab's ACTIVE crossfade layer, in bar order — the layer
|
|
197
|
+
* `activeGlyphStyle` drives. Identified by the shape of its style: the only
|
|
198
|
+
* absolutely-positioned, centering, opacity-carrying animated view in the tree.
|
|
199
|
+
*/
|
|
200
|
+
function activeGlyphOpacities(root: ReactTestInstance): number[] {
|
|
201
|
+
return root
|
|
202
|
+
.findAll((node) => hostName(node) === 'Animated.View')
|
|
203
|
+
.map((node) => flattenStyle(node.props?.style))
|
|
204
|
+
.filter(
|
|
205
|
+
(style) =>
|
|
206
|
+
style.position === 'absolute' &&
|
|
207
|
+
style.alignItems === 'center' &&
|
|
208
|
+
style.justifyContent === 'center' &&
|
|
209
|
+
typeof style.opacity === 'number',
|
|
210
|
+
)
|
|
211
|
+
.map((style) => style.opacity as number);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The color of every tab label, in bar order. */
|
|
215
|
+
function labelColors(root: ReactTestInstance): unknown[] {
|
|
216
|
+
return root
|
|
217
|
+
.findAll((node) => hostName(node) === 'Animated.Text')
|
|
218
|
+
.map((node) => flattenStyle(node.props?.style).color);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function selectedFlags(root: ReactTestInstance): boolean[] {
|
|
222
|
+
return root
|
|
223
|
+
.findAll((node) => hostName(node) !== null && node.props?.accessibilityRole === 'tab')
|
|
224
|
+
.map((node) => node.props.accessibilityState.selected);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The gesture the detector was actually given — the last race constructed. */
|
|
228
|
+
function member(kind: string): MockGesture {
|
|
229
|
+
const races = mockGestures.filter((gesture) => gesture.kind === 'Race');
|
|
230
|
+
const last = races[races.length - 1];
|
|
231
|
+
if (!last) throw new Error('The bar composed no gesture race');
|
|
232
|
+
const found = last.members.find((gesture) => gesture.kind === kind);
|
|
233
|
+
if (!found) throw new Error(`No ${kind} gesture in the bar's race`);
|
|
234
|
+
return found;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type BarProps = {
|
|
238
|
+
activeIndex?: number;
|
|
239
|
+
onIndexChange?: (index: number) => void;
|
|
240
|
+
/** When set, drives the FOCUS path (`isFocused`) instead of `activeIndex`. */
|
|
241
|
+
focusedIndex?: number;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
function Bar({ activeIndex, onIndexChange, focusedIndex }: BarProps) {
|
|
245
|
+
return (
|
|
246
|
+
<TabBar
|
|
247
|
+
activeIndex={activeIndex}
|
|
248
|
+
onIndexChange={onIndexChange}
|
|
249
|
+
theme={{ highlight: HIGHLIGHT, activeTint: ACTIVE, inactiveTint: INACTIVE }}
|
|
250
|
+
>
|
|
251
|
+
{ITEMS.map((item, index) => (
|
|
252
|
+
<TabBarButton
|
|
253
|
+
key={item.name}
|
|
254
|
+
item={item}
|
|
255
|
+
index={index}
|
|
256
|
+
isFocused={focusedIndex === undefined ? undefined : focusedIndex === index}
|
|
257
|
+
/>
|
|
258
|
+
))}
|
|
259
|
+
</TabBar>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Mount the bar and drive its controlled index.
|
|
265
|
+
*
|
|
266
|
+
* The reanimated mock's shared values are plain boxes and `useAnimatedStyle`
|
|
267
|
+
* runs during render, so a mapper reading a value an EFFECT wrote reflects it
|
|
268
|
+
* only on the NEXT render — every state change is therefore followed by a
|
|
269
|
+
* repaint. React bails out of a referentially identical element, so each pass
|
|
270
|
+
* builds a fresh one.
|
|
271
|
+
*/
|
|
272
|
+
function mountBar(props: BarProps = {}) {
|
|
273
|
+
let current = props;
|
|
274
|
+
const utils = render(withTheme(<Bar {...current} />));
|
|
275
|
+
const paint = () => utils.rerender(withTheme(<Bar {...current} />));
|
|
276
|
+
paint();
|
|
277
|
+
return {
|
|
278
|
+
root: utils.UNSAFE_root,
|
|
279
|
+
paint,
|
|
280
|
+
set(next: BarProps) {
|
|
281
|
+
current = { ...current, ...next };
|
|
282
|
+
utils.rerender(withTheme(<Bar {...current} />));
|
|
283
|
+
paint();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
mockGestures.length = 0;
|
|
290
|
+
springs.mockClear();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('TabBar with no selection', () => {
|
|
294
|
+
it('finds exactly one highlight to assert on (guards against a vacuous pass)', () => {
|
|
295
|
+
expect(highlights(mountBar({ activeIndex: 0 }).root)).toHaveLength(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it.each([
|
|
299
|
+
['does not flash the highlight when it mounts with nothing selected', -1, 0],
|
|
300
|
+
['shows it on the very first frame when it mounts with a selection', 0, 1],
|
|
301
|
+
])('%s', (_case, activeIndex, opacity) => {
|
|
302
|
+
// The FIRST render pass is the only place jest can see the value the
|
|
303
|
+
// highlight is seeded with: the reanimated mock resolves `withTiming`
|
|
304
|
+
// instantly and writing a shared value does not re-render, so any repaint
|
|
305
|
+
// from here on reports what the mount effect wrote instead. Rendering once
|
|
306
|
+
// is the point — do not add a repaint.
|
|
307
|
+
const utils = render(withTheme(<Bar activeIndex={activeIndex} />));
|
|
308
|
+
expect(highlight(utils.UNSAFE_root).opacity).toBe(opacity);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('shows the highlight for every index that names a tab', () => {
|
|
312
|
+
for (const activeIndex of [0, 1, 2]) {
|
|
313
|
+
const bar = mountBar({ activeIndex });
|
|
314
|
+
expect(highlight(bar.root).opacity).toBe(1);
|
|
315
|
+
expect(translateXOf(highlight(bar.root))).toBeCloseTo(translateXFor(activeIndex));
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it.each([
|
|
320
|
+
['negative', -1],
|
|
321
|
+
['past the last tab', 3],
|
|
322
|
+
['far past the last tab', 99],
|
|
323
|
+
['fractional', 1.5],
|
|
324
|
+
['not a number at all', Number.NaN],
|
|
325
|
+
])('hides the highlight when activeIndex is %s', (_case, activeIndex) => {
|
|
326
|
+
expect(highlight(mountBar({ activeIndex }).root).opacity).toBe(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('leaves no glyph and no label tinted active', () => {
|
|
330
|
+
// A capsule that is gone but a tab still lit is the same bug wearing a
|
|
331
|
+
// different hat: `activeGlyphStyle` derives from the distance to
|
|
332
|
+
// `slideIndex`, so freezing the position alone would keep the last tab
|
|
333
|
+
// looking selected.
|
|
334
|
+
const bar = mountBar({ activeIndex: 2 });
|
|
335
|
+
expect(activeGlyphOpacities(bar.root)).toEqual([0, 0, 1]);
|
|
336
|
+
expect(labelColors(bar.root)).toEqual([INACTIVE, INACTIVE, ACTIVE]);
|
|
337
|
+
|
|
338
|
+
bar.set({ activeIndex: -1 });
|
|
339
|
+
|
|
340
|
+
expect(activeGlyphOpacities(bar.root)).toEqual([0, 0, 0]);
|
|
341
|
+
expect(labelColors(bar.root)).toEqual([INACTIVE, INACTIVE, INACTIVE]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('marks no tab selected for accessibility', () => {
|
|
345
|
+
expect(selectedFlags(mountBar({ activeIndex: -1 }).root)).toEqual([false, false, false]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('fades out in place, without moving the capsule', () => {
|
|
349
|
+
// Animating the position to a sentinel instead would drag the capsule the
|
|
350
|
+
// length of the bar on its way out, which reads as a glitch rather than as
|
|
351
|
+
// a dismissal.
|
|
352
|
+
const bar = mountBar({ activeIndex: 2 });
|
|
353
|
+
const before = translateXOf(highlight(bar.root));
|
|
354
|
+
|
|
355
|
+
bar.set({ activeIndex: -1 });
|
|
356
|
+
|
|
357
|
+
expect(translateXOf(highlight(bar.root))).toBe(before);
|
|
358
|
+
expect(highlight(bar.root).opacity).toBe(0);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('appears at the new tab instead of sliding to it', () => {
|
|
362
|
+
// Coming back from hidden there is no position the user has seen to slide
|
|
363
|
+
// FROM, and a spring across the bar would light up every tab it passed.
|
|
364
|
+
const bar = mountBar({ activeIndex: 2 });
|
|
365
|
+
bar.set({ activeIndex: -1 });
|
|
366
|
+
springs.mockClear();
|
|
367
|
+
|
|
368
|
+
bar.set({ activeIndex: 0 });
|
|
369
|
+
|
|
370
|
+
expect(springs).not.toHaveBeenCalled();
|
|
371
|
+
expect(highlight(bar.root).opacity).toBe(1);
|
|
372
|
+
expect(translateXOf(highlight(bar.root))).toBeCloseTo(translateXFor(0));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('still slides between two tabs that are both selected', () => {
|
|
376
|
+
// The contrast that keeps the test above honest: a spring IS started when
|
|
377
|
+
// the capsule was on screen the whole time.
|
|
378
|
+
const bar = mountBar({ activeIndex: 2 });
|
|
379
|
+
springs.mockClear();
|
|
380
|
+
|
|
381
|
+
bar.set({ activeIndex: 0 });
|
|
382
|
+
|
|
383
|
+
expect(springs).toHaveBeenCalledWith(0, expect.anything());
|
|
384
|
+
expect(translateXOf(highlight(bar.root))).toBeCloseTo(translateXFor(0));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('arms the highlight under the finger when a scrub starts from nowhere', () => {
|
|
388
|
+
const onIndexChange = jest.fn();
|
|
389
|
+
const bar = mountBar({ activeIndex: -1, onIndexChange });
|
|
390
|
+
expect(highlight(bar.root).opacity).toBe(0);
|
|
391
|
+
|
|
392
|
+
act(() => {
|
|
393
|
+
member('Pan').start?.({ x: NEAR_RIGHT_EDGE });
|
|
394
|
+
});
|
|
395
|
+
bar.paint();
|
|
396
|
+
|
|
397
|
+
expect(highlight(bar.root).opacity).toBe(1);
|
|
398
|
+
// Under the finger, not at the stale index it faded out on — which would
|
|
399
|
+
// also fire the first boundary tick against a phantom position.
|
|
400
|
+
expect(translateXOf(highlight(bar.root))).toBeCloseTo(translateXFor(2));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('selects on release from a scrub that started with nothing selected', () => {
|
|
404
|
+
const onIndexChange = jest.fn();
|
|
405
|
+
const bar = mountBar({ activeIndex: -1, onIndexChange });
|
|
406
|
+
|
|
407
|
+
act(() => {
|
|
408
|
+
member('Pan').start?.({ x: NEAR_RIGHT_EDGE });
|
|
409
|
+
member('Pan').update?.({ x: NEAR_LEFT_EDGE });
|
|
410
|
+
member('Pan').finalize?.();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(onIndexChange).toHaveBeenCalledWith(0);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('arms the highlight on a tap made with nothing selected', () => {
|
|
417
|
+
const onIndexChange = jest.fn();
|
|
418
|
+
const bar = mountBar({ activeIndex: -1, onIndexChange });
|
|
419
|
+
|
|
420
|
+
act(() => {
|
|
421
|
+
member('Tap').end?.({ x: NEAR_RIGHT_EDGE }, true);
|
|
422
|
+
});
|
|
423
|
+
bar.paint();
|
|
424
|
+
|
|
425
|
+
expect(onIndexChange).toHaveBeenCalledWith(2);
|
|
426
|
+
expect(highlight(bar.root).opacity).toBe(1);
|
|
427
|
+
expect(translateXOf(highlight(bar.root))).toBeCloseTo(translateXFor(2));
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe('TabBar focus-driven path (no activeIndex at all)', () => {
|
|
432
|
+
/**
|
|
433
|
+
* The regression this whole change is most dangerous to. Omitting
|
|
434
|
+
* `activeIndex` is the router adapter's contract, NOT an absent selection: the
|
|
435
|
+
* bar is not the writer, each button drives the highlight from its own
|
|
436
|
+
* `isFocused`. Treating it as "no selection" would leave every adapter
|
|
437
|
+
* consumer with an invisible highlight — and nothing else in the suite would
|
|
438
|
+
* notice, because the capsule would still be in the right PLACE.
|
|
439
|
+
*/
|
|
440
|
+
it('still shows the highlight, on the focused tab', () => {
|
|
441
|
+
const bar = mountBar({ focusedIndex: 2 });
|
|
442
|
+
expect(highlight(bar.root).opacity).toBe(1);
|
|
443
|
+
expect(translateXOf(highlight(bar.root))).toBeCloseTo(translateXFor(2));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('still tints the focused tab, and only that one', () => {
|
|
447
|
+
const bar = mountBar({ focusedIndex: 1 });
|
|
448
|
+
expect(activeGlyphOpacities(bar.root)).toEqual([0, 1, 0]);
|
|
449
|
+
expect(labelColors(bar.root)).toEqual([INACTIVE, ACTIVE, INACTIVE]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('still follows focus as it moves', () => {
|
|
453
|
+
const bar = mountBar({ focusedIndex: 0 });
|
|
454
|
+
bar.set({ focusedIndex: 2 });
|
|
455
|
+
expect(highlight(bar.root).opacity).toBe(1);
|
|
456
|
+
expect(translateXOf(highlight(bar.root))).toBeCloseTo(translateXFor(2));
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -63,6 +63,30 @@ function sourceFiles(dir: string): string[] {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A mapper's deps array: the LAST bracket group in the call text, matched by
|
|
68
|
+
* depth from its closing `]`.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately not a regex — see the same helper in `animated-style-deps.test.ts`.
|
|
71
|
+
* A lazy `/,\s*(\[[\s\S]*?\])\s*,?\s*$/` anchors on the first comma followed by a
|
|
72
|
+
* bracket, so in a mapper carrying an inline `interpolate(…, [0, 1], [a, b])`
|
|
73
|
+
* range it captures from THERE to the end — which is never `[]`, so a genuinely
|
|
74
|
+
* empty deps array on the busiest mappers in this file passed unnoticed.
|
|
75
|
+
*/
|
|
76
|
+
function trailingDeps(text: string): string | null {
|
|
77
|
+
const trimmed = text.replace(/[\s,]+$/, '');
|
|
78
|
+
if (!trimmed.endsWith(']')) return null;
|
|
79
|
+
let depth = 0;
|
|
80
|
+
for (let i = trimmed.length - 1; i >= 0; i -= 1) {
|
|
81
|
+
if (trimmed[i] === ']') depth += 1;
|
|
82
|
+
else if (trimmed[i] === '[') {
|
|
83
|
+
depth -= 1;
|
|
84
|
+
if (depth === 0) return trimmed.slice(i);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
/** Text between the parens of the call starting at `start`, paren-balanced. */
|
|
67
91
|
function callText(source: string, start: number): string {
|
|
68
92
|
const open = source.indexOf('(', start);
|
|
@@ -95,13 +119,13 @@ function analyse(): { offenders: Offender[]; files: number; calls: number } {
|
|
|
95
119
|
const line = source.slice(0, call.index).split('\n').length;
|
|
96
120
|
const location = `${relative(SRC, file)}:${line}`;
|
|
97
121
|
|
|
98
|
-
//
|
|
99
|
-
const deps =
|
|
100
|
-
if (
|
|
122
|
+
// A trailing comma after the deps array is idiomatic and must parse.
|
|
123
|
+
const deps = trailingDeps(text);
|
|
124
|
+
if (deps === null) {
|
|
101
125
|
offenders.push({ location, mapper, problem: 'no deps array' });
|
|
102
126
|
continue;
|
|
103
127
|
}
|
|
104
|
-
if (
|
|
128
|
+
if (deps.replace(/\s/g, '') === '[]') {
|
|
105
129
|
offenders.push({ location, mapper, problem: 'empty deps array' });
|
|
106
130
|
}
|
|
107
131
|
}
|