@rn-tools/navigation 3.0.1 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/readme.md +6 -3
- package/src/navigation-client.test.tsx +53 -22
- package/src/navigation-client.tsx +46 -17
- package/src/navigation.tsx +8 -5
- package/src/stack.test.tsx +85 -16
- package/src/stack.tsx +42 -32
- package/src/tabs.test.tsx +85 -14
- package/src/tabs.tsx +32 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rn-tools/navigation",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"main": "./src/index.ts",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"react-native-screens": "*"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@rn-tools/sheets": "*",
|
|
37
38
|
"path-to-regexp": "^6.2.2",
|
|
38
39
|
"zustand": "^4.5.2"
|
|
39
40
|
}
|
package/readme.md
CHANGED
|
@@ -54,9 +54,12 @@ export default function App() {
|
|
|
54
54
|
Push and pop screens imperatively:
|
|
55
55
|
|
|
56
56
|
```tsx
|
|
57
|
-
navigation.
|
|
58
|
-
navigation.
|
|
59
|
-
navigation.
|
|
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();
|
|
60
63
|
```
|
|
61
64
|
|
|
62
65
|
When no explicit target is provided, these methods automatically resolve the deepest active stack or tabs instance.
|
|
@@ -48,9 +48,13 @@ describe("createNavigation", () => {
|
|
|
48
48
|
const nav = createNavigation();
|
|
49
49
|
expect(nav.store).toBeDefined();
|
|
50
50
|
expect(nav.renderTreeStore).toBeDefined();
|
|
51
|
-
expect(
|
|
52
|
-
expect(typeof nav.
|
|
53
|
-
expect(typeof nav.
|
|
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");
|
|
54
58
|
});
|
|
55
59
|
|
|
56
60
|
it("initializes with empty state by default", () => {
|
|
@@ -71,6 +75,33 @@ describe("createNavigation", () => {
|
|
|
71
75
|
});
|
|
72
76
|
});
|
|
73
77
|
|
|
78
|
+
describe("sheet methods", () => {
|
|
79
|
+
it("present returns a key", () => {
|
|
80
|
+
const nav = createNavigation();
|
|
81
|
+
const key = nav.present(<span>sheet</span>);
|
|
82
|
+
|
|
83
|
+
expect(typeof key).toBe("string");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("present reuses key when id is reused", () => {
|
|
87
|
+
const nav = createNavigation();
|
|
88
|
+
const key1 = nav.present(<span>sheet-a</span>, { id: "edit" });
|
|
89
|
+
const key2 = nav.present(<span>sheet-b</span>, { id: "edit" });
|
|
90
|
+
|
|
91
|
+
expect(key2).toBe(key1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("dismiss and dismissAll are callable via public API", () => {
|
|
95
|
+
const nav = createNavigation();
|
|
96
|
+
nav.present(<span>a</span>);
|
|
97
|
+
nav.present(<span>b</span>);
|
|
98
|
+
|
|
99
|
+
expect(() => nav.dismiss()).not.toThrow();
|
|
100
|
+
nav.dismissAll();
|
|
101
|
+
expect(() => nav.dismissAll()).not.toThrow();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
74
105
|
describe("loadNavigationState", () => {
|
|
75
106
|
it("replaces the store state with normalized input", () => {
|
|
76
107
|
const nav = createNavigation({
|
|
@@ -87,13 +118,13 @@ describe("loadNavigationState", () => {
|
|
|
87
118
|
});
|
|
88
119
|
});
|
|
89
120
|
|
|
90
|
-
describe("
|
|
121
|
+
describe("push", () => {
|
|
91
122
|
it("pushes a screen to the specified stack", () => {
|
|
92
123
|
const nav = createNavigation({
|
|
93
124
|
stacks: { "stack-a": [] },
|
|
94
125
|
});
|
|
95
126
|
|
|
96
|
-
nav.
|
|
127
|
+
nav.push(<span>pushed</span>, { stack: "stack-a" });
|
|
97
128
|
|
|
98
129
|
const screens = nav.store.getState().stacks.get("stack-a");
|
|
99
130
|
expect(screens).toHaveLength(1);
|
|
@@ -102,7 +133,7 @@ describe("pushScreen", () => {
|
|
|
102
133
|
it("creates the stack entry if it did not exist", () => {
|
|
103
134
|
const nav = createNavigation();
|
|
104
135
|
|
|
105
|
-
nav.
|
|
136
|
+
nav.push(<span>pushed</span>, { stack: "stack-new" });
|
|
106
137
|
|
|
107
138
|
const screens = nav.store.getState().stacks.get("stack-new");
|
|
108
139
|
expect(screens).toHaveLength(1);
|
|
@@ -115,7 +146,7 @@ describe("pushScreen", () => {
|
|
|
115
146
|
},
|
|
116
147
|
});
|
|
117
148
|
|
|
118
|
-
nav.
|
|
149
|
+
nav.push(<span>dup</span>, { id: "screen-1", stack: "stack-a" });
|
|
119
150
|
|
|
120
151
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
|
|
121
152
|
});
|
|
@@ -127,22 +158,22 @@ describe("pushScreen", () => {
|
|
|
127
158
|
},
|
|
128
159
|
});
|
|
129
160
|
|
|
130
|
-
nav.
|
|
161
|
+
nav.pop({ stack: "stack-a" });
|
|
131
162
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(0);
|
|
132
163
|
|
|
133
|
-
nav.
|
|
164
|
+
nav.push(<span>new</span>, { id: "screen-1", stack: "stack-a" });
|
|
134
165
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
|
|
135
166
|
});
|
|
136
167
|
|
|
137
|
-
it("throws when no
|
|
168
|
+
it("throws when no stack is provided and no stack is mounted", () => {
|
|
138
169
|
const nav = createNavigation();
|
|
139
|
-
expect(() => nav.
|
|
140
|
-
"could not resolve
|
|
170
|
+
expect(() => nav.push(<span>x</span>)).toThrow(
|
|
171
|
+
"could not resolve stack",
|
|
141
172
|
);
|
|
142
173
|
});
|
|
143
174
|
});
|
|
144
175
|
|
|
145
|
-
describe("
|
|
176
|
+
describe("pop", () => {
|
|
146
177
|
it("removes the top screen from the specified stack", () => {
|
|
147
178
|
const nav = createNavigation({
|
|
148
179
|
stacks: {
|
|
@@ -153,7 +184,7 @@ describe("popScreen", () => {
|
|
|
153
184
|
},
|
|
154
185
|
});
|
|
155
186
|
|
|
156
|
-
nav.
|
|
187
|
+
nav.pop({ stack: "stack-a" });
|
|
157
188
|
|
|
158
189
|
expect(nav.store.getState().stacks.get("stack-a")).toHaveLength(1);
|
|
159
190
|
});
|
|
@@ -164,25 +195,25 @@ describe("popScreen", () => {
|
|
|
164
195
|
});
|
|
165
196
|
|
|
166
197
|
const before = nav.store.getState();
|
|
167
|
-
nav.
|
|
198
|
+
nav.pop({ stack: "stack-a" });
|
|
168
199
|
const after = nav.store.getState();
|
|
169
200
|
|
|
170
201
|
expect(before).toBe(after);
|
|
171
202
|
});
|
|
172
203
|
|
|
173
|
-
it("throws when no
|
|
204
|
+
it("throws when no stack is provided and no stack is mounted", () => {
|
|
174
205
|
const nav = createNavigation();
|
|
175
|
-
expect(() => nav.
|
|
206
|
+
expect(() => nav.pop()).toThrow("could not resolve stack");
|
|
176
207
|
});
|
|
177
208
|
});
|
|
178
209
|
|
|
179
|
-
describe("
|
|
210
|
+
describe("tab", () => {
|
|
180
211
|
it("sets the active index for the specified tabs", () => {
|
|
181
212
|
const nav = createNavigation({
|
|
182
213
|
tabs: { "my-tabs": { activeIndex: 0 } },
|
|
183
214
|
});
|
|
184
215
|
|
|
185
|
-
nav.
|
|
216
|
+
nav.tab(2, { tabs: "my-tabs" });
|
|
186
217
|
|
|
187
218
|
expect(nav.store.getState().tabs.get("my-tabs")).toEqual({
|
|
188
219
|
activeIndex: 2,
|
|
@@ -192,15 +223,15 @@ describe("setActiveTab", () => {
|
|
|
192
223
|
it("creates the tabs entry if it did not exist", () => {
|
|
193
224
|
const nav = createNavigation();
|
|
194
225
|
|
|
195
|
-
nav.
|
|
226
|
+
nav.tab(1, { tabs: "new-tabs" });
|
|
196
227
|
|
|
197
228
|
expect(nav.store.getState().tabs.get("new-tabs")).toEqual({
|
|
198
229
|
activeIndex: 1,
|
|
199
230
|
});
|
|
200
231
|
});
|
|
201
232
|
|
|
202
|
-
it("throws when no
|
|
233
|
+
it("throws when no tabs is provided and no tabs are mounted", () => {
|
|
203
234
|
const nav = createNavigation();
|
|
204
|
-
expect(() => nav.
|
|
235
|
+
expect(() => nav.tab(0)).toThrow("could not resolve tabs");
|
|
205
236
|
});
|
|
206
237
|
});
|
|
@@ -7,16 +7,18 @@ import {
|
|
|
7
7
|
getRenderNodeDepth,
|
|
8
8
|
} from "@rn-tools/core";
|
|
9
9
|
import type { Store, RenderTreeStore } from "@rn-tools/core";
|
|
10
|
+
import { createSheets } from "@rn-tools/sheets";
|
|
11
|
+
import type { SheetOptions, SheetsClient } from "@rn-tools/sheets";
|
|
10
12
|
|
|
11
|
-
export type
|
|
13
|
+
export type PushOptions = {
|
|
12
14
|
id?: string;
|
|
13
|
-
|
|
15
|
+
stack?: string;
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
export type NavigationScreenEntry = {
|
|
17
19
|
element: React.ReactElement;
|
|
18
20
|
id?: string;
|
|
19
|
-
options?:
|
|
21
|
+
options?: PushOptions;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
export type TabState = {
|
|
@@ -61,9 +63,13 @@ export function loadNavigationState(
|
|
|
61
63
|
export type NavigationClient = {
|
|
62
64
|
store: NavigationStore;
|
|
63
65
|
renderTreeStore: RenderTreeStore;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
sheetsStore: SheetsClient;
|
|
67
|
+
push: (element: React.ReactElement, options?: PushOptions) => void;
|
|
68
|
+
pop: (options?: { stack?: string }) => void;
|
|
69
|
+
tab: (index: number, options?: { tabs?: string }) => void;
|
|
70
|
+
present: (element: React.ReactElement, options?: SheetOptions) => string;
|
|
71
|
+
dismiss: (id?: string) => void;
|
|
72
|
+
dismissAll: () => void;
|
|
67
73
|
};
|
|
68
74
|
|
|
69
75
|
export function createNavigation(
|
|
@@ -73,6 +79,7 @@ export function createNavigation(
|
|
|
73
79
|
normalizeNavigationState(initialState ?? { stacks: new Map() }),
|
|
74
80
|
);
|
|
75
81
|
const renderTreeStore = createRenderTreeStore();
|
|
82
|
+
const sheetsStore = createSheets();
|
|
76
83
|
|
|
77
84
|
function getDeepestActiveNodeId(type: string): string | null {
|
|
78
85
|
const tree = renderTreeStore.getState();
|
|
@@ -93,14 +100,14 @@ export function createNavigation(
|
|
|
93
100
|
return deepestId;
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
function
|
|
103
|
+
function push(
|
|
97
104
|
element: React.ReactElement,
|
|
98
|
-
options?:
|
|
105
|
+
options?: PushOptions,
|
|
99
106
|
) {
|
|
100
|
-
const stackId = options?.
|
|
107
|
+
const stackId = options?.stack ?? getDeepestActiveNodeId("stack");
|
|
101
108
|
if (!stackId) {
|
|
102
109
|
throw new Error(
|
|
103
|
-
"
|
|
110
|
+
"push: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
|
|
104
111
|
);
|
|
105
112
|
}
|
|
106
113
|
|
|
@@ -122,11 +129,11 @@ export function createNavigation(
|
|
|
122
129
|
});
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
function
|
|
126
|
-
const stackId = options?.
|
|
132
|
+
function pop(options?: { stack?: string }) {
|
|
133
|
+
const stackId = options?.stack ?? getDeepestActiveNodeId("stack");
|
|
127
134
|
if (!stackId) {
|
|
128
135
|
throw new Error(
|
|
129
|
-
"
|
|
136
|
+
"pop: could not resolve stack. Pass { stack } explicitly or ensure a Stack is mounted and active.",
|
|
130
137
|
);
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -139,11 +146,11 @@ export function createNavigation(
|
|
|
139
146
|
});
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
function
|
|
143
|
-
const tabsId = options?.
|
|
149
|
+
function tab(index: number, options?: { tabs?: string }) {
|
|
150
|
+
const tabsId = options?.tabs ?? getDeepestActiveNodeId("tabs");
|
|
144
151
|
if (!tabsId) {
|
|
145
152
|
throw new Error(
|
|
146
|
-
"
|
|
153
|
+
"tab: could not resolve tabs. Pass { tabs } explicitly or ensure a Tabs is mounted and active.",
|
|
147
154
|
);
|
|
148
155
|
}
|
|
149
156
|
|
|
@@ -154,7 +161,29 @@ export function createNavigation(
|
|
|
154
161
|
});
|
|
155
162
|
}
|
|
156
163
|
|
|
157
|
-
|
|
164
|
+
function present(element: React.ReactElement, options?: SheetOptions): string {
|
|
165
|
+
return sheetsStore.present(element, options);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function dismiss(id?: string) {
|
|
169
|
+
sheetsStore.dismiss(id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function dismissAll() {
|
|
173
|
+
sheetsStore.dismissAll();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
store: navStore,
|
|
178
|
+
renderTreeStore,
|
|
179
|
+
sheetsStore,
|
|
180
|
+
push,
|
|
181
|
+
pop,
|
|
182
|
+
tab,
|
|
183
|
+
present,
|
|
184
|
+
dismiss,
|
|
185
|
+
dismissAll,
|
|
186
|
+
};
|
|
158
187
|
}
|
|
159
188
|
|
|
160
189
|
export function useNavigation(): NavigationClient {
|
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 { SheetsProvider } from "@rn-tools/sheets";
|
|
3
4
|
import {
|
|
4
5
|
NavigationContext,
|
|
5
6
|
NavigationStoreContext,
|
|
@@ -30,11 +31,13 @@ export const Navigation = React.memo(function Navigation(
|
|
|
30
31
|
) {
|
|
31
32
|
return (
|
|
32
33
|
<RenderTree store={props.navigation.renderTreeStore}>
|
|
33
|
-
<
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
<SheetsProvider sheets={props.navigation.sheetsStore}>
|
|
35
|
+
<NavigationContext.Provider value={props.navigation}>
|
|
36
|
+
<NavigationStoreContext.Provider value={props.navigation.store}>
|
|
37
|
+
<RootStack>{props.children}</RootStack>
|
|
38
|
+
</NavigationStoreContext.Provider>
|
|
39
|
+
</NavigationContext.Provider>
|
|
40
|
+
</SheetsProvider>
|
|
38
41
|
</RenderTree>
|
|
39
42
|
);
|
|
40
43
|
});
|
package/src/stack.test.tsx
CHANGED
|
@@ -110,7 +110,7 @@ describe("Stack", () => {
|
|
|
110
110
|
expect(renderer.getByText("screen-b:true")).toBeTruthy();
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
it("
|
|
113
|
+
it("push adds a new active screen to the stack", async () => {
|
|
114
114
|
const { navigation, renderer } = await renderWithProviders(
|
|
115
115
|
<Stack id="stack-a" />,
|
|
116
116
|
{
|
|
@@ -132,13 +132,13 @@ describe("Stack", () => {
|
|
|
132
132
|
);
|
|
133
133
|
|
|
134
134
|
act(() => {
|
|
135
|
-
navigation.
|
|
135
|
+
navigation.push(
|
|
136
136
|
<RenderNodeProbe
|
|
137
137
|
render={(data) => <span>{`screen-b:${String(data.active)}`}</span>}
|
|
138
138
|
/>,
|
|
139
139
|
{
|
|
140
140
|
id: "screen-b",
|
|
141
|
-
|
|
141
|
+
stack: "stack-a",
|
|
142
142
|
},
|
|
143
143
|
);
|
|
144
144
|
});
|
|
@@ -227,7 +227,7 @@ describe("Stack", () => {
|
|
|
227
227
|
expect(renderer.getByText("a1:true")).toBeTruthy();
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
it("
|
|
230
|
+
it("push does not push a duplicate when a screen with the same id exists", async () => {
|
|
231
231
|
const { navigation, renderer } = await renderWithProviders(
|
|
232
232
|
<Stack id="stack-a" />,
|
|
233
233
|
{
|
|
@@ -243,9 +243,9 @@ describe("Stack", () => {
|
|
|
243
243
|
);
|
|
244
244
|
|
|
245
245
|
act(() => {
|
|
246
|
-
navigation.
|
|
246
|
+
navigation.push(<span>screen-a-dup</span>, {
|
|
247
247
|
id: "screen-a",
|
|
248
|
-
|
|
248
|
+
stack: "stack-a",
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
251
|
|
|
@@ -253,16 +253,16 @@ describe("Stack", () => {
|
|
|
253
253
|
expect(renderer.getByText("screen-a")).toBeTruthy();
|
|
254
254
|
|
|
255
255
|
act(() => {
|
|
256
|
-
navigation.
|
|
256
|
+
navigation.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.
|
|
263
|
+
navigation.push(<span>screen-a-again</span>, {
|
|
264
264
|
id: "screen-a",
|
|
265
|
-
|
|
265
|
+
stack: "stack-a",
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
268
|
|
|
@@ -270,30 +270,99 @@ describe("Stack", () => {
|
|
|
270
270
|
renderer.getByText("screen-a-again");
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
-
it("ref.
|
|
273
|
+
it("ref.push adds a screen and ref.pop removes it", async () => {
|
|
274
274
|
const ref = React.createRef<StackHandle>();
|
|
275
|
-
const { renderer } = await renderWithProviders(
|
|
275
|
+
const { renderer, store } = await renderWithProviders(
|
|
276
276
|
<Stack ref={ref} id="stack-a" rootScreen={<span>root</span>} />,
|
|
277
277
|
);
|
|
278
278
|
|
|
279
279
|
expect(renderer.getByText("root")).toBeTruthy();
|
|
280
280
|
|
|
281
281
|
act(() => {
|
|
282
|
-
ref.current!.
|
|
282
|
+
ref.current!.push(<span>pushed</span>, { id: "pushed-screen" });
|
|
283
283
|
});
|
|
284
284
|
|
|
285
285
|
await waitFor(() => {
|
|
286
286
|
expect(renderer.getByText("pushed")).toBeTruthy();
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
expect(store.getState().stacks.get("stack-a")).toHaveLength(1);
|
|
290
|
+
|
|
289
291
|
act(() => {
|
|
290
|
-
ref.current!.
|
|
292
|
+
ref.current!.pop();
|
|
291
293
|
});
|
|
292
294
|
|
|
293
295
|
await waitFor(() => {
|
|
294
296
|
expect(renderer.queryByText("pushed")).toBeNull();
|
|
295
297
|
expect(renderer.getByText("root")).toBeTruthy();
|
|
296
298
|
});
|
|
299
|
+
|
|
300
|
+
expect(store.getState().stacks.get("stack-a")).toHaveLength(0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("ref.push targets the correct stack when id is not provided", async () => {
|
|
304
|
+
const ref = React.createRef<StackHandle>();
|
|
305
|
+
const { renderer, store } = await renderWithProviders(
|
|
306
|
+
<Stack ref={ref} rootScreen={<span>root</span>} />,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
expect(renderer.getByText("root")).toBeTruthy();
|
|
310
|
+
|
|
311
|
+
act(() => {
|
|
312
|
+
ref.current!.push(<span>pushed</span>, { id: "pushed-screen" });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await waitFor(() => {
|
|
316
|
+
expect(renderer.getByText("pushed")).toBeTruthy();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// The screen should be pushed to the auto-generated stack id
|
|
320
|
+
const state = store.getState();
|
|
321
|
+
expect(state.stacks.size).toBe(1);
|
|
322
|
+
const [stackId, screens] = [...state.stacks.entries()][0];
|
|
323
|
+
expect(typeof stackId).toBe("string");
|
|
324
|
+
expect(screens).toHaveLength(1);
|
|
325
|
+
|
|
326
|
+
act(() => {
|
|
327
|
+
ref.current!.pop();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await waitFor(() => {
|
|
331
|
+
expect(renderer.queryByText("pushed")).toBeNull();
|
|
332
|
+
expect(renderer.getByText("root")).toBeTruthy();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(store.getState().stacks.get(stackId)).toHaveLength(0);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("ref.push targets the inner stack when nested without an explicit id", async () => {
|
|
339
|
+
const innerRef = React.createRef<StackHandle>();
|
|
340
|
+
|
|
341
|
+
function InnerStack() {
|
|
342
|
+
return <Stack ref={innerRef} rootScreen={<span>inner-root</span>} />;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const { renderer, store } = await renderWithProviders(
|
|
346
|
+
<Stack id="outer" rootScreen={<InnerStack />} />,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await waitFor(() => {
|
|
350
|
+
expect(renderer.getByText("inner-root")).toBeTruthy();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
act(() => {
|
|
354
|
+
innerRef.current!.push(<span>inner-pushed</span>, {
|
|
355
|
+
id: "inner-screen",
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await waitFor(() => {
|
|
360
|
+
expect(renderer.getByText("inner-pushed")).toBeTruthy();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// The screen was pushed to the inner stack's auto-generated id, not "outer"
|
|
364
|
+
const state = store.getState();
|
|
365
|
+
expect(state.stacks.has("outer")).toBe(false);
|
|
297
366
|
});
|
|
298
367
|
|
|
299
368
|
it("resolves the deepest active stack and falls back when a subtree becomes inactive", async () => {
|
|
@@ -314,9 +383,9 @@ describe("Stack", () => {
|
|
|
314
383
|
expect(result.getByText("nested-root")).toBeTruthy();
|
|
315
384
|
});
|
|
316
385
|
|
|
317
|
-
//
|
|
386
|
+
// push with no stack should target the deepest active stack: right-nested
|
|
318
387
|
act(() => {
|
|
319
|
-
navigation.
|
|
388
|
+
navigation.push(<span>first-push</span>);
|
|
320
389
|
});
|
|
321
390
|
|
|
322
391
|
const stateAfterFirst = navigation.store.getState();
|
|
@@ -332,7 +401,7 @@ describe("Stack", () => {
|
|
|
332
401
|
);
|
|
333
402
|
|
|
334
403
|
act(() => {
|
|
335
|
-
navigation.
|
|
404
|
+
navigation.push(<span>second-push</span>);
|
|
336
405
|
});
|
|
337
406
|
|
|
338
407
|
const stateAfterSecond = navigation.store.getState();
|
package/src/stack.tsx
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
RenderTreeNode,
|
|
4
|
+
useRenderNode,
|
|
5
|
+
nextRenderTreeIdForType,
|
|
6
|
+
} from "@rn-tools/core";
|
|
3
7
|
import {
|
|
4
8
|
useStackScreens,
|
|
5
9
|
useNavigation,
|
|
6
|
-
type
|
|
10
|
+
type PushOptions,
|
|
7
11
|
} from "./navigation-client";
|
|
8
12
|
|
|
9
13
|
// TODO - replace with custom implementation
|
|
@@ -11,8 +15,11 @@ import * as RNScreens from "react-native-screens";
|
|
|
11
15
|
import { StyleSheet } from "react-native";
|
|
12
16
|
|
|
13
17
|
export type StackHandle = {
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
push: (
|
|
19
|
+
element: React.ReactElement,
|
|
20
|
+
options?: PushOptions,
|
|
21
|
+
) => void;
|
|
22
|
+
pop: () => void;
|
|
16
23
|
};
|
|
17
24
|
|
|
18
25
|
export type StackProps = {
|
|
@@ -22,16 +29,36 @@ export type StackProps = {
|
|
|
22
29
|
children?: React.ReactNode;
|
|
23
30
|
};
|
|
24
31
|
|
|
25
|
-
const StackRoot = React.memo(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const StackRoot = React.memo(
|
|
33
|
+
React.forwardRef<StackHandle, StackProps>(function StackRoot(props, ref) {
|
|
34
|
+
const stackId = React.useRef(
|
|
35
|
+
props.id ?? nextRenderTreeIdForType("stack"),
|
|
36
|
+
).current;
|
|
37
|
+
const navigation = useNavigation();
|
|
38
|
+
|
|
39
|
+
React.useImperativeHandle(
|
|
40
|
+
ref,
|
|
41
|
+
() => ({
|
|
42
|
+
push(element: React.ReactElement, options?: PushOptions) {
|
|
43
|
+
navigation.push(element, { ...options, stack: stackId });
|
|
44
|
+
},
|
|
45
|
+
pop() {
|
|
46
|
+
navigation.pop({ stack: stackId });
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
[stackId, navigation],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<RenderTreeNode type="stack" id={stackId} active={props.active}>
|
|
54
|
+
<RNScreens.ScreenStack style={StyleSheet.absoluteFill}>
|
|
55
|
+
{props.rootScreen && <StackScreen>{props.rootScreen}</StackScreen>}
|
|
56
|
+
{props.children}
|
|
57
|
+
</RNScreens.ScreenStack>
|
|
58
|
+
</RenderTreeNode>
|
|
59
|
+
);
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
35
62
|
|
|
36
63
|
export type StackScreenProps = {
|
|
37
64
|
id?: string;
|
|
@@ -72,25 +99,8 @@ const StackSlot = React.memo(function StackSlot() {
|
|
|
72
99
|
export const Stack = React.memo(
|
|
73
100
|
React.forwardRef<StackHandle, Omit<StackProps, "children">>(
|
|
74
101
|
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
102
|
return (
|
|
93
|
-
<StackRoot {...props}>
|
|
103
|
+
<StackRoot ref={ref} {...props}>
|
|
94
104
|
<StackSlot />
|
|
95
105
|
</StackRoot>
|
|
96
106
|
);
|
package/src/tabs.test.tsx
CHANGED
|
@@ -62,14 +62,14 @@ describe("Tabs", () => {
|
|
|
62
62
|
expect(renderer.getByText("tab-2:tab-screen:false")).toBeTruthy();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
it("
|
|
65
|
+
it("tab changes which screen is active", async () => {
|
|
66
66
|
const screens = makeScreens(3);
|
|
67
67
|
const { navigation, renderer } = renderWithProviders(
|
|
68
68
|
<Tabs id="my-tabs" screens={screens} />,
|
|
69
69
|
);
|
|
70
70
|
|
|
71
71
|
act(() => {
|
|
72
|
-
navigation.
|
|
72
|
+
navigation.tab(2);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
await waitFor(() => {
|
|
@@ -88,17 +88,39 @@ describe("Tabs", () => {
|
|
|
88
88
|
expect(renderer.getByText("tab-0:tab-screen:false")).toBeTruthy();
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
it("ref.
|
|
91
|
+
it("ref.setActive changes the active tab", async () => {
|
|
92
92
|
const screens = makeScreens(3);
|
|
93
93
|
const ref = React.createRef<TabsHandle>();
|
|
94
|
-
const { renderer } = renderWithProviders(
|
|
94
|
+
const { renderer, store } = renderWithProviders(
|
|
95
95
|
<Tabs ref={ref} id="my-tabs" screens={screens} />,
|
|
96
96
|
);
|
|
97
97
|
|
|
98
98
|
expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
|
|
99
99
|
|
|
100
100
|
act(() => {
|
|
101
|
-
ref.current!.
|
|
101
|
+
ref.current!.setActive(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
|
+
expect(store.getState().tabs.get("my-tabs")).toEqual({ activeIndex: 2 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ref.setActive targets the correct tabs when id is not provided", async () => {
|
|
114
|
+
const screens = makeScreens(3);
|
|
115
|
+
const ref = React.createRef<TabsHandle>();
|
|
116
|
+
const { renderer, store } = renderWithProviders(
|
|
117
|
+
<Tabs ref={ref} screens={screens} />,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(renderer.getByText("tab-0:tab-screen:true")).toBeTruthy();
|
|
121
|
+
|
|
122
|
+
act(() => {
|
|
123
|
+
ref.current!.setActive(2);
|
|
102
124
|
});
|
|
103
125
|
|
|
104
126
|
await waitFor(() => {
|
|
@@ -106,6 +128,55 @@ describe("Tabs", () => {
|
|
|
106
128
|
expect(renderer.getByText("tab-1:tab-screen:false")).toBeTruthy();
|
|
107
129
|
expect(renderer.getByText("tab-2:tab-screen:true")).toBeTruthy();
|
|
108
130
|
});
|
|
131
|
+
|
|
132
|
+
// The tab state should be set on the auto-generated tabs id
|
|
133
|
+
const state = store.getState();
|
|
134
|
+
expect(state.tabs.size).toBe(1);
|
|
135
|
+
const [, tabState] = [...state.tabs.entries()][0];
|
|
136
|
+
expect(tabState).toEqual({ activeIndex: 2 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ref.setActive targets the inner tabs when nested inside a stack without an explicit id", async () => {
|
|
140
|
+
const ref = React.createRef<TabsHandle>();
|
|
141
|
+
const navigation = createNavigation();
|
|
142
|
+
|
|
143
|
+
const tabScreens: TabScreenOptions[] = [
|
|
144
|
+
{
|
|
145
|
+
id: "tab-a",
|
|
146
|
+
screen: <span>tab-a-content</span>,
|
|
147
|
+
tab: () => <span>tab-a</span>,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "tab-b",
|
|
151
|
+
screen: <span>tab-b-content</span>,
|
|
152
|
+
tab: () => <span>tab-b</span>,
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const result = render(
|
|
157
|
+
<Navigation navigation={navigation}>
|
|
158
|
+
<Stack
|
|
159
|
+
id="outer-stack"
|
|
160
|
+
rootScreen={<Tabs ref={ref} screens={tabScreens} />}
|
|
161
|
+
/>
|
|
162
|
+
</Navigation>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await waitFor(() => {
|
|
166
|
+
expect(result.getByText("tab-a-content")).toBeTruthy();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
act(() => {
|
|
170
|
+
ref.current!.setActive(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// The tab switch should target the auto-generated tabs id, not "outer-stack"
|
|
174
|
+
const state = navigation.store.getState();
|
|
175
|
+
expect(state.tabs.has("outer-stack")).toBe(false);
|
|
176
|
+
expect(state.tabs.size).toBe(1);
|
|
177
|
+
|
|
178
|
+
const [, tabState] = [...state.tabs.entries()][0];
|
|
179
|
+
expect(tabState.activeIndex).toBe(1);
|
|
109
180
|
});
|
|
110
181
|
|
|
111
182
|
it("supports preloaded activeIndex from the navigation state", () => {
|
|
@@ -179,7 +250,7 @@ describe("TabBar", () => {
|
|
|
179
250
|
});
|
|
180
251
|
|
|
181
252
|
describe("Nested Stack + Tabs", () => {
|
|
182
|
-
it("
|
|
253
|
+
it("push targets the stack inside the active tab", async () => {
|
|
183
254
|
const navigation = createNavigation();
|
|
184
255
|
|
|
185
256
|
const screens: TabScreenOptions[] = [
|
|
@@ -205,9 +276,9 @@ describe("Nested Stack + Tabs", () => {
|
|
|
205
276
|
expect(result.getByText("stack-a-root")).toBeTruthy();
|
|
206
277
|
});
|
|
207
278
|
|
|
208
|
-
// Tab 0 (stack-a) is active —
|
|
279
|
+
// Tab 0 (stack-a) is active — push should target stack-a
|
|
209
280
|
act(() => {
|
|
210
|
-
navigation.
|
|
281
|
+
navigation.push(<span>pushed-to-a</span>);
|
|
211
282
|
});
|
|
212
283
|
|
|
213
284
|
const stateAfterFirst = navigation.store.getState();
|
|
@@ -215,7 +286,7 @@ describe("Nested Stack + Tabs", () => {
|
|
|
215
286
|
expect(stateAfterFirst.stacks.has("stack-b")).toBe(false);
|
|
216
287
|
});
|
|
217
288
|
|
|
218
|
-
it("switching tabs redirects
|
|
289
|
+
it("switching tabs redirects push to the newly active stack", async () => {
|
|
219
290
|
const navigation = createNavigation();
|
|
220
291
|
|
|
221
292
|
const screens: TabScreenOptions[] = [
|
|
@@ -239,11 +310,11 @@ describe("Nested Stack + Tabs", () => {
|
|
|
239
310
|
|
|
240
311
|
// Switch to tab 1 (stack-b)
|
|
241
312
|
act(() => {
|
|
242
|
-
navigation.
|
|
313
|
+
navigation.tab(1);
|
|
243
314
|
});
|
|
244
315
|
|
|
245
316
|
act(() => {
|
|
246
|
-
navigation.
|
|
317
|
+
navigation.push(<span>pushed-to-b</span>);
|
|
247
318
|
});
|
|
248
319
|
|
|
249
320
|
const state = navigation.store.getState();
|
|
@@ -251,7 +322,7 @@ describe("Nested Stack + Tabs", () => {
|
|
|
251
322
|
expect(state.stacks.has("stack-a")).toBe(false);
|
|
252
323
|
});
|
|
253
324
|
|
|
254
|
-
it("
|
|
325
|
+
it("tab resolves the correct tabs when a stack wraps tabs", async () => {
|
|
255
326
|
const navigation = createNavigation();
|
|
256
327
|
|
|
257
328
|
const tabScreens: TabScreenOptions[] = [
|
|
@@ -277,9 +348,9 @@ describe("Nested Stack + Tabs", () => {
|
|
|
277
348
|
expect(result.getByText("tab-a-content")).toBeTruthy();
|
|
278
349
|
});
|
|
279
350
|
|
|
280
|
-
//
|
|
351
|
+
// tab with no explicit tabs should resolve inner-tabs
|
|
281
352
|
act(() => {
|
|
282
|
-
navigation.
|
|
353
|
+
navigation.tab(1);
|
|
283
354
|
});
|
|
284
355
|
|
|
285
356
|
const state = navigation.store.getState();
|
package/src/tabs.tsx
CHANGED
|
@@ -3,8 +3,13 @@ import {
|
|
|
3
3
|
RenderTreeNode,
|
|
4
4
|
useRenderNode,
|
|
5
5
|
useSafeAreaInsets,
|
|
6
|
+
nextRenderTreeIdForType,
|
|
6
7
|
} from "@rn-tools/core";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
useTabActiveIndex,
|
|
10
|
+
useNavigationStore,
|
|
11
|
+
useNavigation,
|
|
12
|
+
} from "./navigation-client";
|
|
8
13
|
|
|
9
14
|
import * as RNScreens from "react-native-screens";
|
|
10
15
|
import { StyleSheet, View, type ViewStyle } from "react-native";
|
|
@@ -20,7 +25,7 @@ export type TabScreenOptions = {
|
|
|
20
25
|
};
|
|
21
26
|
|
|
22
27
|
export type TabsHandle = {
|
|
23
|
-
|
|
28
|
+
setActive: (index: number) => void;
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
export type TabsProps = {
|
|
@@ -32,13 +37,30 @@ export type TabsProps = {
|
|
|
32
37
|
children?: React.ReactNode;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
|
-
const TabsRoot = React.memo(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const TabsRoot = React.memo(
|
|
41
|
+
React.forwardRef<TabsHandle, TabsProps>(function TabsRoot(props, ref) {
|
|
42
|
+
const tabsId = React.useRef(
|
|
43
|
+
props.id ?? nextRenderTreeIdForType("tabs"),
|
|
44
|
+
).current;
|
|
45
|
+
const navigation = useNavigation();
|
|
46
|
+
|
|
47
|
+
React.useImperativeHandle(
|
|
48
|
+
ref,
|
|
49
|
+
() => ({
|
|
50
|
+
setActive(index: number) {
|
|
51
|
+
navigation.tab(index, { tabs: tabsId });
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
[tabsId, navigation],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<RenderTreeNode type="tabs" id={tabsId} active={props.active}>
|
|
59
|
+
{props.children}
|
|
60
|
+
</RenderTreeNode>
|
|
61
|
+
);
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
42
64
|
|
|
43
65
|
const TabBar = React.memo(function TabBar(props: {
|
|
44
66
|
screens: TabScreenOptions[];
|
|
@@ -121,17 +143,6 @@ export const Tabs = React.memo(
|
|
|
121
143
|
React.forwardRef<TabsHandle, Omit<TabsProps, "children">>(
|
|
122
144
|
function Tabs(props, ref) {
|
|
123
145
|
const position = props.tabbarPosition ?? "bottom";
|
|
124
|
-
const navigation = useNavigation();
|
|
125
|
-
const node = useRenderNode();
|
|
126
|
-
const tabsId = props.id ?? node?.id ?? null;
|
|
127
|
-
|
|
128
|
-
React.useImperativeHandle(ref, () => ({
|
|
129
|
-
setActiveIndex(index: number) {
|
|
130
|
-
if (tabsId) {
|
|
131
|
-
navigation.setActiveTab(index, { tabsId });
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
}), [tabsId, navigation]);
|
|
135
146
|
|
|
136
147
|
const tabbar = React.useMemo(
|
|
137
148
|
() => (
|
|
@@ -145,7 +156,7 @@ export const Tabs = React.memo(
|
|
|
145
156
|
);
|
|
146
157
|
|
|
147
158
|
return (
|
|
148
|
-
<TabsRoot {...props}>
|
|
159
|
+
<TabsRoot ref={ref} {...props}>
|
|
149
160
|
{position === "top" && tabbar}
|
|
150
161
|
<View style={styles.slotContainer}>
|
|
151
162
|
<TabsSlot screens={props.screens} />
|