@rn-tools/navigation 3.0.3 → 3.0.4
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 +8 -7
- package/readme.md +31 -10
- package/src/index.ts +5 -0
- package/src/navigation-client.test.tsx +70 -36
- package/src/navigation-client.tsx +79 -39
- package/src/navigation.tsx +10 -7
- package/src/stack.test.tsx +54 -30
- package/src/stack.tsx +40 -19
- package/src/tabs.test.tsx +67 -46
- package/src/tabs.tsx +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rn-tools/navigation",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.4",
|
|
4
4
|
"main": "./src/index.ts",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -8,25 +8,25 @@
|
|
|
8
8
|
],
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc",
|
|
11
|
-
"test": "
|
|
11
|
+
"test": "jest",
|
|
12
12
|
"lint": "eslint -c ./.eslintrc.js ."
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@rn-tools/eslint-config": "*",
|
|
16
|
-
"@testing-library/react": "^
|
|
16
|
+
"@testing-library/react-native": "^13.3.3",
|
|
17
|
+
"@types/jest": "^29.5.14",
|
|
17
18
|
"@types/react": "~18.2.79",
|
|
18
19
|
"@types/react-native": "^0.72.8",
|
|
19
|
-
"@types/react-test-renderer": "^18",
|
|
20
20
|
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
|
21
21
|
"@typescript-eslint/parser": "^6.8.0",
|
|
22
22
|
"babel-preset-expo": "~11.0.0",
|
|
23
23
|
"eslint": "^8.56.0",
|
|
24
24
|
"expo": "~52.0.0",
|
|
25
|
+
"jest": "^29.7.0",
|
|
26
|
+
"jest-expo": "~52.0.4",
|
|
25
27
|
"lint-staged": "^15.2.0",
|
|
26
28
|
"prettier": "^3.2.1",
|
|
27
|
-
"
|
|
28
|
-
"typescript": "~5.3.3",
|
|
29
|
-
"vitest": "^1.6.0"
|
|
29
|
+
"typescript": "~5.3.3"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"react": "*",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"react-native-screens": "*"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@rn-tools/notifications": "*",
|
|
37
38
|
"@rn-tools/sheets": "*",
|
|
38
39
|
"path-to-regexp": "^6.2.2",
|
|
39
40
|
"zustand": "^4.5.2"
|
package/readme.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @rn-tools/navigation
|
|
2
2
|
|
|
3
|
-
Navigation primitives for React Native. Built on `react-native-screens
|
|
3
|
+
Navigation primitives for React Native. Built on `react-native-screens` with integrated sheets and notifications support.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -51,21 +51,42 @@ export default function App() {
|
|
|
51
51
|
}
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
Navigate screens, present sheets, and trigger notifications imperatively:
|
|
55
55
|
|
|
56
56
|
```tsx
|
|
57
|
-
navigation.push(<DetailScreen />, { id: "detail" });
|
|
58
|
-
navigation.pop();
|
|
59
|
-
navigation.tab(1);
|
|
60
|
-
navigation.present(<EditSheet />, { id: "edit", snapPoints: [320, 520] });
|
|
61
|
-
navigation.dismiss();
|
|
62
|
-
navigation.dismissAll();
|
|
57
|
+
navigation.stack.push(<DetailScreen />, { id: "detail" });
|
|
58
|
+
navigation.stack.pop();
|
|
59
|
+
navigation.tabs.tab(1);
|
|
60
|
+
navigation.sheets.present(<EditSheet />, { id: "edit", snapPoints: [320, 520] });
|
|
61
|
+
navigation.sheets.dismiss();
|
|
62
|
+
navigation.sheets.dismissAll();
|
|
63
|
+
navigation.notifications.present(<SavedNotification />, { id: "saved", position: "top", durationMs: 3000 });
|
|
64
|
+
navigation.notifications.dismiss();
|
|
65
|
+
navigation.notifications.dismiss("bottom");
|
|
66
|
+
navigation.notifications.dismiss("saved");
|
|
63
67
|
```
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
`durationMs` defaults to `3000`; pass `null` for persistent notifications.
|
|
70
|
+
|
|
71
|
+
Presented sheet and notification elements receive an injected optional `dismiss?: () => void` prop.
|
|
72
|
+
|
|
73
|
+
When no explicit target is provided:
|
|
74
|
+
- `push/pop/tab` resolve the deepest active stack/tabs node.
|
|
75
|
+
- `dismiss()` resolves the active sheet.
|
|
76
|
+
- `notifications.dismiss()` resolves the latest non-closing top-lane notification.
|
|
77
|
+
|
|
78
|
+
Hooks are also re-exported for convenience:
|
|
79
|
+
- `useSheetEntry` (from `@rn-tools/sheets`)
|
|
80
|
+
- `useNotificationEntry` (from `@rn-tools/notifications`)
|
|
81
|
+
|
|
82
|
+
Injected-prop typings are re-exported as well:
|
|
83
|
+
- `SheetInjectedProps`
|
|
84
|
+
- `NotificationInjectedProps`
|
|
66
85
|
|
|
67
86
|
## Docs
|
|
68
87
|
|
|
69
|
-
- [Navigation](docs/navigation.md) — setup, `createNavigation`, `NavigationClient` API, hooks
|
|
88
|
+
- [Navigation](docs/navigation.md) — setup, `createNavigation`, `NavigationClient` API (screens + sheets + notifications), hooks
|
|
70
89
|
- [Stack](docs/stack.md) — stack navigation, pushing/popping, refs, preloading, nesting
|
|
71
90
|
- [Tabs](docs/tabs.md) — tab navigation, tab bar, refs, preloading, nesting with stacks
|
|
91
|
+
- [Sheets](../sheets/README.md) — sheet setup, API, and props
|
|
92
|
+
- [Notifications](../notifications/README.md) — notification setup, API, and props
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,8 @@ export * from "./stack";
|
|
|
2
2
|
export * from "./navigation-client";
|
|
3
3
|
export { Navigation, type NavigationProps } from "./navigation";
|
|
4
4
|
export * from "./tabs";
|
|
5
|
+
export { useSheetEntry, type SheetInjectedProps } from "@rn-tools/sheets";
|
|
6
|
+
export {
|
|
7
|
+
useNotificationEntry,
|
|
8
|
+
type NotificationInjectedProps,
|
|
9
|
+
} from "@rn-tools/notifications";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { Text } from "react-native";
|
|
3
3
|
import {
|
|
4
4
|
createNavigation,
|
|
5
5
|
createNavigationState,
|
|
@@ -18,7 +18,7 @@ describe("createNavigationState", () => {
|
|
|
18
18
|
it("normalizes Record inputs into Maps", () => {
|
|
19
19
|
const state = createNavigationState({
|
|
20
20
|
stacks: {
|
|
21
|
-
"stack-a": [{ element: <
|
|
21
|
+
"stack-a": [{ element: <Text>a</Text> }],
|
|
22
22
|
},
|
|
23
23
|
tabs: {
|
|
24
24
|
"my-tabs": { activeIndex: 2 },
|
|
@@ -33,7 +33,7 @@ describe("createNavigationState", () => {
|
|
|
33
33
|
|
|
34
34
|
it("accepts Map inputs directly", () => {
|
|
35
35
|
const stacks = new Map([
|
|
36
|
-
["stack-a", [{ element: <
|
|
36
|
+
["stack-a", [{ element: <Text>a</Text> }]],
|
|
37
37
|
]);
|
|
38
38
|
const tabs = new Map([["my-tabs", { activeIndex: 1 }]]);
|
|
39
39
|
const state = createNavigationState({ stacks, tabs });
|
|
@@ -49,12 +49,14 @@ describe("createNavigation", () => {
|
|
|
49
49
|
expect(nav.store).toBeDefined();
|
|
50
50
|
expect(nav.renderTreeStore).toBeDefined();
|
|
51
51
|
expect(nav.sheetsStore).toBeDefined();
|
|
52
|
-
expect(typeof nav.push).toBe("function");
|
|
53
|
-
expect(typeof nav.pop).toBe("function");
|
|
54
|
-
expect(typeof nav.tab).toBe("function");
|
|
55
|
-
expect(typeof nav.present).toBe("function");
|
|
56
|
-
expect(typeof nav.dismiss).toBe("function");
|
|
57
|
-
expect(typeof nav.dismissAll).toBe("function");
|
|
52
|
+
expect(typeof nav.stack.push).toBe("function");
|
|
53
|
+
expect(typeof nav.stack.pop).toBe("function");
|
|
54
|
+
expect(typeof nav.tabs.tab).toBe("function");
|
|
55
|
+
expect(typeof nav.sheets.present).toBe("function");
|
|
56
|
+
expect(typeof nav.sheets.dismiss).toBe("function");
|
|
57
|
+
expect(typeof nav.sheets.dismissAll).toBe("function");
|
|
58
|
+
expect(typeof nav.notifications.present).toBe("function");
|
|
59
|
+
expect(typeof nav.notifications.dismiss).toBe("function");
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
it("initializes with empty state by default", () => {
|
|
@@ -66,7 +68,7 @@ describe("createNavigation", () => {
|
|
|
66
68
|
|
|
67
69
|
it("initializes with the provided state", () => {
|
|
68
70
|
const nav = createNavigation({
|
|
69
|
-
stacks: { "stack-a": [{ element: <
|
|
71
|
+
stacks: { "stack-a": [{ element: <Text>a</Text> }] },
|
|
70
72
|
tabs: { "my-tabs": { activeIndex: 1 } },
|
|
71
73
|
});
|
|
72
74
|
const state = nav.store.getState();
|
|
@@ -78,38 +80,70 @@ describe("createNavigation", () => {
|
|
|
78
80
|
describe("sheet methods", () => {
|
|
79
81
|
it("present returns a key", () => {
|
|
80
82
|
const nav = createNavigation();
|
|
81
|
-
const key = nav.present(<
|
|
83
|
+
const key = nav.sheets.present(<Text>sheet</Text>);
|
|
82
84
|
|
|
83
85
|
expect(typeof key).toBe("string");
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
it("present reuses key when id is reused", () => {
|
|
87
89
|
const nav = createNavigation();
|
|
88
|
-
const key1 = nav.present(<
|
|
89
|
-
const key2 = nav.present(<
|
|
90
|
+
const key1 = nav.sheets.present(<Text>sheet-a</Text>, { id: "edit" });
|
|
91
|
+
const key2 = nav.sheets.present(<Text>sheet-b</Text>, { id: "edit" });
|
|
90
92
|
|
|
91
93
|
expect(key2).toBe(key1);
|
|
92
94
|
});
|
|
93
95
|
|
|
94
96
|
it("dismiss and dismissAll are callable via public API", () => {
|
|
95
97
|
const nav = createNavigation();
|
|
96
|
-
nav.present(<
|
|
97
|
-
nav.present(<
|
|
98
|
+
nav.sheets.present(<Text>a</Text>);
|
|
99
|
+
nav.sheets.present(<Text>b</Text>);
|
|
98
100
|
|
|
99
|
-
expect(() => nav.dismiss()).not.toThrow();
|
|
100
|
-
nav.dismissAll();
|
|
101
|
-
expect(() => nav.dismissAll()).not.toThrow();
|
|
101
|
+
expect(() => nav.sheets.dismiss()).not.toThrow();
|
|
102
|
+
nav.sheets.dismissAll();
|
|
103
|
+
expect(() => nav.sheets.dismissAll()).not.toThrow();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("notification methods", () => {
|
|
108
|
+
it("notify returns a key", () => {
|
|
109
|
+
const nav = createNavigation();
|
|
110
|
+
const key = nav.notifications.present(<Text>notification</Text>, { durationMs: null });
|
|
111
|
+
|
|
112
|
+
expect(typeof key).toBe("string");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("notify reuses key when id is reused", () => {
|
|
116
|
+
const nav = createNavigation();
|
|
117
|
+
const key1 = nav.notifications.present(<Text>notification-a</Text>, {
|
|
118
|
+
id: "welcome",
|
|
119
|
+
durationMs: null,
|
|
120
|
+
});
|
|
121
|
+
const key2 = nav.notifications.present(<Text>notification-b</Text>, {
|
|
122
|
+
id: "welcome",
|
|
123
|
+
durationMs: null,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(key2).toBe(key1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("dismissNotification is callable via public API", () => {
|
|
130
|
+
const nav = createNavigation();
|
|
131
|
+
nav.notifications.present(<Text>a</Text>, { durationMs: null });
|
|
132
|
+
nav.notifications.present(<Text>b</Text>, { position: "bottom", durationMs: null });
|
|
133
|
+
|
|
134
|
+
expect(() => nav.notifications.dismiss()).not.toThrow();
|
|
135
|
+
expect(() => nav.notifications.dismiss("bottom")).not.toThrow();
|
|
102
136
|
});
|
|
103
137
|
});
|
|
104
138
|
|
|
105
139
|
describe("loadNavigationState", () => {
|
|
106
140
|
it("replaces the store state with normalized input", () => {
|
|
107
141
|
const nav = createNavigation({
|
|
108
|
-
stacks: { "stack-a": [{ element: <
|
|
142
|
+
stacks: { "stack-a": [{ element: <Text>old</Text> }] },
|
|
109
143
|
});
|
|
110
144
|
|
|
111
145
|
loadNavigationState(nav.store, {
|
|
112
|
-
stacks: { "stack-b": [{ element: <
|
|
146
|
+
stacks: { "stack-b": [{ element: <Text>new</Text> }] },
|
|
113
147
|
});
|
|
114
148
|
|
|
115
149
|
const state = nav.store.getState();
|
|
@@ -124,7 +158,7 @@ describe("push", () => {
|
|
|
124
158
|
stacks: { "stack-a": [] },
|
|
125
159
|
});
|
|
126
160
|
|
|
127
|
-
nav.push(<
|
|
161
|
+
nav.stack.push(<Text>pushed</Text>, { stack: "stack-a" });
|
|
128
162
|
|
|
129
163
|
const screens = nav.store.getState().stacks.get("stack-a");
|
|
130
164
|
expect(screens).toHaveLength(1);
|
|
@@ -133,7 +167,7 @@ describe("push", () => {
|
|
|
133
167
|
it("creates the stack entry if it did not exist", () => {
|
|
134
168
|
const nav = createNavigation();
|
|
135
169
|
|
|
136
|
-
nav.push(<
|
|
170
|
+
nav.stack.push(<Text>pushed</Text>, { stack: "stack-new" });
|
|
137
171
|
|
|
138
172
|
const screens = nav.store.getState().stacks.get("stack-new");
|
|
139
173
|
expect(screens).toHaveLength(1);
|
|
@@ -142,11 +176,11 @@ describe("push", () => {
|
|
|
142
176
|
it("does not push a duplicate screen with the same id", () => {
|
|
143
177
|
const nav = createNavigation({
|
|
144
178
|
stacks: {
|
|
145
|
-
"stack-a": [{ element: <
|
|
179
|
+
"stack-a": [{ element: <Text>a</Text>, options: { id: "screen-1" } }],
|
|
146
180
|
},
|
|
147
181
|
});
|
|
148
182
|
|
|
149
|
-
nav.push(<
|
|
183
|
+
nav.stack.push(<Text>dup</Text>, { id: "screen-1", stack: "stack-a" });
|
|
150
184
|
|
|
151
185
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
|
|
152
186
|
});
|
|
@@ -154,20 +188,20 @@ describe("push", () => {
|
|
|
154
188
|
it("allows pushing after a screen with the same id was popped", () => {
|
|
155
189
|
const nav = createNavigation({
|
|
156
190
|
stacks: {
|
|
157
|
-
"stack-a": [{ element: <
|
|
191
|
+
"stack-a": [{ element: <Text>a</Text>, options: { id: "screen-1" } }],
|
|
158
192
|
},
|
|
159
193
|
});
|
|
160
194
|
|
|
161
|
-
nav.pop({ stack: "stack-a" });
|
|
195
|
+
nav.stack.pop({ stack: "stack-a" });
|
|
162
196
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(0);
|
|
163
197
|
|
|
164
|
-
nav.push(<
|
|
198
|
+
nav.stack.push(<Text>new</Text>, { id: "screen-1", stack: "stack-a" });
|
|
165
199
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
|
|
166
200
|
});
|
|
167
201
|
|
|
168
202
|
it("throws when no stack is provided and no stack is mounted", () => {
|
|
169
203
|
const nav = createNavigation();
|
|
170
|
-
expect(() => nav.push(<
|
|
204
|
+
expect(() => nav.stack.push(<Text>x</Text>)).toThrow(
|
|
171
205
|
"could not resolve stack",
|
|
172
206
|
);
|
|
173
207
|
});
|
|
@@ -178,13 +212,13 @@ describe("pop", () => {
|
|
|
178
212
|
const nav = createNavigation({
|
|
179
213
|
stacks: {
|
|
180
214
|
"stack-a": [
|
|
181
|
-
{ element: <
|
|
182
|
-
{ element: <
|
|
215
|
+
{ element: <Text>a</Text> },
|
|
216
|
+
{ element: <Text>b</Text> },
|
|
183
217
|
],
|
|
184
218
|
},
|
|
185
219
|
});
|
|
186
220
|
|
|
187
|
-
nav.pop({ stack: "stack-a" });
|
|
221
|
+
nav.stack.pop({ stack: "stack-a" });
|
|
188
222
|
|
|
189
223
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
|
|
190
224
|
});
|
|
@@ -195,7 +229,7 @@ describe("pop", () => {
|
|
|
195
229
|
});
|
|
196
230
|
|
|
197
231
|
const before = nav.store.getState();
|
|
198
|
-
nav.pop({ stack: "stack-a" });
|
|
232
|
+
nav.stack.pop({ stack: "stack-a" });
|
|
199
233
|
const after = nav.store.getState();
|
|
200
234
|
|
|
201
235
|
expect(before).toBe(after);
|
|
@@ -203,7 +237,7 @@ describe("pop", () => {
|
|
|
203
237
|
|
|
204
238
|
it("throws when no stack is provided and no stack is mounted", () => {
|
|
205
239
|
const nav = createNavigation();
|
|
206
|
-
expect(() => nav.pop()).toThrow("could not resolve stack");
|
|
240
|
+
expect(() => nav.stack.pop()).toThrow("could not resolve stack");
|
|
207
241
|
});
|
|
208
242
|
});
|
|
209
243
|
|
|
@@ -213,7 +247,7 @@ describe("tab", () => {
|
|
|
213
247
|
tabs: { "my-tabs": { activeIndex: 0 } },
|
|
214
248
|
});
|
|
215
249
|
|
|
216
|
-
nav.tab(2, { tabs: "my-tabs" });
|
|
250
|
+
nav.tabs.tab(2, { tabs: "my-tabs" });
|
|
217
251
|
|
|
218
252
|
expect(nav.store.getState().tabs.get("my-tabs")).toEqual({
|
|
219
253
|
activeIndex: 2,
|
|
@@ -223,7 +257,7 @@ describe("tab", () => {
|
|
|
223
257
|
it("creates the tabs entry if it did not exist", () => {
|
|
224
258
|
const nav = createNavigation();
|
|
225
259
|
|
|
226
|
-
nav.tab(1, { tabs: "new-tabs" });
|
|
260
|
+
nav.tabs.tab(1, { tabs: "new-tabs" });
|
|
227
261
|
|
|
228
262
|
expect(nav.store.getState().tabs.get("new-tabs")).toEqual({
|
|
229
263
|
activeIndex: 1,
|
|
@@ -232,6 +266,6 @@ describe("tab", () => {
|
|
|
232
266
|
|
|
233
267
|
it("throws when no tabs is provided and no tabs are mounted", () => {
|
|
234
268
|
const nav = createNavigation();
|
|
235
|
-
expect(() => nav.tab(0)).toThrow("could not resolve tabs");
|
|
269
|
+
expect(() => nav.tabs.tab(0)).toThrow("could not resolve tabs");
|
|
236
270
|
});
|
|
237
271
|
});
|
|
@@ -6,18 +6,27 @@ import {
|
|
|
6
6
|
getRenderNodeActive,
|
|
7
7
|
getRenderNodeDepth,
|
|
8
8
|
} from "@rn-tools/core";
|
|
9
|
-
import type { Store, RenderTreeStore } from "@rn-tools/core";
|
|
9
|
+
import type { Store, RenderTreeStore, BaseOverlayOptions } from "@rn-tools/core";
|
|
10
10
|
import { createSheets } from "@rn-tools/sheets";
|
|
11
11
|
import type { SheetOptions, SheetsClient } from "@rn-tools/sheets";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import { createNotifications } from "@rn-tools/notifications";
|
|
13
|
+
import type {
|
|
14
|
+
NotificationDismissTarget,
|
|
15
|
+
NotificationOptions,
|
|
16
|
+
NotificationsClient,
|
|
17
|
+
} from "@rn-tools/notifications";
|
|
18
|
+
|
|
19
|
+
export const STACK_TYPE = "stack";
|
|
20
|
+
export const SCREEN_TYPE = "screen";
|
|
21
|
+
export const TABS_TYPE = "tabs";
|
|
22
|
+
export const TAB_SCREEN_TYPE = "tab-screen";
|
|
23
|
+
|
|
24
|
+
export type PushOptions = BaseOverlayOptions & {
|
|
15
25
|
stack?: string;
|
|
16
26
|
};
|
|
17
27
|
|
|
18
28
|
export type NavigationScreenEntry = {
|
|
19
29
|
element: React.ReactElement;
|
|
20
|
-
id?: string;
|
|
21
30
|
options?: PushOptions;
|
|
22
31
|
};
|
|
23
32
|
|
|
@@ -32,19 +41,18 @@ export type NavigationState = {
|
|
|
32
41
|
|
|
33
42
|
export type NavigationStore = Store<NavigationState>;
|
|
34
43
|
|
|
35
|
-
export const NavigationContext = React.createContext<NavigationClient | null>(
|
|
36
|
-
|
|
37
|
-
export const NavigationStoreContext = React.createContext<NavigationStore | null>(
|
|
44
|
+
export const NavigationContext = React.createContext<NavigationClient | null>(
|
|
38
45
|
null,
|
|
39
46
|
);
|
|
40
47
|
|
|
48
|
+
export const NavigationStoreContext =
|
|
49
|
+
React.createContext<NavigationStore | null>(null);
|
|
50
|
+
|
|
41
51
|
export type NavigationStateInput = {
|
|
42
52
|
stacks?:
|
|
43
53
|
| Map<string, NavigationScreenEntry[]>
|
|
44
54
|
| Record<string, NavigationScreenEntry[]>;
|
|
45
|
-
tabs?:
|
|
46
|
-
| Map<string, TabState>
|
|
47
|
-
| Record<string, TabState>;
|
|
55
|
+
tabs?: Map<string, TabState> | Record<string, TabState>;
|
|
48
56
|
};
|
|
49
57
|
|
|
50
58
|
export function createNavigationState(
|
|
@@ -64,12 +72,26 @@ export type NavigationClient = {
|
|
|
64
72
|
store: NavigationStore;
|
|
65
73
|
renderTreeStore: RenderTreeStore;
|
|
66
74
|
sheetsStore: SheetsClient;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
notificationsStore: NotificationsClient;
|
|
76
|
+
stack: {
|
|
77
|
+
push: (element: React.ReactElement, options?: PushOptions) => void;
|
|
78
|
+
pop: (options?: { stack?: string }) => void;
|
|
79
|
+
};
|
|
80
|
+
tabs: {
|
|
81
|
+
tab: (index: number, options?: { tabs?: string }) => void;
|
|
82
|
+
};
|
|
83
|
+
sheets: {
|
|
84
|
+
present: (element: React.ReactElement, options?: SheetOptions) => string;
|
|
85
|
+
dismiss: (id?: string) => void;
|
|
86
|
+
dismissAll: () => void;
|
|
87
|
+
};
|
|
88
|
+
notifications: {
|
|
89
|
+
present: (
|
|
90
|
+
element: React.ReactElement,
|
|
91
|
+
options?: NotificationOptions,
|
|
92
|
+
) => string;
|
|
93
|
+
dismiss: (target?: NotificationDismissTarget) => void;
|
|
94
|
+
};
|
|
73
95
|
};
|
|
74
96
|
|
|
75
97
|
export function createNavigation(
|
|
@@ -79,7 +101,8 @@ export function createNavigation(
|
|
|
79
101
|
normalizeNavigationState(initialState ?? { stacks: new Map() }),
|
|
80
102
|
);
|
|
81
103
|
const renderTreeStore = createRenderTreeStore();
|
|
82
|
-
const sheetsStore = createSheets();
|
|
104
|
+
const sheetsStore = createSheets(renderTreeStore);
|
|
105
|
+
const notificationsStore = createNotifications(renderTreeStore);
|
|
83
106
|
|
|
84
107
|
function getDeepestActiveNodeId(type: string): string | null {
|
|
85
108
|
const tree = renderTreeStore.getState();
|
|
@@ -100,11 +123,8 @@ export function createNavigation(
|
|
|
100
123
|
return deepestId;
|
|
101
124
|
}
|
|
102
125
|
|
|
103
|
-
function push(
|
|
104
|
-
|
|
105
|
-
options?: PushOptions,
|
|
106
|
-
) {
|
|
107
|
-
const stackId = options?.stack ?? getDeepestActiveNodeId("stack");
|
|
126
|
+
function push(element: React.ReactElement, options?: PushOptions) {
|
|
127
|
+
const stackId = options?.stack ?? getDeepestActiveNodeId(STACK_TYPE);
|
|
108
128
|
if (!stackId) {
|
|
109
129
|
throw new Error(
|
|
110
130
|
"push: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
|
|
@@ -115,22 +135,17 @@ export function createNavigation(
|
|
|
115
135
|
const stacks = new Map(prev.stacks);
|
|
116
136
|
const existing = stacks.get(stackId) ?? [];
|
|
117
137
|
|
|
118
|
-
if (
|
|
119
|
-
options?.id &&
|
|
120
|
-
existing.some(
|
|
121
|
-
(s) => s.id === options.id || s.options?.id === options.id,
|
|
122
|
-
)
|
|
123
|
-
) {
|
|
138
|
+
if (options?.id && existing.some((s) => s.options?.id === options.id)) {
|
|
124
139
|
return prev;
|
|
125
140
|
}
|
|
126
141
|
|
|
127
|
-
stacks.set(stackId, [...existing, { element,
|
|
142
|
+
stacks.set(stackId, [...existing, { element, options }]);
|
|
128
143
|
return { ...prev, stacks };
|
|
129
144
|
});
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
function pop(options?: { stack?: string }) {
|
|
133
|
-
const stackId = options?.stack ?? getDeepestActiveNodeId(
|
|
148
|
+
const stackId = options?.stack ?? getDeepestActiveNodeId(STACK_TYPE);
|
|
134
149
|
if (!stackId) {
|
|
135
150
|
throw new Error(
|
|
136
151
|
"pop: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
|
|
@@ -147,7 +162,7 @@ export function createNavigation(
|
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
function tab(index: number, options?: { tabs?: string }) {
|
|
150
|
-
const tabsId = options?.tabs ?? getDeepestActiveNodeId(
|
|
165
|
+
const tabsId = options?.tabs ?? getDeepestActiveNodeId(TABS_TYPE);
|
|
151
166
|
if (!tabsId) {
|
|
152
167
|
throw new Error(
|
|
153
168
|
"tab: could not resolve tabs. Pass { tabs } explicitly or ensure a Tabs is mounted and active.",
|
|
@@ -161,7 +176,10 @@ export function createNavigation(
|
|
|
161
176
|
});
|
|
162
177
|
}
|
|
163
178
|
|
|
164
|
-
function present(
|
|
179
|
+
function present(
|
|
180
|
+
element: React.ReactElement,
|
|
181
|
+
options?: SheetOptions,
|
|
182
|
+
): string {
|
|
165
183
|
return sheetsStore.present(element, options);
|
|
166
184
|
}
|
|
167
185
|
|
|
@@ -173,16 +191,38 @@ export function createNavigation(
|
|
|
173
191
|
sheetsStore.dismissAll();
|
|
174
192
|
}
|
|
175
193
|
|
|
194
|
+
function notify(
|
|
195
|
+
element: React.ReactElement,
|
|
196
|
+
options?: NotificationOptions,
|
|
197
|
+
): string {
|
|
198
|
+
return notificationsStore.show(element, options);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function dismissNotification(target?: NotificationDismissTarget) {
|
|
202
|
+
notificationsStore.dismiss(target);
|
|
203
|
+
}
|
|
204
|
+
|
|
176
205
|
return {
|
|
177
206
|
store: navStore,
|
|
178
207
|
renderTreeStore,
|
|
179
208
|
sheetsStore,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
notificationsStore,
|
|
210
|
+
stack: {
|
|
211
|
+
push,
|
|
212
|
+
pop,
|
|
213
|
+
},
|
|
214
|
+
tabs: {
|
|
215
|
+
tab,
|
|
216
|
+
},
|
|
217
|
+
sheets: {
|
|
218
|
+
present,
|
|
219
|
+
dismiss,
|
|
220
|
+
dismissAll,
|
|
221
|
+
},
|
|
222
|
+
notifications: {
|
|
223
|
+
present: notify,
|
|
224
|
+
dismiss: dismissNotification,
|
|
225
|
+
},
|
|
186
226
|
};
|
|
187
227
|
}
|
|
188
228
|
|
package/src/navigation.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { RenderTree } from "@rn-tools/core";
|
|
3
|
+
import { NotificationsProvider } from "@rn-tools/notifications";
|
|
3
4
|
import { SheetsProvider } from "@rn-tools/sheets";
|
|
4
5
|
import {
|
|
5
6
|
NavigationContext,
|
|
@@ -31,13 +32,15 @@ export const Navigation = React.memo(function Navigation(
|
|
|
31
32
|
) {
|
|
32
33
|
return (
|
|
33
34
|
<RenderTree store={props.navigation.renderTreeStore}>
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
<NotificationsProvider notifications={props.navigation.notificationsStore}>
|
|
36
|
+
<SheetsProvider sheets={props.navigation.sheetsStore}>
|
|
37
|
+
<NavigationContext.Provider value={props.navigation}>
|
|
38
|
+
<NavigationStoreContext.Provider value={props.navigation.store}>
|
|
39
|
+
<RootStack>{props.children}</RootStack>
|
|
40
|
+
</NavigationStoreContext.Provider>
|
|
41
|
+
</NavigationContext.Provider>
|
|
42
|
+
</SheetsProvider>
|
|
43
|
+
</NotificationsProvider>
|
|
41
44
|
</RenderTree>
|
|
42
45
|
);
|
|
43
46
|
});
|
package/src/stack.test.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { act, render, waitFor } from "@testing-library/react-native";
|
|
3
|
+
import { Text } from "react-native";
|
|
4
4
|
import { RenderNodeProbe } from "@rn-tools/core/mocks/render-node-probe";
|
|
5
5
|
|
|
6
6
|
import {
|
|
@@ -25,7 +25,7 @@ async function renderWithProviders(
|
|
|
25
25
|
describe("Stack", () => {
|
|
26
26
|
it("renders the rootScreen inside a screen node", async () => {
|
|
27
27
|
const { renderer } = await renderWithProviders(
|
|
28
|
-
<Stack rootScreen={<RenderNodeProbe render={(data) => data.type} />} />,
|
|
28
|
+
<Stack rootScreen={<RenderNodeProbe render={(data) => <Text>{data.type}</Text>} />} />,
|
|
29
29
|
);
|
|
30
30
|
|
|
31
31
|
expect(renderer.getByText("screen")).toBeTruthy();
|
|
@@ -35,7 +35,7 @@ describe("Stack", () => {
|
|
|
35
35
|
const { renderer } = await renderWithProviders(
|
|
36
36
|
<Stack
|
|
37
37
|
active={false}
|
|
38
|
-
rootScreen={<RenderNodeProbe render={(data) => String(data.active)} />}
|
|
38
|
+
rootScreen={<RenderNodeProbe render={(data) => <Text>{String(data.active)}</Text>} />}
|
|
39
39
|
/>,
|
|
40
40
|
);
|
|
41
41
|
|
|
@@ -54,7 +54,7 @@ describe("Stack", () => {
|
|
|
54
54
|
{
|
|
55
55
|
element: (
|
|
56
56
|
<RenderNodeProbe
|
|
57
|
-
render={(data) => <
|
|
57
|
+
render={(data) => <Text>{`a:${data.node.parentId}`}</Text>}
|
|
58
58
|
/>
|
|
59
59
|
),
|
|
60
60
|
options: { id: "screen-a" },
|
|
@@ -64,7 +64,7 @@ describe("Stack", () => {
|
|
|
64
64
|
{
|
|
65
65
|
element: (
|
|
66
66
|
<RenderNodeProbe
|
|
67
|
-
render={(data) => <
|
|
67
|
+
render={(data) => <Text>{`b:${data.node.parentId}`}</Text>}
|
|
68
68
|
/>
|
|
69
69
|
),
|
|
70
70
|
options: { id: "screen-b" },
|
|
@@ -86,7 +86,7 @@ describe("Stack", () => {
|
|
|
86
86
|
element: (
|
|
87
87
|
<RenderNodeProbe
|
|
88
88
|
render={(data) => (
|
|
89
|
-
<
|
|
89
|
+
<Text>{`screen-a:${String(data.active)}`}</Text>
|
|
90
90
|
)}
|
|
91
91
|
/>
|
|
92
92
|
),
|
|
@@ -96,7 +96,7 @@ describe("Stack", () => {
|
|
|
96
96
|
element: (
|
|
97
97
|
<RenderNodeProbe
|
|
98
98
|
render={(data) => (
|
|
99
|
-
<
|
|
99
|
+
<Text>{`screen-b:${String(data.active)}`}</Text>
|
|
100
100
|
)}
|
|
101
101
|
/>
|
|
102
102
|
),
|
|
@@ -120,7 +120,7 @@ describe("Stack", () => {
|
|
|
120
120
|
element: (
|
|
121
121
|
<RenderNodeProbe
|
|
122
122
|
render={(data) => (
|
|
123
|
-
<
|
|
123
|
+
<Text>{`screen-a:${String(data.active)}`}</Text>
|
|
124
124
|
)}
|
|
125
125
|
/>
|
|
126
126
|
),
|
|
@@ -132,9 +132,9 @@ describe("Stack", () => {
|
|
|
132
132
|
);
|
|
133
133
|
|
|
134
134
|
act(() => {
|
|
135
|
-
navigation.push(
|
|
135
|
+
navigation.stack.push(
|
|
136
136
|
<RenderNodeProbe
|
|
137
|
-
render={(data) => <
|
|
137
|
+
render={(data) => <Text>{`screen-b:${String(data.active)}`}</Text>}
|
|
138
138
|
/>,
|
|
139
139
|
{
|
|
140
140
|
id: "screen-b",
|
|
@@ -162,7 +162,7 @@ describe("Stack", () => {
|
|
|
162
162
|
element: (
|
|
163
163
|
<RenderNodeProbe
|
|
164
164
|
render={(data) => (
|
|
165
|
-
<
|
|
165
|
+
<Text>{`a1:${data.type}:${String(data.active)}`}</Text>
|
|
166
166
|
)}
|
|
167
167
|
/>
|
|
168
168
|
),
|
|
@@ -172,7 +172,7 @@ describe("Stack", () => {
|
|
|
172
172
|
element: (
|
|
173
173
|
<RenderNodeProbe
|
|
174
174
|
render={(data) => (
|
|
175
|
-
<
|
|
175
|
+
<Text>{`a2:${data.type}:${String(data.active)}`}</Text>
|
|
176
176
|
)}
|
|
177
177
|
/>
|
|
178
178
|
),
|
|
@@ -184,7 +184,7 @@ describe("Stack", () => {
|
|
|
184
184
|
element: (
|
|
185
185
|
<RenderNodeProbe
|
|
186
186
|
render={(data) => (
|
|
187
|
-
<
|
|
187
|
+
<Text>{`b1:${data.type}:${String(data.active)}`}</Text>
|
|
188
188
|
)}
|
|
189
189
|
/>
|
|
190
190
|
),
|
|
@@ -209,7 +209,7 @@ describe("Stack", () => {
|
|
|
209
209
|
{
|
|
210
210
|
element: (
|
|
211
211
|
<RenderNodeProbe
|
|
212
|
-
render={(data) => <
|
|
212
|
+
render={(data) => <Text>{`a1:${String(data.active)}`}</Text>}
|
|
213
213
|
/>
|
|
214
214
|
),
|
|
215
215
|
options: { id: "screen-a1" },
|
|
@@ -234,7 +234,7 @@ describe("Stack", () => {
|
|
|
234
234
|
stacks: {
|
|
235
235
|
"stack-a": [
|
|
236
236
|
{
|
|
237
|
-
element: <
|
|
237
|
+
element: <Text>screen-a</Text>,
|
|
238
238
|
options: { id: "screen-a" },
|
|
239
239
|
},
|
|
240
240
|
],
|
|
@@ -243,7 +243,7 @@ describe("Stack", () => {
|
|
|
243
243
|
);
|
|
244
244
|
|
|
245
245
|
act(() => {
|
|
246
|
-
navigation.push(<
|
|
246
|
+
navigation.stack.push(<Text>screen-a-dup</Text>, {
|
|
247
247
|
id: "screen-a",
|
|
248
248
|
stack: "stack-a",
|
|
249
249
|
});
|
|
@@ -253,14 +253,14 @@ describe("Stack", () => {
|
|
|
253
253
|
expect(renderer.getByText("screen-a")).toBeTruthy();
|
|
254
254
|
|
|
255
255
|
act(() => {
|
|
256
|
-
navigation.pop({ stack: "stack-a" });
|
|
256
|
+
navigation.stack.pop({ stack: "stack-a" });
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
expect(navigation.store.getState().stacks.get("stack-a")).toHaveLength(0);
|
|
260
260
|
expect(renderer.queryByText("screen-a")).toBeNull();
|
|
261
261
|
|
|
262
262
|
act(() => {
|
|
263
|
-
navigation.push(<
|
|
263
|
+
navigation.stack.push(<Text>screen-a-again</Text>, {
|
|
264
264
|
id: "screen-a",
|
|
265
265
|
stack: "stack-a",
|
|
266
266
|
});
|
|
@@ -273,13 +273,13 @@ describe("Stack", () => {
|
|
|
273
273
|
it("ref.push adds a screen and ref.pop removes it", async () => {
|
|
274
274
|
const ref = React.createRef<StackHandle>();
|
|
275
275
|
const { renderer, store } = await renderWithProviders(
|
|
276
|
-
<Stack ref={ref} id="stack-a" rootScreen={<
|
|
276
|
+
<Stack ref={ref} id="stack-a" rootScreen={<Text>root</Text>} />,
|
|
277
277
|
);
|
|
278
278
|
|
|
279
279
|
expect(renderer.getByText("root")).toBeTruthy();
|
|
280
280
|
|
|
281
281
|
act(() => {
|
|
282
|
-
ref.current!.push(<
|
|
282
|
+
ref.current!.push(<Text>pushed</Text>, { id: "pushed-screen" });
|
|
283
283
|
});
|
|
284
284
|
|
|
285
285
|
await waitFor(() => {
|
|
@@ -303,13 +303,13 @@ describe("Stack", () => {
|
|
|
303
303
|
it("ref.push targets the correct stack when id is not provided", async () => {
|
|
304
304
|
const ref = React.createRef<StackHandle>();
|
|
305
305
|
const { renderer, store } = await renderWithProviders(
|
|
306
|
-
<Stack ref={ref} rootScreen={<
|
|
306
|
+
<Stack ref={ref} rootScreen={<Text>root</Text>} />,
|
|
307
307
|
);
|
|
308
308
|
|
|
309
309
|
expect(renderer.getByText("root")).toBeTruthy();
|
|
310
310
|
|
|
311
311
|
act(() => {
|
|
312
|
-
ref.current!.push(<
|
|
312
|
+
ref.current!.push(<Text>pushed</Text>, { id: "pushed-screen" });
|
|
313
313
|
});
|
|
314
314
|
|
|
315
315
|
await waitFor(() => {
|
|
@@ -339,7 +339,7 @@ describe("Stack", () => {
|
|
|
339
339
|
const innerRef = React.createRef<StackHandle>();
|
|
340
340
|
|
|
341
341
|
function InnerStack() {
|
|
342
|
-
return <Stack ref={innerRef} rootScreen={<
|
|
342
|
+
return <Stack ref={innerRef} rootScreen={<Text>inner-root</Text>} />;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
345
|
const { renderer, store } = await renderWithProviders(
|
|
@@ -351,7 +351,7 @@ describe("Stack", () => {
|
|
|
351
351
|
});
|
|
352
352
|
|
|
353
353
|
act(() => {
|
|
354
|
-
innerRef.current!.push(<
|
|
354
|
+
innerRef.current!.push(<Text>inner-pushed</Text>, {
|
|
355
355
|
id: "inner-screen",
|
|
356
356
|
});
|
|
357
357
|
});
|
|
@@ -365,16 +365,40 @@ describe("Stack", () => {
|
|
|
365
365
|
expect(state.stacks.has("outer")).toBe(false);
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
+
it("renders unwrapped screen content when wrapped is false", async () => {
|
|
369
|
+
const { renderer } = await renderWithProviders(
|
|
370
|
+
<Stack id="stack-a" />,
|
|
371
|
+
{
|
|
372
|
+
stacks: {
|
|
373
|
+
"stack-a": [
|
|
374
|
+
{
|
|
375
|
+
element: (
|
|
376
|
+
<RenderNodeProbe
|
|
377
|
+
render={(data) => (
|
|
378
|
+
<Text>{`unwrapped:${data.type}:${String(data.active)}`}</Text>
|
|
379
|
+
)}
|
|
380
|
+
/>
|
|
381
|
+
),
|
|
382
|
+
options: { id: "screen-unwrapped", wrapped: false },
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
expect(renderer.getByText("unwrapped:screen:true")).toBeTruthy();
|
|
390
|
+
});
|
|
391
|
+
|
|
368
392
|
it("resolves the deepest active stack and falls back when a subtree becomes inactive", async () => {
|
|
369
393
|
const navigation = createNavigation();
|
|
370
394
|
|
|
371
395
|
function NestedRight() {
|
|
372
|
-
return <Stack id="right-nested" rootScreen={<
|
|
396
|
+
return <Stack id="right-nested" rootScreen={<Text>nested-root</Text>} />;
|
|
373
397
|
}
|
|
374
398
|
|
|
375
399
|
const result = render(
|
|
376
400
|
<Navigation navigation={navigation}>
|
|
377
|
-
<Stack id="left" rootScreen={<
|
|
401
|
+
<Stack id="left" rootScreen={<Text>left-root</Text>} />
|
|
378
402
|
<Stack id="right" active={true} rootScreen={<NestedRight />} />
|
|
379
403
|
</Navigation>,
|
|
380
404
|
);
|
|
@@ -385,7 +409,7 @@ describe("Stack", () => {
|
|
|
385
409
|
|
|
386
410
|
// push with no stack should target the deepest active stack: right-nested
|
|
387
411
|
act(() => {
|
|
388
|
-
navigation.push(<
|
|
412
|
+
navigation.stack.push(<Text>first-push</Text>);
|
|
389
413
|
});
|
|
390
414
|
|
|
391
415
|
const stateAfterFirst = navigation.store.getState();
|
|
@@ -395,13 +419,13 @@ describe("Stack", () => {
|
|
|
395
419
|
// Deactivate the right subtree — left should become the active stack
|
|
396
420
|
result.rerender(
|
|
397
421
|
<Navigation navigation={navigation}>
|
|
398
|
-
<Stack id="left" rootScreen={<
|
|
422
|
+
<Stack id="left" rootScreen={<Text>left-root</Text>} />
|
|
399
423
|
<Stack id="right" active={false} rootScreen={<NestedRight />} />
|
|
400
424
|
</Navigation>,
|
|
401
425
|
);
|
|
402
426
|
|
|
403
427
|
act(() => {
|
|
404
|
-
navigation.push(<
|
|
428
|
+
navigation.stack.push(<Text>second-push</Text>);
|
|
405
429
|
});
|
|
406
430
|
|
|
407
431
|
const stateAfterSecond = navigation.store.getState();
|
package/src/stack.tsx
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
useStackScreens,
|
|
9
9
|
useNavigation,
|
|
10
|
+
STACK_TYPE,
|
|
11
|
+
SCREEN_TYPE,
|
|
10
12
|
type PushOptions,
|
|
11
13
|
} from "./navigation-client";
|
|
12
14
|
|
|
@@ -15,10 +17,7 @@ import * as RNScreens from "react-native-screens";
|
|
|
15
17
|
import { StyleSheet } from "react-native";
|
|
16
18
|
|
|
17
19
|
export type StackHandle = {
|
|
18
|
-
push: (
|
|
19
|
-
element: React.ReactElement,
|
|
20
|
-
options?: PushOptions,
|
|
21
|
-
) => void;
|
|
20
|
+
push: (element: React.ReactElement, options?: PushOptions) => void;
|
|
22
21
|
pop: () => void;
|
|
23
22
|
};
|
|
24
23
|
|
|
@@ -32,7 +31,7 @@ export type StackProps = {
|
|
|
32
31
|
const StackRoot = React.memo(
|
|
33
32
|
React.forwardRef<StackHandle, StackProps>(function StackRoot(props, ref) {
|
|
34
33
|
const stackId = React.useRef(
|
|
35
|
-
props.id ?? nextRenderTreeIdForType(
|
|
34
|
+
props.id ?? nextRenderTreeIdForType(STACK_TYPE),
|
|
36
35
|
).current;
|
|
37
36
|
const navigation = useNavigation();
|
|
38
37
|
|
|
@@ -40,17 +39,17 @@ const StackRoot = React.memo(
|
|
|
40
39
|
ref,
|
|
41
40
|
() => ({
|
|
42
41
|
push(element: React.ReactElement, options?: PushOptions) {
|
|
43
|
-
navigation.push(element, { ...options, stack: stackId });
|
|
42
|
+
navigation.stack.push(element, { ...options, stack: stackId });
|
|
44
43
|
},
|
|
45
44
|
pop() {
|
|
46
|
-
navigation.pop({ stack: stackId });
|
|
45
|
+
navigation.stack.pop({ stack: stackId });
|
|
47
46
|
},
|
|
48
47
|
}),
|
|
49
48
|
[stackId, navigation],
|
|
50
49
|
);
|
|
51
50
|
|
|
52
51
|
return (
|
|
53
|
-
<RenderTreeNode type=
|
|
52
|
+
<RenderTreeNode type={STACK_TYPE} id={stackId} active={props.active}>
|
|
54
53
|
<RNScreens.ScreenStack style={StyleSheet.absoluteFill}>
|
|
55
54
|
{props.rootScreen && <StackScreen>{props.rootScreen}</StackScreen>}
|
|
56
55
|
{props.children}
|
|
@@ -66,10 +65,17 @@ export type StackScreenProps = {
|
|
|
66
65
|
children: React.ReactNode;
|
|
67
66
|
};
|
|
68
67
|
|
|
68
|
+
const defaultStyle = StyleSheet.create({
|
|
69
|
+
screen: {
|
|
70
|
+
...StyleSheet.absoluteFillObject,
|
|
71
|
+
backgroundColor: "white",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
69
75
|
const StackScreen = React.memo(function StackScreen(props: StackScreenProps) {
|
|
70
76
|
return (
|
|
71
|
-
<RNScreens.Screen style={
|
|
72
|
-
<RenderTreeNode type=
|
|
77
|
+
<RNScreens.Screen style={defaultStyle.screen}>
|
|
78
|
+
<RenderTreeNode type={SCREEN_TYPE} id={props.id} active={props.active}>
|
|
73
79
|
{props.children}
|
|
74
80
|
</RenderTreeNode>
|
|
75
81
|
</RNScreens.Screen>
|
|
@@ -83,15 +89,30 @@ const StackSlot = React.memo(function StackSlot() {
|
|
|
83
89
|
|
|
84
90
|
return (
|
|
85
91
|
<React.Fragment>
|
|
86
|
-
{screens.map((screen, index, arr) =>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
{screens.map((screen, index, arr) => {
|
|
93
|
+
if (screen.options?.wrapped === false) {
|
|
94
|
+
return (
|
|
95
|
+
<RenderTreeNode
|
|
96
|
+
key={screen.options?.id ?? index}
|
|
97
|
+
type={SCREEN_TYPE}
|
|
98
|
+
id={screen.options?.id}
|
|
99
|
+
active={index === arr.length - 1}
|
|
100
|
+
>
|
|
101
|
+
{screen.element}
|
|
102
|
+
</RenderTreeNode>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<StackScreen
|
|
108
|
+
id={screen.options?.id}
|
|
109
|
+
key={screen.options?.id ?? index}
|
|
110
|
+
active={index === arr.length - 1}
|
|
111
|
+
>
|
|
112
|
+
{screen.element}
|
|
113
|
+
</StackScreen>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
95
116
|
</React.Fragment>
|
|
96
117
|
);
|
|
97
118
|
});
|
package/src/tabs.test.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { act, render, waitFor, fireEvent } from "@testing-library/react-native";
|
|
3
|
+
import { Pressable, Text } from "react-native";
|
|
4
4
|
import { RenderNodeProbe } from "@rn-tools/core/mocks/render-node-probe";
|
|
5
5
|
|
|
6
6
|
import {
|
|
@@ -11,20 +11,47 @@ import { Navigation } from "./navigation";
|
|
|
11
11
|
import { Tabs, type TabScreenOptions, type TabsHandle } from "./tabs";
|
|
12
12
|
import { Stack } from "./stack";
|
|
13
13
|
|
|
14
|
+
function collectTextNodes(node: unknown, output: string[]) {
|
|
15
|
+
if (typeof node === "string") {
|
|
16
|
+
output.push(node);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!node || typeof node !== "object") {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (Array.isArray(node)) {
|
|
25
|
+
node.forEach((item) => collectTextNodes(item, output));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const children = (node as { children?: unknown[] }).children;
|
|
30
|
+
if (children) {
|
|
31
|
+
children.forEach((child) => collectTextNodes(child, output));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textOrder(renderer: ReturnType<typeof render>) {
|
|
36
|
+
const text: string[] = [];
|
|
37
|
+
collectTextNodes(renderer.toJSON(), text);
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
|
|
14
41
|
function makeScreens(count: number): TabScreenOptions[] {
|
|
15
42
|
return Array.from({ length: count }, (_, i) => ({
|
|
16
43
|
id: `tab-${i}`,
|
|
17
44
|
screen: (
|
|
18
45
|
<RenderNodeProbe
|
|
19
46
|
render={(data) => (
|
|
20
|
-
<
|
|
47
|
+
<Text>{`tab-${i}:${data.type}:${String(data.active)}`}</Text>
|
|
21
48
|
)}
|
|
22
49
|
/>
|
|
23
50
|
),
|
|
24
51
|
tab: ({ isActive, onPress }) => (
|
|
25
|
-
<
|
|
26
|
-
{`tab-btn-${i}:${String(isActive)}`}
|
|
27
|
-
</
|
|
52
|
+
<Pressable testID={`tab-btn-${i}`} onPress={onPress}>
|
|
53
|
+
<Text>{`tab-btn-${i}:${String(isActive)}`}</Text>
|
|
54
|
+
</Pressable>
|
|
28
55
|
),
|
|
29
56
|
}));
|
|
30
57
|
}
|
|
@@ -69,7 +96,7 @@ describe("Tabs", () => {
|
|
|
69
96
|
);
|
|
70
97
|
|
|
71
98
|
act(() => {
|
|
72
|
-
navigation.tab(2);
|
|
99
|
+
navigation.tabs.tab(2);
|
|
73
100
|
});
|
|
74
101
|
|
|
75
102
|
await waitFor(() => {
|
|
@@ -129,7 +156,6 @@ describe("Tabs", () => {
|
|
|
129
156
|
expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
|
|
130
157
|
});
|
|
131
158
|
|
|
132
|
-
// The tab state should be set on the auto-generated tabs id
|
|
133
159
|
const state = store.getState();
|
|
134
160
|
expect(state.tabs.size).toBe(1);
|
|
135
161
|
const [, tabState] = [...state.tabs.entries()][0];
|
|
@@ -143,13 +169,13 @@ describe("Tabs", () => {
|
|
|
143
169
|
const tabScreens: TabScreenOptions[] = [
|
|
144
170
|
{
|
|
145
171
|
id: "tab-a",
|
|
146
|
-
screen: <
|
|
147
|
-
tab: () => <
|
|
172
|
+
screen: <Text>tab-a-content</Text>,
|
|
173
|
+
tab: () => <Text>tab-a</Text>,
|
|
148
174
|
},
|
|
149
175
|
{
|
|
150
176
|
id: "tab-b",
|
|
151
|
-
screen: <
|
|
152
|
-
tab: () => <
|
|
177
|
+
screen: <Text>tab-b-content</Text>,
|
|
178
|
+
tab: () => <Text>tab-b</Text>,
|
|
153
179
|
},
|
|
154
180
|
];
|
|
155
181
|
|
|
@@ -170,7 +196,6 @@ describe("Tabs", () => {
|
|
|
170
196
|
ref.current!.setActive(1);
|
|
171
197
|
});
|
|
172
198
|
|
|
173
|
-
// The tab switch should target the auto-generated tabs id, not "outer-stack"
|
|
174
199
|
const state = navigation.store.getState();
|
|
175
200
|
expect(state.tabs.has("outer-stack")).toBe(false);
|
|
176
201
|
expect(state.tabs.size).toBe(1);
|
|
@@ -210,7 +235,7 @@ describe("TabBar", () => {
|
|
|
210
235
|
<Tabs id="my-tabs" screens={screens} />,
|
|
211
236
|
);
|
|
212
237
|
|
|
213
|
-
fireEvent.
|
|
238
|
+
fireEvent.press(renderer.getByTestId("tab-btn-2"));
|
|
214
239
|
|
|
215
240
|
await waitFor(() => {
|
|
216
241
|
expect(renderer.getByText("tab-btn-0:false")).toBeTruthy();
|
|
@@ -226,12 +251,10 @@ describe("TabBar", () => {
|
|
|
226
251
|
<Tabs id="my-tabs" screens={screens} />,
|
|
227
252
|
);
|
|
228
253
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// DOCUMENT_POSITION_FOLLOWING = 4
|
|
234
|
-
expect(order & 4).toBe(4);
|
|
254
|
+
const order = textOrder(renderer);
|
|
255
|
+
expect(order.indexOf("tab-0:tab-screen:true")).toBeLessThan(
|
|
256
|
+
order.indexOf("tab-btn-0:true"),
|
|
257
|
+
);
|
|
235
258
|
});
|
|
236
259
|
|
|
237
260
|
it("renders tabbar at top when tabbarPosition is top", () => {
|
|
@@ -240,12 +263,10 @@ describe("TabBar", () => {
|
|
|
240
263
|
<Tabs id="my-tabs" screens={screens} tabbarPosition="top" />,
|
|
241
264
|
);
|
|
242
265
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// DOCUMENT_POSITION_FOLLOWING = 4
|
|
248
|
-
expect(order & 4).toBe(4);
|
|
266
|
+
const order = textOrder(renderer);
|
|
267
|
+
expect(order.indexOf("tab-btn-0:true")).toBeLessThan(
|
|
268
|
+
order.indexOf("tab-0:tab-screen:true"),
|
|
269
|
+
);
|
|
249
270
|
});
|
|
250
271
|
});
|
|
251
272
|
|
|
@@ -256,13 +277,13 @@ describe("Nested Stack + Tabs", () => {
|
|
|
256
277
|
const screens: TabScreenOptions[] = [
|
|
257
278
|
{
|
|
258
279
|
id: "tab-a",
|
|
259
|
-
screen: <Stack id="stack-a" rootScreen={<
|
|
260
|
-
tab: () => <
|
|
280
|
+
screen: <Stack id="stack-a" rootScreen={<Text>stack-a-root</Text>} />,
|
|
281
|
+
tab: () => <Text>tab-a</Text>,
|
|
261
282
|
},
|
|
262
283
|
{
|
|
263
284
|
id: "tab-b",
|
|
264
|
-
screen: <Stack id="stack-b" rootScreen={<
|
|
265
|
-
tab: () => <
|
|
285
|
+
screen: <Stack id="stack-b" rootScreen={<Text>stack-b-root</Text>} />,
|
|
286
|
+
tab: () => <Text>tab-b</Text>,
|
|
266
287
|
},
|
|
267
288
|
];
|
|
268
289
|
|
|
@@ -276,9 +297,8 @@ describe("Nested Stack + Tabs", () => {
|
|
|
276
297
|
expect(result.getByText("stack-a-root")).toBeTruthy();
|
|
277
298
|
});
|
|
278
299
|
|
|
279
|
-
// Tab 0 (stack-a) is active — push should target stack-a
|
|
280
300
|
act(() => {
|
|
281
|
-
navigation.push(<
|
|
301
|
+
navigation.stack.push(<Text>pushed-to-a</Text>);
|
|
282
302
|
});
|
|
283
303
|
|
|
284
304
|
const stateAfterFirst = navigation.store.getState();
|
|
@@ -292,13 +312,13 @@ describe("Nested Stack + Tabs", () => {
|
|
|
292
312
|
const screens: TabScreenOptions[] = [
|
|
293
313
|
{
|
|
294
314
|
id: "tab-a",
|
|
295
|
-
screen: <Stack id="stack-a" rootScreen={<
|
|
296
|
-
tab: () => <
|
|
315
|
+
screen: <Stack id="stack-a" rootScreen={<Text>stack-a-root</Text>} />,
|
|
316
|
+
tab: () => <Text>tab-a</Text>,
|
|
297
317
|
},
|
|
298
318
|
{
|
|
299
319
|
id: "tab-b",
|
|
300
|
-
screen: <Stack id="stack-b" rootScreen={<
|
|
301
|
-
tab: () => <
|
|
320
|
+
screen: <Stack id="stack-b" rootScreen={<Text>stack-b-root</Text>} />,
|
|
321
|
+
tab: () => <Text>tab-b</Text>,
|
|
302
322
|
},
|
|
303
323
|
];
|
|
304
324
|
|
|
@@ -308,13 +328,12 @@ describe("Nested Stack + Tabs", () => {
|
|
|
308
328
|
</Navigation>,
|
|
309
329
|
);
|
|
310
330
|
|
|
311
|
-
// Switch to tab 1 (stack-b)
|
|
312
331
|
act(() => {
|
|
313
|
-
navigation.tab(1);
|
|
332
|
+
navigation.tabs.tab(1);
|
|
314
333
|
});
|
|
315
334
|
|
|
316
335
|
act(() => {
|
|
317
|
-
navigation.push(<
|
|
336
|
+
navigation.stack.push(<Text>pushed-to-b</Text>);
|
|
318
337
|
});
|
|
319
338
|
|
|
320
339
|
const state = navigation.store.getState();
|
|
@@ -328,19 +347,22 @@ describe("Nested Stack + Tabs", () => {
|
|
|
328
347
|
const tabScreens: TabScreenOptions[] = [
|
|
329
348
|
{
|
|
330
349
|
id: "tab-a",
|
|
331
|
-
screen: <
|
|
332
|
-
tab: () => <
|
|
350
|
+
screen: <Text>tab-a-content</Text>,
|
|
351
|
+
tab: () => <Text>tab-a</Text>,
|
|
333
352
|
},
|
|
334
353
|
{
|
|
335
354
|
id: "tab-b",
|
|
336
|
-
screen: <
|
|
337
|
-
tab: () => <
|
|
355
|
+
screen: <Text>tab-b-content</Text>,
|
|
356
|
+
tab: () => <Text>tab-b</Text>,
|
|
338
357
|
},
|
|
339
358
|
];
|
|
340
359
|
|
|
341
360
|
const result = render(
|
|
342
361
|
<Navigation navigation={navigation}>
|
|
343
|
-
<Stack
|
|
362
|
+
<Stack
|
|
363
|
+
id="outer-stack"
|
|
364
|
+
rootScreen={<Tabs id="inner-tabs" screens={tabScreens} />}
|
|
365
|
+
/>
|
|
344
366
|
</Navigation>,
|
|
345
367
|
);
|
|
346
368
|
|
|
@@ -348,9 +370,8 @@ describe("Nested Stack + Tabs", () => {
|
|
|
348
370
|
expect(result.getByText("tab-a-content")).toBeTruthy();
|
|
349
371
|
});
|
|
350
372
|
|
|
351
|
-
// tab with no explicit tabs should resolve inner-tabs
|
|
352
373
|
act(() => {
|
|
353
|
-
navigation.tab(1);
|
|
374
|
+
navigation.tabs.tab(1);
|
|
354
375
|
});
|
|
355
376
|
|
|
356
377
|
const state = navigation.store.getState();
|
package/src/tabs.tsx
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
useTabActiveIndex,
|
|
10
10
|
useNavigationStore,
|
|
11
11
|
useNavigation,
|
|
12
|
+
TABS_TYPE,
|
|
13
|
+
TAB_SCREEN_TYPE,
|
|
12
14
|
} from "./navigation-client";
|
|
13
15
|
|
|
14
16
|
import * as RNScreens from "react-native-screens";
|
|
@@ -40,7 +42,7 @@ export type TabsProps = {
|
|
|
40
42
|
const TabsRoot = React.memo(
|
|
41
43
|
React.forwardRef<TabsHandle, TabsProps>(function TabsRoot(props, ref) {
|
|
42
44
|
const tabsId = React.useRef(
|
|
43
|
-
props.id ?? nextRenderTreeIdForType(
|
|
45
|
+
props.id ?? nextRenderTreeIdForType(TABS_TYPE),
|
|
44
46
|
).current;
|
|
45
47
|
const navigation = useNavigation();
|
|
46
48
|
|
|
@@ -48,14 +50,14 @@ const TabsRoot = React.memo(
|
|
|
48
50
|
ref,
|
|
49
51
|
() => ({
|
|
50
52
|
setActive(index: number) {
|
|
51
|
-
navigation.tab(index, { tabs: tabsId });
|
|
53
|
+
navigation.tabs.tab(index, { tabs: tabsId });
|
|
52
54
|
},
|
|
53
55
|
}),
|
|
54
56
|
[tabsId, navigation],
|
|
55
57
|
);
|
|
56
58
|
|
|
57
59
|
return (
|
|
58
|
-
<RenderTreeNode type=
|
|
60
|
+
<RenderTreeNode type={TABS_TYPE} id={tabsId} active={props.active}>
|
|
59
61
|
{props.children}
|
|
60
62
|
</RenderTreeNode>
|
|
61
63
|
);
|
|
@@ -127,7 +129,7 @@ const TabsSlot = React.memo(function TabsSlot(props: {
|
|
|
127
129
|
style={StyleSheet.absoluteFill}
|
|
128
130
|
>
|
|
129
131
|
<RenderTreeNode
|
|
130
|
-
type=
|
|
132
|
+
type={TAB_SCREEN_TYPE}
|
|
131
133
|
id={`${tabsId}/${entry.id}`}
|
|
132
134
|
active={index === activeIndex}
|
|
133
135
|
>
|