@rn-tools/navigation 2.2.6 → 3.0.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/package.json +6 -15
- package/readme.md +68 -0
- package/src/index.ts +2 -2
- package/src/navigation-client.test.tsx +206 -0
- package/src/navigation-client.tsx +216 -0
- package/src/navigation.tsx +38 -147
- package/src/stack.test.tsx +341 -0
- package/src/stack.tsx +78 -307
- package/src/tabs.test.tsx +288 -0
- package/src/tabs.tsx +142 -291
- package/README.md +0 -788
- package/src/__tests__/navigation-reducer.test.tsx +0 -346
- package/src/__tests__/stack.test.tsx +0 -48
- package/src/contexts.tsx +0 -8
- package/src/deep-links.tsx +0 -37
- package/src/navigation-reducer.ts +0 -487
- package/src/navigation-store.ts +0 -58
- package/src/types.ts +0 -48
- package/src/utils.ts +0 -41
package/src/stack.tsx
CHANGED
|
@@ -1,328 +1,99 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { RenderTreeNode, useRenderNode } from "@rn-tools/core";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from "react-native";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
ScreenStackHeaderConfig as RNScreenStackHeaderConfig,
|
|
16
|
-
ScreenStackHeaderLeftView as RNScreenStackHeaderLeftView,
|
|
17
|
-
ScreenStackHeaderRightView as RNScreenStackHeaderRightView,
|
|
18
|
-
ScreenStackHeaderCenterView as RNScreenStackHeaderCenterView,
|
|
19
|
-
ScreenStackHeaderConfigProps as RNScreenStackHeaderConfigProps,
|
|
20
|
-
ScreenStackHeaderBackButtonImage as RNScreenStackHeaderBackButtonImage,
|
|
21
|
-
type ScreenProps,
|
|
22
|
-
} from "react-native-screens";
|
|
23
|
-
import ScreenStackNativeComponent from "react-native-screens/src/fabric/ScreenStackNativeComponent";
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
ActiveContext,
|
|
27
|
-
DepthContext,
|
|
28
|
-
TabIdContext,
|
|
29
|
-
TabScreenIndexContext,
|
|
30
|
-
} from "./contexts";
|
|
31
|
-
import { DEFAULT_SLOT_NAME } from "./navigation-reducer";
|
|
32
|
-
import { useNavigationDispatch, useNavigationState } from "./navigation-store";
|
|
33
|
-
import type { StackItem } from "./types";
|
|
34
|
-
import { generateStackId, useSafeAreaInsetsSafe } from "./utils";
|
|
35
|
-
|
|
36
|
-
let StackIdContext = React.createContext<string>("");
|
|
37
|
-
let ScreenIdContext = React.createContext<string>("");
|
|
38
|
-
|
|
39
|
-
// Component returned from `react-native-screens` references `react-navigation` data structures in recent updates
|
|
40
|
-
// This is a workaround to make it work with our custom navigation
|
|
41
|
-
let RNScreenStack = React.memo(function RNScreenStack(
|
|
42
|
-
props: RNScreenStackProps
|
|
43
|
-
) {
|
|
44
|
-
let { children, gestureDetectorBridge, ...rest } = props;
|
|
45
|
-
let ref = React.useRef(null);
|
|
46
|
-
|
|
47
|
-
React.useEffect(() => {
|
|
48
|
-
if (gestureDetectorBridge) {
|
|
49
|
-
gestureDetectorBridge.current.stackUseEffectCallback(ref);
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
<ScreenStackNativeComponent {...rest} ref={ref}>
|
|
55
|
-
{children}
|
|
56
|
-
</ScreenStackNativeComponent>
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
export type StackRootProps = {
|
|
61
|
-
children: React.ReactNode;
|
|
62
|
-
id?: string;
|
|
4
|
+
useStackScreens,
|
|
5
|
+
useNavigation,
|
|
6
|
+
type PushScreenOptions,
|
|
7
|
+
} from "./navigation-client";
|
|
8
|
+
|
|
9
|
+
// TODO - replace with custom implementation
|
|
10
|
+
import * as RNScreens from "react-native-screens";
|
|
11
|
+
import { StyleSheet } from "react-native";
|
|
12
|
+
|
|
13
|
+
export type StackHandle = {
|
|
14
|
+
pushScreen: (element: React.ReactElement, options?: PushScreenOptions) => void;
|
|
15
|
+
popScreen: () => void;
|
|
63
16
|
};
|
|
64
17
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
function StackRoot({ children, id }: StackRootProps) {
|
|
73
|
-
let idRef = React.useRef(id || generateStackId());
|
|
74
|
-
let stack = useStackInternal(idRef.current);
|
|
75
|
-
|
|
76
|
-
let isActive = React.useContext(ActiveContext);
|
|
77
|
-
let parentDepth = React.useContext(DepthContext);
|
|
78
|
-
let parentStackId = React.useContext(StackIdContext);
|
|
79
|
-
|
|
80
|
-
let depth = parentDepth + 1;
|
|
81
|
-
let stackId = idRef.current;
|
|
82
|
-
let parentTabId = React.useContext(TabIdContext);
|
|
83
|
-
let tabIndex = React.useContext(TabScreenIndexContext);
|
|
84
|
-
|
|
85
|
-
let dispatch = useNavigationDispatch();
|
|
86
|
-
|
|
87
|
-
React.useLayoutEffect(() => {
|
|
88
|
-
if (!stack) {
|
|
89
|
-
dispatch({ type: "CREATE_STACK_INSTANCE", stackId: idRef.current });
|
|
90
|
-
}
|
|
91
|
-
}, [stack, dispatch]);
|
|
92
|
-
|
|
93
|
-
React.useEffect(() => {
|
|
94
|
-
if (stack != null) {
|
|
95
|
-
dispatch({
|
|
96
|
-
type: "REGISTER_STACK",
|
|
97
|
-
depth,
|
|
98
|
-
isActive,
|
|
99
|
-
stackId: stack.id,
|
|
100
|
-
parentStackId,
|
|
101
|
-
parentTabId,
|
|
102
|
-
tabIndex,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
}, [stack, depth, isActive, parentStackId, parentTabId, tabIndex, dispatch]);
|
|
106
|
-
|
|
107
|
-
React.useEffect(() => {
|
|
108
|
-
return () => {
|
|
109
|
-
if (stackId != null) {
|
|
110
|
-
dispatch({ type: "UNREGISTER_STACK", stackId });
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
}, [stackId, dispatch]);
|
|
114
|
-
|
|
115
|
-
if (!stack) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return (
|
|
120
|
-
<StackIdContext.Provider value={stack.id}>
|
|
121
|
-
<DepthContext.Provider value={depth}>
|
|
122
|
-
<ActiveContext.Provider value={isActive}>
|
|
123
|
-
{children}
|
|
124
|
-
</ActiveContext.Provider>
|
|
125
|
-
</DepthContext.Provider>
|
|
126
|
-
</StackIdContext.Provider>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export type StackScreensProps = RNScreenStackProps;
|
|
131
|
-
|
|
132
|
-
function StackScreens({ style: styleProp, ...props }: StackScreensProps) {
|
|
133
|
-
let style = React.useMemo(
|
|
134
|
-
() => styleProp || StyleSheet.absoluteFill,
|
|
135
|
-
[styleProp]
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
return <RNScreenStack {...props} style={style} />;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
let defaultScreenStyle: ViewStyle = {
|
|
142
|
-
...StyleSheet.absoluteFillObject,
|
|
143
|
-
backgroundColor: "white",
|
|
18
|
+
export type StackProps = {
|
|
19
|
+
id?: string;
|
|
20
|
+
active?: boolean;
|
|
21
|
+
rootScreen?: React.ReactElement;
|
|
22
|
+
children?: React.ReactNode;
|
|
144
23
|
};
|
|
145
24
|
|
|
146
|
-
|
|
147
|
-
header?: React.ReactElement<StackScreenHeaderProps>
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
let HeaderHeightContext = React.createContext<number>(0);
|
|
151
|
-
|
|
152
|
-
let StackScreen = React.memo(function StackScreen({
|
|
153
|
-
children,
|
|
154
|
-
style: styleProp,
|
|
155
|
-
gestureEnabled = true,
|
|
156
|
-
onDismissed: onDismissedProp,
|
|
157
|
-
onHeaderHeightChange: onHeaderHeightChangeProp,
|
|
158
|
-
header,
|
|
159
|
-
...props
|
|
160
|
-
}: StackScreenProps) {
|
|
161
|
-
let stackId = React.useContext(StackIdContext);
|
|
162
|
-
let screenId = React.useContext(ScreenIdContext);
|
|
163
|
-
let stack = useStackInternal(stackId);
|
|
164
|
-
|
|
165
|
-
let dispatch = useNavigationDispatch();
|
|
166
|
-
|
|
167
|
-
let isActive = React.useContext(ActiveContext);
|
|
168
|
-
|
|
169
|
-
let onDismissed: RNScreenProps["onDismissed"] = React.useCallback(
|
|
170
|
-
(e) => {
|
|
171
|
-
dispatch({ type: "POP_SCREEN_BY_KEY", key: screenId });
|
|
172
|
-
onDismissedProp?.(e);
|
|
173
|
-
},
|
|
174
|
-
[onDismissedProp, dispatch, screenId]
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
React.useEffect(() => {
|
|
178
|
-
function backHandler() {
|
|
179
|
-
if (gestureEnabled && isActive && stack?.screens.length > 0) {
|
|
180
|
-
dispatch({ type: "POP_SCREEN_BY_KEY", key: screenId });
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
BackHandler.addEventListener("hardwareBackPress", backHandler);
|
|
188
|
-
|
|
189
|
-
return () => {
|
|
190
|
-
BackHandler.removeEventListener("hardwareBackPress", backHandler);
|
|
191
|
-
};
|
|
192
|
-
}, [gestureEnabled, stack, screenId, isActive, dispatch]);
|
|
193
|
-
|
|
194
|
-
let parentHeaderHeight = React.useContext(HeaderHeightContext);
|
|
195
|
-
let [headerHeight, setHeaderHeight] = React.useState(parentHeaderHeight);
|
|
196
|
-
|
|
197
|
-
let onHeaderHeightChange: ScreenProps["onHeaderHeightChange"] =
|
|
198
|
-
React.useCallback(
|
|
199
|
-
(e) => {
|
|
200
|
-
Platform.OS === "ios" &&
|
|
201
|
-
e.nativeEvent.headerHeight > 0 &&
|
|
202
|
-
setHeaderHeight(e.nativeEvent.headerHeight);
|
|
203
|
-
onHeaderHeightChangeProp?.(e);
|
|
204
|
-
},
|
|
205
|
-
[onHeaderHeightChangeProp]
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
let style = React.useMemo(
|
|
209
|
-
() => [
|
|
210
|
-
defaultScreenStyle,
|
|
211
|
-
{ paddingTop: headerHeight || parentHeaderHeight },
|
|
212
|
-
styleProp,
|
|
213
|
-
],
|
|
214
|
-
[styleProp, headerHeight, parentHeaderHeight]
|
|
215
|
-
);
|
|
216
|
-
|
|
25
|
+
const StackRoot = React.memo(function StackRoot(props: StackProps) {
|
|
217
26
|
return (
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
{
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
gestureEnabled={gestureEnabled}
|
|
225
|
-
onDismissed={onDismissed}
|
|
226
|
-
onHeaderHeightChange={onHeaderHeightChange}
|
|
227
|
-
>
|
|
228
|
-
{header}
|
|
229
|
-
{children}
|
|
230
|
-
</RNScreen>
|
|
231
|
-
</HeaderHeightContext.Provider>
|
|
27
|
+
<RenderTreeNode type="stack" id={props.id} active={props.active}>
|
|
28
|
+
<RNScreens.ScreenStack style={StyleSheet.absoluteFill}>
|
|
29
|
+
{props.rootScreen && <StackScreen>{props.rootScreen}</StackScreen>}
|
|
30
|
+
{props.children}
|
|
31
|
+
</RNScreens.ScreenStack>
|
|
32
|
+
</RenderTreeNode>
|
|
232
33
|
);
|
|
233
34
|
});
|
|
234
35
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
stack?.screens
|
|
240
|
-
.map((screenId) => state.screens.lookup[screenId])
|
|
241
|
-
.filter((s) => s && s.slotName === slotName) ?? []
|
|
242
|
-
);
|
|
243
|
-
});
|
|
36
|
+
export type StackScreenProps = {
|
|
37
|
+
id?: string;
|
|
38
|
+
active?: boolean;
|
|
39
|
+
children: React.ReactNode;
|
|
244
40
|
};
|
|
245
41
|
|
|
246
|
-
|
|
247
|
-
slotName = DEFAULT_SLOT_NAME,
|
|
248
|
-
}: {
|
|
249
|
-
slotName?: string;
|
|
250
|
-
}) {
|
|
251
|
-
let stackId = React.useContext(StackIdContext);
|
|
252
|
-
let screens = useStackScreens(stackId, slotName);
|
|
253
|
-
|
|
42
|
+
const StackScreen = React.memo(function StackScreen(props: StackScreenProps) {
|
|
254
43
|
return (
|
|
255
|
-
|
|
256
|
-
{
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
</ScreenIdContext.Provider>
|
|
261
|
-
);
|
|
262
|
-
})}
|
|
263
|
-
</>
|
|
44
|
+
<RNScreens.Screen style={StyleSheet.absoluteFill}>
|
|
45
|
+
<RenderTreeNode type="screen" id={props.id} active={props.active}>
|
|
46
|
+
{props.children}
|
|
47
|
+
</RenderTreeNode>
|
|
48
|
+
</RNScreens.Screen>
|
|
264
49
|
);
|
|
265
50
|
});
|
|
266
51
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}: StackScreenHeaderProps) {
|
|
272
|
-
return <RNScreenStackHeaderConfig {...props} />;
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
let StackScreenHeaderLeft = React.memo(function StackScreenHeaderLeft({
|
|
276
|
-
...props
|
|
277
|
-
}: ViewProps) {
|
|
278
|
-
return <RNScreenStackHeaderLeftView {...props} />;
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
let StackScreenHeaderCenter = React.memo(function StackScreenHeaderCenter({
|
|
282
|
-
...props
|
|
283
|
-
}: ViewProps) {
|
|
284
|
-
return <RNScreenStackHeaderCenterView {...props} />;
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
let StackScreenHeaderRight = React.memo(function StackScreenHeaderRight({
|
|
288
|
-
...props
|
|
289
|
-
}: ViewProps) {
|
|
290
|
-
return <RNScreenStackHeaderRightView {...props} />;
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
let ScreenStackHeaderBackButtonImage = React.memo(
|
|
294
|
-
function ScreenStackHeaderBackButtonImage(props: ImageProps) {
|
|
295
|
-
return <RNScreenStackHeaderBackButtonImage {...props} />;
|
|
296
|
-
}
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
export type StackNavigatorProps = Omit<StackRootProps, "children"> & {
|
|
300
|
-
rootScreen: React.ReactElement<unknown>;
|
|
301
|
-
};
|
|
52
|
+
const StackSlot = React.memo(function StackSlot() {
|
|
53
|
+
const node = useRenderNode();
|
|
54
|
+
const stackKey = node?.id ?? null;
|
|
55
|
+
const screens = useStackScreens(stackKey);
|
|
302
56
|
|
|
303
|
-
let StackNavigator = React.memo(function StackNavigator({
|
|
304
|
-
rootScreen,
|
|
305
|
-
...rootProps
|
|
306
|
-
}: StackNavigatorProps) {
|
|
307
57
|
return (
|
|
308
|
-
<
|
|
309
|
-
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
58
|
+
<React.Fragment>
|
|
59
|
+
{screens.map((screen, index, arr) => (
|
|
60
|
+
<StackScreen
|
|
61
|
+
id={screen.options?.id}
|
|
62
|
+
key={screen.options?.id ?? index}
|
|
63
|
+
active={index === arr.length - 1}
|
|
64
|
+
>
|
|
65
|
+
{screen.element}
|
|
66
|
+
</StackScreen>
|
|
67
|
+
))}
|
|
68
|
+
</React.Fragment>
|
|
314
69
|
);
|
|
315
70
|
});
|
|
316
71
|
|
|
317
|
-
export
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
72
|
+
export const Stack = React.memo(
|
|
73
|
+
React.forwardRef<StackHandle, Omit<StackProps, "children">>(
|
|
74
|
+
function Stack(props, ref) {
|
|
75
|
+
const navigation = useNavigation();
|
|
76
|
+
const node = useRenderNode();
|
|
77
|
+
const stackId = props.id ?? node?.id ?? null;
|
|
78
|
+
|
|
79
|
+
React.useImperativeHandle(ref, () => ({
|
|
80
|
+
pushScreen(element: React.ReactElement, options?: PushScreenOptions) {
|
|
81
|
+
if (stackId) {
|
|
82
|
+
navigation.pushScreen(element, { ...options, stackId });
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
popScreen() {
|
|
86
|
+
if (stackId) {
|
|
87
|
+
navigation.popScreen({ stackId });
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}), [stackId, navigation]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<StackRoot {...props}>
|
|
94
|
+
<StackSlot />
|
|
95
|
+
</StackRoot>
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
),
|
|
99
|
+
);
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { act, render, waitFor, fireEvent } from "@testing-library/react";
|
|
4
|
+
import { RenderNodeProbe } from "@rn-tools/core/mocks/render-node-probe";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createNavigation,
|
|
8
|
+
type NavigationStateInput,
|
|
9
|
+
} from "./navigation-client";
|
|
10
|
+
import { Navigation } from "./navigation";
|
|
11
|
+
import { Tabs, type TabScreenOptions, type TabsHandle } from "./tabs";
|
|
12
|
+
import { Stack } from "./stack";
|
|
13
|
+
|
|
14
|
+
function makeScreens(count: number): TabScreenOptions[] {
|
|
15
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
16
|
+
id: `tab-${i}`,
|
|
17
|
+
screen: (
|
|
18
|
+
<RenderNodeProbe
|
|
19
|
+
render={(data) => (
|
|
20
|
+
<span>{`tab-${i}:${data.type}:${String(data.active)}`}</span>
|
|
21
|
+
)}
|
|
22
|
+
/>
|
|
23
|
+
),
|
|
24
|
+
tab: ({ isActive, onPress }) => (
|
|
25
|
+
<span data-testid={`tab-btn-${i}`} onClick={onPress}>
|
|
26
|
+
{`tab-btn-${i}:${String(isActive)}`}
|
|
27
|
+
</span>
|
|
28
|
+
),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderWithProviders(
|
|
33
|
+
node: React.ReactNode,
|
|
34
|
+
initialState?: NavigationStateInput,
|
|
35
|
+
) {
|
|
36
|
+
const navigation = createNavigation(initialState);
|
|
37
|
+
const renderer = render(
|
|
38
|
+
<Navigation navigation={navigation}>{node}</Navigation>,
|
|
39
|
+
);
|
|
40
|
+
return { store: navigation.store, navigation, renderer };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Tabs", () => {
|
|
44
|
+
it("renders each screen inside a screen render tree node", () => {
|
|
45
|
+
const screens = makeScreens(2);
|
|
46
|
+
const { renderer } = renderWithProviders(
|
|
47
|
+
<Tabs id="my-tabs" screens={screens} />,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
|
|
51
|
+
expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("defaults active tab index to 0", () => {
|
|
55
|
+
const screens = makeScreens(3);
|
|
56
|
+
const { renderer } = renderWithProviders(
|
|
57
|
+
<Tabs id="my-tabs" screens={screens} />,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
|
|
61
|
+
expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
|
|
62
|
+
expect(renderer.getByText("tab-2:tab-screen:false")).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("setActiveTab changes which screen is active", async () => {
|
|
66
|
+
const screens = makeScreens(3);
|
|
67
|
+
const { navigation, renderer } = renderWithProviders(
|
|
68
|
+
<Tabs id="my-tabs" screens={screens} />,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
act(() => {
|
|
72
|
+
navigation.setActiveTab(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
|
|
77
|
+
expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
|
|
78
|
+
expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("respects the active flag from the tabs container", () => {
|
|
83
|
+
const screens = makeScreens(1);
|
|
84
|
+
const { renderer } = renderWithProviders(
|
|
85
|
+
<Tabs id="my-tabs" active={false} screens={screens} />,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("ref.setActiveIndex changes the active tab", async () => {
|
|
92
|
+
const screens = makeScreens(3);
|
|
93
|
+
const ref = React.createRef<TabsHandle>();
|
|
94
|
+
const { renderer } = renderWithProviders(
|
|
95
|
+
<Tabs ref={ref} id="my-tabs" screens={screens} />,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
|
|
99
|
+
|
|
100
|
+
act(() => {
|
|
101
|
+
ref.current!.setActiveIndex(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
|
|
106
|
+
expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
|
|
107
|
+
expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("supports preloaded activeIndex from the navigation state", () => {
|
|
112
|
+
const screens = makeScreens(3);
|
|
113
|
+
const { renderer } = renderWithProviders(
|
|
114
|
+
<Tabs id="my-tabs" screens={screens} />,
|
|
115
|
+
{ tabs: { "my-tabs": { activeIndex: 1 } } },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
|
|
119
|
+
expect(renderer.getByText("tab-1:tab-screen:true")).toBeTruthy();
|
|
120
|
+
expect(renderer.getByText("tab-2:tab-screen:false")).toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("TabBar", () => {
|
|
125
|
+
it("renders tab items with correct isActive state", () => {
|
|
126
|
+
const screens = makeScreens(3);
|
|
127
|
+
const { renderer } = renderWithProviders(
|
|
128
|
+
<Tabs id="my-tabs" screens={screens} />,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(renderer.getByText("tab-btn-0:true")).toBeTruthy();
|
|
132
|
+
expect(renderer.getByText("tab-btn-1:false")).toBeTruthy();
|
|
133
|
+
expect(renderer.getByText("tab-btn-2:false")).toBeTruthy();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("onPress switches the active tab", async () => {
|
|
137
|
+
const screens = makeScreens(3);
|
|
138
|
+
const { renderer } = renderWithProviders(
|
|
139
|
+
<Tabs id="my-tabs" screens={screens} />,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
fireEvent.click(renderer.getByTestId("tab-btn-2"));
|
|
143
|
+
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
expect(renderer.getByText("tab-btn-0:false")).toBeTruthy();
|
|
146
|
+
expect(renderer.getByText("tab-btn-2:true")).toBeTruthy();
|
|
147
|
+
expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
|
|
148
|
+
expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("renders tabbar at bottom by default", () => {
|
|
153
|
+
const screens = makeScreens(1);
|
|
154
|
+
const { renderer } = renderWithProviders(
|
|
155
|
+
<Tabs id="my-tabs" screens={screens} />,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Screen content should appear before the tab bar in the DOM
|
|
159
|
+
const screenNode = renderer.getByText("tab-0:tab-screen:true");
|
|
160
|
+
const tabNode = renderer.getByText("tab-btn-0:true");
|
|
161
|
+
const order = screenNode.compareDocumentPosition(tabNode);
|
|
162
|
+
// DOCUMENT_POSITION_FOLLOWING = 4
|
|
163
|
+
expect(order & 4).toBe(4);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("renders tabbar at top when tabbarPosition is top", () => {
|
|
167
|
+
const screens = makeScreens(1);
|
|
168
|
+
const { renderer } = renderWithProviders(
|
|
169
|
+
<Tabs id="my-tabs" screens={screens} tabbarPosition="top" />,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Tab bar should appear before screen content in the DOM
|
|
173
|
+
const tabNode = renderer.getByText("tab-btn-0:true");
|
|
174
|
+
const screenNode = renderer.getByText("tab-0:tab-screen:true");
|
|
175
|
+
const order = tabNode.compareDocumentPosition(screenNode);
|
|
176
|
+
// DOCUMENT_POSITION_FOLLOWING = 4
|
|
177
|
+
expect(order & 4).toBe(4);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("Nested Stack + Tabs", () => {
|
|
182
|
+
it("pushScreen targets the stack inside the active tab", async () => {
|
|
183
|
+
const navigation = createNavigation();
|
|
184
|
+
|
|
185
|
+
const screens: TabScreenOptions[] = [
|
|
186
|
+
{
|
|
187
|
+
id: "tab-a",
|
|
188
|
+
screen: <Stack id="stack-a" rootScreen={<span>stack-a-root</span>} />,
|
|
189
|
+
tab: () => <span>tab-a</span>,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "tab-b",
|
|
193
|
+
screen: <Stack id="stack-b" rootScreen={<span>stack-b-root</span>} />,
|
|
194
|
+
tab: () => <span>tab-b</span>,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
const result = render(
|
|
199
|
+
<Navigation navigation={navigation}>
|
|
200
|
+
<Tabs id="my-tabs" screens={screens} />
|
|
201
|
+
</Navigation>,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(result.getByText("stack-a-root")).toBeTruthy();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Tab 0 (stack-a) is active — pushScreen should target stack-a
|
|
209
|
+
act(() => {
|
|
210
|
+
navigation.pushScreen(<span>pushed-to-a</span>);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const stateAfterFirst = navigation.store.getState();
|
|
214
|
+
expect(stateAfterFirst.stacks.get("stack-a")).toHaveLength(1);
|
|
215
|
+
expect(stateAfterFirst.stacks.has("stack-b")).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("switching tabs redirects pushScreen to the newly active stack", async () => {
|
|
219
|
+
const navigation = createNavigation();
|
|
220
|
+
|
|
221
|
+
const screens: TabScreenOptions[] = [
|
|
222
|
+
{
|
|
223
|
+
id: "tab-a",
|
|
224
|
+
screen: <Stack id="stack-a" rootScreen={<span>stack-a-root</span>} />,
|
|
225
|
+
tab: () => <span>tab-a</span>,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "tab-b",
|
|
229
|
+
screen: <Stack id="stack-b" rootScreen={<span>stack-b-root</span>} />,
|
|
230
|
+
tab: () => <span>tab-b</span>,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
render(
|
|
235
|
+
<Navigation navigation={navigation}>
|
|
236
|
+
<Tabs id="my-tabs" screens={screens} />
|
|
237
|
+
</Navigation>,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Switch to tab 1 (stack-b)
|
|
241
|
+
act(() => {
|
|
242
|
+
navigation.setActiveTab(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
act(() => {
|
|
246
|
+
navigation.pushScreen(<span>pushed-to-b</span>);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const state = navigation.store.getState();
|
|
250
|
+
expect(state.stacks.get("stack-b")).toHaveLength(1);
|
|
251
|
+
expect(state.stacks.has("stack-a")).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("setActiveTab resolves the correct tabs when a stack wraps tabs", async () => {
|
|
255
|
+
const navigation = createNavigation();
|
|
256
|
+
|
|
257
|
+
const tabScreens: TabScreenOptions[] = [
|
|
258
|
+
{
|
|
259
|
+
id: "tab-a",
|
|
260
|
+
screen: <span>tab-a-content</span>,
|
|
261
|
+
tab: () => <span>tab-a</span>,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: "tab-b",
|
|
265
|
+
screen: <span>tab-b-content</span>,
|
|
266
|
+
tab: () => <span>tab-b</span>,
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const result = render(
|
|
271
|
+
<Navigation navigation={navigation}>
|
|
272
|
+
<Stack id="outer-stack" rootScreen={<Tabs id="inner-tabs" screens={tabScreens} />} />
|
|
273
|
+
</Navigation>,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
expect(result.getByText("tab-a-content")).toBeTruthy();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// setActiveTab with no explicit tabsId should resolve inner-tabs
|
|
281
|
+
act(() => {
|
|
282
|
+
navigation.setActiveTab(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const state = navigation.store.getState();
|
|
286
|
+
expect(state.tabs.get("inner-tabs")).toEqual({ activeIndex: 1 });
|
|
287
|
+
});
|
|
288
|
+
});
|