@natsuneko-laboratory/react-native-desktop-navigation 0.1.0-alpha.12
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/DesktopNavigation.podspec +18 -0
- package/LICENSE +21 -0
- package/NATIVE.md +300 -0
- package/README.md +127 -0
- package/dist/core/NavigationContainer.d.ts +23 -0
- package/dist/core/NavigationContainer.js +68 -0
- package/dist/core/NavigationItem.d.ts +27 -0
- package/dist/core/NavigationItem.js +71 -0
- package/dist/core/Scene.d.ts +19 -0
- package/dist/core/Scene.js +124 -0
- package/dist/core/SelectionNavigator.d.ts +38 -0
- package/dist/core/SelectionNavigator.js +194 -0
- package/dist/core/builder.d.ts +40 -0
- package/dist/core/builder.js +81 -0
- package/dist/core/context.d.ts +26 -0
- package/dist/core/context.js +36 -0
- package/dist/core/focus.d.ts +14 -0
- package/dist/core/focus.js +39 -0
- package/dist/core/hooks.d.ts +8 -0
- package/dist/core/hooks.js +48 -0
- package/dist/core/navigation.d.ts +6 -0
- package/dist/core/navigation.js +57 -0
- package/dist/core/store.d.ts +39 -0
- package/dist/core/store.js +391 -0
- package/dist/core/types.d.ts +155 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +46 -0
- package/dist/native/host.d.ts +100 -0
- package/dist/native/host.js +190 -0
- package/dist/native/icons.d.ts +56 -0
- package/dist/native/icons.js +55 -0
- package/dist/native/index.d.ts +13 -0
- package/dist/native/index.js +44 -0
- package/dist/native/sidebar.d.ts +39 -0
- package/dist/native/sidebar.js +126 -0
- package/dist/native/specs/DesktopNavigationHostNativeComponent.d.ts +10 -0
- package/dist/native/specs/DesktopNavigationHostNativeComponent.js +4 -0
- package/dist/native/split.d.ts +27 -0
- package/dist/native/split.js +105 -0
- package/dist/native/stack.d.ts +26 -0
- package/dist/native/stack.js +66 -0
- package/dist/platform/index.d.ts +52 -0
- package/dist/platform/index.js +28 -0
- package/dist/platform/macos.d.ts +2 -0
- package/dist/platform/macos.js +18 -0
- package/dist/platform/windows.d.ts +2 -0
- package/dist/platform/windows.js +18 -0
- package/dist/routers/index.d.ts +23 -0
- package/dist/routers/index.js +393 -0
- package/dist/routers/types.d.ts +116 -0
- package/dist/routers/types.js +2 -0
- package/dist/sidebar/index.d.ts +17 -0
- package/dist/sidebar/index.js +18 -0
- package/dist/split/index.d.ts +42 -0
- package/dist/split/index.js +199 -0
- package/dist/stack/index.d.ts +23 -0
- package/dist/stack/index.js +88 -0
- package/dist/tabs/index.d.ts +13 -0
- package/dist/tabs/index.js +15 -0
- package/docs/GUIDE.md +384 -0
- package/macos/DDNNavigationComponentView.h +4 -0
- package/macos/DDNNavigationComponentView.mm +40 -0
- package/macos/DDNNavigationView.swift +398 -0
- package/macos/DDNNavigationViewManager.mm +11 -0
- package/package.json +82 -0
- package/react-native.config.js +19 -0
- package/src/core/NavigationContainer.tsx +137 -0
- package/src/core/NavigationItem.tsx +149 -0
- package/src/core/Scene.tsx +194 -0
- package/src/core/SelectionNavigator.tsx +371 -0
- package/src/core/builder.tsx +147 -0
- package/src/core/context.tsx +43 -0
- package/src/core/focus.tsx +50 -0
- package/src/core/hooks.ts +42 -0
- package/src/core/navigation.ts +77 -0
- package/src/core/store.ts +471 -0
- package/src/core/types.ts +176 -0
- package/src/index.ts +36 -0
- package/src/native/host.tsx +377 -0
- package/src/native/icons.ts +110 -0
- package/src/native/index.ts +56 -0
- package/src/native/sidebar.tsx +230 -0
- package/src/native/specs/DesktopNavigationHostNativeComponent.ts +12 -0
- package/src/native/split.tsx +160 -0
- package/src/native/stack.tsx +157 -0
- package/src/platform/index.tsx +76 -0
- package/src/platform/macos.ts +15 -0
- package/src/platform/windows.ts +15 -0
- package/src/routers/index.ts +442 -0
- package/src/routers/types.ts +117 -0
- package/src/sidebar/index.tsx +42 -0
- package/src/split/index.tsx +350 -0
- package/src/stack/index.tsx +213 -0
- package/src/tabs/index.tsx +40 -0
- package/windows/DesktopNavigation/DesktopNavigation.def +3 -0
- package/windows/DesktopNavigation/DesktopNavigation.vcxproj +125 -0
- package/windows/DesktopNavigation/NavigationHost.cpp +432 -0
- package/windows/DesktopNavigation/ReactPackageProvider.cpp +9 -0
- package/windows/DesktopNavigation/ReactPackageProvider.h +10 -0
- package/windows/DesktopNavigation/ReactPackageProvider.idl +6 -0
- package/windows/DesktopNavigation/pch.cpp +1 -0
- package/windows/DesktopNavigation/pch.h +27 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import {
|
|
2
|
+
reduceNavigation,
|
|
3
|
+
createRouteKey,
|
|
4
|
+
type NavigationAction,
|
|
5
|
+
type NavigationNode,
|
|
6
|
+
type NavigationState,
|
|
7
|
+
type RootNavigationState,
|
|
8
|
+
type RouterOptions,
|
|
9
|
+
} from '../routers';
|
|
10
|
+
export type NavigationEventType = 'focus' | 'blur' | 'beforeRemove' | 'state';
|
|
11
|
+
export interface NavigationEvent {
|
|
12
|
+
type: NavigationEventType;
|
|
13
|
+
target: string;
|
|
14
|
+
action?: NavigationAction;
|
|
15
|
+
defaultPrevented: boolean;
|
|
16
|
+
preventDefault(): void;
|
|
17
|
+
}
|
|
18
|
+
type Listener = (event: NavigationEvent) => void;
|
|
19
|
+
function assertSerializable(value: unknown, seen = new Set<object>()): void {
|
|
20
|
+
if (
|
|
21
|
+
value === undefined ||
|
|
22
|
+
value === null ||
|
|
23
|
+
typeof value === 'string' ||
|
|
24
|
+
typeof value === 'boolean'
|
|
25
|
+
)
|
|
26
|
+
return;
|
|
27
|
+
if (typeof value === 'number' && Number.isFinite(value)) return;
|
|
28
|
+
if (typeof value !== 'object')
|
|
29
|
+
throw new Error('Navigation params must be JSON serializable.');
|
|
30
|
+
if (seen.has(value))
|
|
31
|
+
throw new Error('Navigation state contains a circular reference.');
|
|
32
|
+
if (
|
|
33
|
+
!Array.isArray(value) &&
|
|
34
|
+
Object.getPrototypeOf(value) !== Object.prototype
|
|
35
|
+
)
|
|
36
|
+
throw new Error(
|
|
37
|
+
'Navigation params must contain only plain objects and arrays.',
|
|
38
|
+
);
|
|
39
|
+
seen.add(value);
|
|
40
|
+
for (const child of Object.values(value)) assertSerializable(child, seen);
|
|
41
|
+
seen.delete(value);
|
|
42
|
+
}
|
|
43
|
+
export function serializeNavigationState(state: RootNavigationState): string {
|
|
44
|
+
assertSerializable(state);
|
|
45
|
+
return JSON.stringify(state);
|
|
46
|
+
}
|
|
47
|
+
export function restoreNavigationState(
|
|
48
|
+
input: string | RootNavigationState,
|
|
49
|
+
): RootNavigationState {
|
|
50
|
+
const state: RootNavigationState =
|
|
51
|
+
typeof input === 'string'
|
|
52
|
+
? JSON.parse(input)
|
|
53
|
+
: JSON.parse(serializeNavigationState(input));
|
|
54
|
+
if (
|
|
55
|
+
!state ||
|
|
56
|
+
state.version !== 1 ||
|
|
57
|
+
typeof state.rootNodeId !== 'string' ||
|
|
58
|
+
!state.nodes ||
|
|
59
|
+
!state.nodes[state.rootNodeId]
|
|
60
|
+
)
|
|
61
|
+
throw new Error('Invalid navigation snapshot (expected version 1).');
|
|
62
|
+
for (const [id, node] of Object.entries(state.nodes)) {
|
|
63
|
+
const s = node.state;
|
|
64
|
+
if (
|
|
65
|
+
id !== node.id ||
|
|
66
|
+
s?.key !== id ||
|
|
67
|
+
node.type !== s.type ||
|
|
68
|
+
!['stack', 'sidebar', 'tabs', 'split'].includes(s.type) ||
|
|
69
|
+
!Array.isArray(s.routes) ||
|
|
70
|
+
!s.routes.length
|
|
71
|
+
)
|
|
72
|
+
throw new Error(`Invalid navigation node: ${id}`);
|
|
73
|
+
const keys = s.routes.map((r) => r.key);
|
|
74
|
+
if (
|
|
75
|
+
new Set(keys).size !== keys.length ||
|
|
76
|
+
!keys.includes(s.activeRouteKey) ||
|
|
77
|
+
s.routes.some(
|
|
78
|
+
(r) => typeof r.key !== 'string' || typeof r.name !== 'string',
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
throw new Error(`Invalid routes: ${id}`);
|
|
82
|
+
if (
|
|
83
|
+
s.type === 'stack' &&
|
|
84
|
+
(!Number.isInteger(s.nextRouteId) || s.nextRouteId < 1)
|
|
85
|
+
)
|
|
86
|
+
throw new Error('Invalid stack route counter.');
|
|
87
|
+
if (
|
|
88
|
+
(s.type === 'sidebar' || s.type === 'tabs') &&
|
|
89
|
+
typeof s.collapsed !== 'boolean'
|
|
90
|
+
)
|
|
91
|
+
throw new Error('Invalid selection state.');
|
|
92
|
+
if (
|
|
93
|
+
s.type === 'stack' &&
|
|
94
|
+
s.activeRouteKey !== s.routes[s.routes.length - 1].key
|
|
95
|
+
)
|
|
96
|
+
throw new Error('Stack active route must be the last route.');
|
|
97
|
+
if (s.type === 'stack' && s.history) {
|
|
98
|
+
for (const routes of [...s.history.past, ...s.history.future]) {
|
|
99
|
+
if (
|
|
100
|
+
!Array.isArray(routes) ||
|
|
101
|
+
!routes.length ||
|
|
102
|
+
routes.some(
|
|
103
|
+
(r) => typeof r.key !== 'string' || typeof r.name !== 'string',
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
throw new Error('Invalid stack history.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
s.type === 'split' &&
|
|
111
|
+
(!s.widths ||
|
|
112
|
+
!Array.isArray(s.visibleColumnIds) ||
|
|
113
|
+
!s.visibleColumnIds.length ||
|
|
114
|
+
new Set(s.visibleColumnIds).size !== s.visibleColumnIds.length ||
|
|
115
|
+
s.visibleColumnIds.some(
|
|
116
|
+
(name) => !s.routes.some((r) => r.name === name),
|
|
117
|
+
) ||
|
|
118
|
+
s.routes.some(
|
|
119
|
+
(r) => !Number.isFinite(s.widths[r.name]) || s.widths[r.name] < 0,
|
|
120
|
+
))
|
|
121
|
+
)
|
|
122
|
+
throw new Error('Invalid split state.');
|
|
123
|
+
if (s.type === 'split' && s.collapsedColumns !== undefined) {
|
|
124
|
+
if (
|
|
125
|
+
!s.collapsedColumns ||
|
|
126
|
+
typeof s.collapsedColumns !== 'object' ||
|
|
127
|
+
Array.isArray(s.collapsedColumns) ||
|
|
128
|
+
Object.entries(s.collapsedColumns).some(
|
|
129
|
+
([name, column]) =>
|
|
130
|
+
!s.routes.some((r) => r.name === name) ||
|
|
131
|
+
!column ||
|
|
132
|
+
!Number.isFinite(column.width) ||
|
|
133
|
+
column.width < 0 ||
|
|
134
|
+
!Number.isFinite(column.expandedWidth) ||
|
|
135
|
+
column.expandedWidth < 0 ||
|
|
136
|
+
s.widths[name] !== column.width,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
throw new Error('Invalid collapsed split state.');
|
|
140
|
+
}
|
|
141
|
+
const visited = new Set([id]);
|
|
142
|
+
let current = node;
|
|
143
|
+
while (current.parentId) {
|
|
144
|
+
const parent = state.nodes[current.parentId];
|
|
145
|
+
if (
|
|
146
|
+
!parent ||
|
|
147
|
+
visited.has(parent.id) ||
|
|
148
|
+
!retainedRoutes(parent.state).some(
|
|
149
|
+
(r) => r.key === current.parentRouteKey,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
throw new Error('Invalid navigation parent relationship.');
|
|
153
|
+
visited.add(parent.id);
|
|
154
|
+
current = parent;
|
|
155
|
+
}
|
|
156
|
+
if (current.id !== state.rootNodeId)
|
|
157
|
+
throw new Error('Navigation snapshot has multiple roots.');
|
|
158
|
+
}
|
|
159
|
+
if (state.activeNodeId && !state.nodes[state.activeNodeId])
|
|
160
|
+
throw new Error('Invalid active navigator.');
|
|
161
|
+
return state;
|
|
162
|
+
}
|
|
163
|
+
function retainedRoutes(state: NavigationState) {
|
|
164
|
+
return state.type === 'stack' && state.history
|
|
165
|
+
? [
|
|
166
|
+
...state.routes,
|
|
167
|
+
...state.history.past.flat(),
|
|
168
|
+
...state.history.future.flat(),
|
|
169
|
+
]
|
|
170
|
+
: state.routes;
|
|
171
|
+
}
|
|
172
|
+
export class NavigationStore {
|
|
173
|
+
private snapshot: RootNavigationState;
|
|
174
|
+
private subscribers = new Set<() => void>();
|
|
175
|
+
private events = new Map<string, Map<NavigationEventType, Set<Listener>>>();
|
|
176
|
+
private configs = new Map<string, RouterOptions>();
|
|
177
|
+
private mounted = new Set<string>();
|
|
178
|
+
constructor(initialState?: RootNavigationState) {
|
|
179
|
+
this.snapshot = initialState
|
|
180
|
+
? restoreNavigationState(initialState)
|
|
181
|
+
: { version: 1, rootNodeId: 'root', nodes: {} };
|
|
182
|
+
}
|
|
183
|
+
getState = () => this.snapshot;
|
|
184
|
+
getNode = (id: string): NavigationNode | undefined => this.snapshot.nodes[id];
|
|
185
|
+
subscribe = (listener: () => void) => {
|
|
186
|
+
this.subscribers.add(listener);
|
|
187
|
+
return () => {
|
|
188
|
+
this.subscribers.delete(listener);
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
isReady = () => this.mounted.has(this.snapshot.rootNodeId);
|
|
192
|
+
register(node: NavigationNode, options: RouterOptions) {
|
|
193
|
+
assertSerializable(node.state);
|
|
194
|
+
if (this.mounted.has(node.id))
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Duplicate navigator id: ${node.id}. Supply a unique id for sibling navigators.`,
|
|
197
|
+
);
|
|
198
|
+
const saved = this.snapshot.nodes[node.id];
|
|
199
|
+
if (
|
|
200
|
+
saved &&
|
|
201
|
+
(saved.type !== node.type ||
|
|
202
|
+
retainedRoutes(saved.state).some(
|
|
203
|
+
(r) => !options.routes.some((c) => c.name === r.name),
|
|
204
|
+
))
|
|
205
|
+
)
|
|
206
|
+
throw new Error(`Saved state does not match navigator ${node.id}.`);
|
|
207
|
+
const previousFocus = this.focusedRouteKeys();
|
|
208
|
+
this.configs.set(node.id, options);
|
|
209
|
+
this.mounted.add(node.id);
|
|
210
|
+
this.commit(
|
|
211
|
+
{
|
|
212
|
+
...this.snapshot,
|
|
213
|
+
rootNodeId: node.parentId ? this.snapshot.rootNodeId : node.id,
|
|
214
|
+
nodes: { ...this.snapshot.nodes, [node.id]: saved ?? node },
|
|
215
|
+
},
|
|
216
|
+
undefined,
|
|
217
|
+
previousFocus,
|
|
218
|
+
);
|
|
219
|
+
return () => {
|
|
220
|
+
const oldFocus = this.focusedRouteKeys();
|
|
221
|
+
this.mounted.delete(node.id);
|
|
222
|
+
this.configs.delete(node.id);
|
|
223
|
+
this.publishFocus(oldFocus);
|
|
224
|
+
for (const listener of this.subscribers) listener();
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
updateOptions(id: string, options: RouterOptions) {
|
|
228
|
+
this.configs.set(id, options);
|
|
229
|
+
}
|
|
230
|
+
addListener(routeKey: string, type: NavigationEventType, listener: Listener) {
|
|
231
|
+
let types = this.events.get(routeKey);
|
|
232
|
+
if (!types) this.events.set(routeKey, (types = new Map()));
|
|
233
|
+
let listeners = types.get(type);
|
|
234
|
+
if (!listeners) types.set(type, (listeners = new Set()));
|
|
235
|
+
listeners.add(listener);
|
|
236
|
+
return () => {
|
|
237
|
+
listeners!.delete(listener);
|
|
238
|
+
if (!listeners!.size) types!.delete(type);
|
|
239
|
+
if (!types!.size) this.events.delete(routeKey);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
private emit(
|
|
243
|
+
target: string,
|
|
244
|
+
type: NavigationEventType,
|
|
245
|
+
action?: NavigationAction,
|
|
246
|
+
) {
|
|
247
|
+
const event: NavigationEvent = {
|
|
248
|
+
type,
|
|
249
|
+
target,
|
|
250
|
+
action,
|
|
251
|
+
defaultPrevented: false,
|
|
252
|
+
preventDefault() {
|
|
253
|
+
if (type === 'beforeRemove') this.defaultPrevented = true;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
for (const listener of this.events.get(target)?.get(type) ?? [])
|
|
257
|
+
listener(event);
|
|
258
|
+
return event.defaultPrevented;
|
|
259
|
+
}
|
|
260
|
+
isNodeFocused(id: string): boolean {
|
|
261
|
+
const node = this.snapshot.nodes[id];
|
|
262
|
+
if (!node || !this.mounted.has(id)) return false;
|
|
263
|
+
if (!node.parentId) return true;
|
|
264
|
+
const parent = this.snapshot.nodes[node.parentId];
|
|
265
|
+
return (
|
|
266
|
+
!!parent &&
|
|
267
|
+
parent.state.activeRouteKey === node.parentRouteKey &&
|
|
268
|
+
this.isNodeFocused(parent.id)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
isRouteFocused(id: string, routeKey: string) {
|
|
272
|
+
return (
|
|
273
|
+
this.getNode(id)?.state.activeRouteKey === routeKey &&
|
|
274
|
+
this.isNodeFocused(id)
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
private focusedRouteKeys() {
|
|
278
|
+
const depth = (node: NavigationNode): number =>
|
|
279
|
+
node.parentId && this.getNode(node.parentId)
|
|
280
|
+
? 1 + depth(this.getNode(node.parentId)!)
|
|
281
|
+
: 0;
|
|
282
|
+
return Object.values(this.snapshot.nodes)
|
|
283
|
+
.filter((n) => this.isNodeFocused(n.id))
|
|
284
|
+
.sort((a, b) => depth(a) - depth(b))
|
|
285
|
+
.map((n) => n.state.activeRouteKey);
|
|
286
|
+
}
|
|
287
|
+
private publishFocus(previous: string[]) {
|
|
288
|
+
const next = this.focusedRouteKeys();
|
|
289
|
+
for (const key of [...previous].reverse())
|
|
290
|
+
if (!next.includes(key)) this.emit(key, 'blur');
|
|
291
|
+
for (const key of next)
|
|
292
|
+
if (!previous.includes(key)) this.emit(key, 'focus');
|
|
293
|
+
}
|
|
294
|
+
private commit(
|
|
295
|
+
next: RootNavigationState,
|
|
296
|
+
action?: NavigationAction,
|
|
297
|
+
previous = this.focusedRouteKeys(),
|
|
298
|
+
) {
|
|
299
|
+
this.snapshot = next;
|
|
300
|
+
this.publishFocus(previous);
|
|
301
|
+
for (const listener of this.subscribers) listener();
|
|
302
|
+
for (const node of Object.values(next.nodes))
|
|
303
|
+
for (const route of node.state.routes)
|
|
304
|
+
this.emit(route.key, 'state', action);
|
|
305
|
+
}
|
|
306
|
+
focusRoute(id: string, routeKey: string) {
|
|
307
|
+
const node = this.getNode(id);
|
|
308
|
+
if (
|
|
309
|
+
node?.state.type === 'split' &&
|
|
310
|
+
node.state.activeRouteKey !== routeKey
|
|
311
|
+
) {
|
|
312
|
+
const route = node.state.routes.find((r) => r.key === routeKey);
|
|
313
|
+
if (!route || !node.state.visibleColumnIds.includes(route.name)) return;
|
|
314
|
+
this.commit({
|
|
315
|
+
...this.snapshot,
|
|
316
|
+
nodes: {
|
|
317
|
+
...this.snapshot.nodes,
|
|
318
|
+
[id]: { ...node, state: { ...node.state, activeRouteKey: routeKey } },
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
// Native focus bubbles: an ancestor Scene must not replace the focused leaf.
|
|
323
|
+
let active = this.getNode(this.getActiveNodeId());
|
|
324
|
+
while (active) {
|
|
325
|
+
if (active.id === id && this.isNodeFocused(this.getActiveNodeId()))
|
|
326
|
+
return;
|
|
327
|
+
active = active.parentId ? this.getNode(active.parentId) : undefined;
|
|
328
|
+
}
|
|
329
|
+
this.setFocusedNode(id);
|
|
330
|
+
}
|
|
331
|
+
setFocusedNode(id: string) {
|
|
332
|
+
if (!this.mounted.has(id)) return;
|
|
333
|
+
let node = this.getNode(id);
|
|
334
|
+
const nodes = { ...this.snapshot.nodes };
|
|
335
|
+
while (node?.parentId) {
|
|
336
|
+
const parent = nodes[node.parentId];
|
|
337
|
+
if (!parent) return;
|
|
338
|
+
if (parent.state.type === 'split') {
|
|
339
|
+
const route = parent.state.routes.find(
|
|
340
|
+
(r) => r.key === node!.parentRouteKey,
|
|
341
|
+
);
|
|
342
|
+
if (!route || !parent.state.visibleColumnIds.includes(route.name))
|
|
343
|
+
return;
|
|
344
|
+
nodes[parent.id] = {
|
|
345
|
+
...parent,
|
|
346
|
+
state: { ...parent.state, activeRouteKey: route.key },
|
|
347
|
+
};
|
|
348
|
+
} else if (parent.state.activeRouteKey !== node.parentRouteKey) return;
|
|
349
|
+
node = parent;
|
|
350
|
+
}
|
|
351
|
+
if (
|
|
352
|
+
this.snapshot.activeNodeId === id &&
|
|
353
|
+
Object.keys(nodes).every((key) => nodes[key] === this.snapshot.nodes[key])
|
|
354
|
+
)
|
|
355
|
+
return;
|
|
356
|
+
this.commit({ ...this.snapshot, nodes, activeNodeId: id });
|
|
357
|
+
}
|
|
358
|
+
getActiveNodeId() {
|
|
359
|
+
const preferred = this.snapshot.activeNodeId;
|
|
360
|
+
let id =
|
|
361
|
+
preferred && this.isNodeFocused(preferred)
|
|
362
|
+
? preferred
|
|
363
|
+
: this.snapshot.rootNodeId;
|
|
364
|
+
while (true) {
|
|
365
|
+
const child = Object.values(this.snapshot.nodes).find(
|
|
366
|
+
(n) => n.parentId === id && this.isNodeFocused(n.id),
|
|
367
|
+
);
|
|
368
|
+
if (!child) return id;
|
|
369
|
+
id = child.id;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
canDispatch(
|
|
373
|
+
type: 'back' | 'forward',
|
|
374
|
+
target = this.getActiveNodeId(),
|
|
375
|
+
): boolean {
|
|
376
|
+
let node = this.getNode(target);
|
|
377
|
+
while (node) {
|
|
378
|
+
const s = node.state;
|
|
379
|
+
if (
|
|
380
|
+
s.type === 'stack' &&
|
|
381
|
+
(type === 'back'
|
|
382
|
+
? s.history
|
|
383
|
+
? s.history.past.length > 0
|
|
384
|
+
: s.routes.length > 1
|
|
385
|
+
: !!s.history?.future.length)
|
|
386
|
+
)
|
|
387
|
+
return true;
|
|
388
|
+
node = node.parentId ? this.getNode(node.parentId) : undefined;
|
|
389
|
+
}
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
dispatch = (original: NavigationAction): boolean => {
|
|
393
|
+
let action = original;
|
|
394
|
+
if ('payload' in action) assertSerializable(action.payload);
|
|
395
|
+
if (
|
|
396
|
+
action.type === 'navigate' ||
|
|
397
|
+
action.type === 'push' ||
|
|
398
|
+
action.type === 'replace'
|
|
399
|
+
)
|
|
400
|
+
action = {
|
|
401
|
+
...action,
|
|
402
|
+
payload: {
|
|
403
|
+
...action.payload,
|
|
404
|
+
key: action.payload.key ?? createRouteKey(action.payload.name),
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
let node = this.getNode(action.target ?? this.getActiveNodeId());
|
|
408
|
+
while (node) {
|
|
409
|
+
const options = this.configs.get(node.id);
|
|
410
|
+
const next = options
|
|
411
|
+
? reduceNavigation(node.state, action, options)
|
|
412
|
+
: null;
|
|
413
|
+
if (next) {
|
|
414
|
+
if (next === node.state) return true;
|
|
415
|
+
const removed = node.state.routes
|
|
416
|
+
.filter((r) => !next.routes.some((n) => n.key === r.key))
|
|
417
|
+
.map((r) => r.key);
|
|
418
|
+
const removedNodes = new Set<string>();
|
|
419
|
+
let changed = true;
|
|
420
|
+
while (changed) {
|
|
421
|
+
changed = false;
|
|
422
|
+
for (const child of Object.values(this.snapshot.nodes))
|
|
423
|
+
if (
|
|
424
|
+
!removedNodes.has(child.id) &&
|
|
425
|
+
((child.parentId === node.id &&
|
|
426
|
+
removed.includes(child.parentRouteKey!)) ||
|
|
427
|
+
removedNodes.has(child.parentId!))
|
|
428
|
+
) {
|
|
429
|
+
removedNodes.add(child.id);
|
|
430
|
+
removed.push(...child.state.routes.map((r) => r.key));
|
|
431
|
+
changed = true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
let blocked = false;
|
|
435
|
+
for (const key of [...removed].reverse())
|
|
436
|
+
blocked = this.emit(key, 'beforeRemove', action) || blocked;
|
|
437
|
+
if (blocked) return true;
|
|
438
|
+
const nodes = {
|
|
439
|
+
...this.snapshot.nodes,
|
|
440
|
+
[node.id]: { ...node, state: next },
|
|
441
|
+
};
|
|
442
|
+
// Keep history-owned subtrees so Back/Forward can restore nested navigator state.
|
|
443
|
+
let pruned = true;
|
|
444
|
+
while (pruned) {
|
|
445
|
+
pruned = false;
|
|
446
|
+
for (const child of Object.values(nodes))
|
|
447
|
+
if (
|
|
448
|
+
child.parentId &&
|
|
449
|
+
(!nodes[child.parentId] ||
|
|
450
|
+
!retainedRoutes(nodes[child.parentId].state).some(
|
|
451
|
+
(r) => r.key === child.parentRouteKey,
|
|
452
|
+
))
|
|
453
|
+
) {
|
|
454
|
+
delete nodes[child.id];
|
|
455
|
+
pruned = true;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
this.commit({ ...this.snapshot, nodes, activeNodeId: node.id }, action);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
node = node.parentId ? this.getNode(node.parentId) : undefined;
|
|
462
|
+
}
|
|
463
|
+
if (
|
|
464
|
+
['navigate', 'push', 'replace', 'popTo', 'select'].includes(action.type)
|
|
465
|
+
)
|
|
466
|
+
console.warn(
|
|
467
|
+
`Unhandled navigation action: ${action.type}${'payload' in action && action.payload && 'name' in action.payload ? ` (${action.payload.name})` : ''}`,
|
|
468
|
+
);
|
|
469
|
+
return false;
|
|
470
|
+
};
|
|
471
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
|
|
3
|
+
import type {
|
|
4
|
+
ParamListBase,
|
|
5
|
+
RouteName,
|
|
6
|
+
RouteArgs,
|
|
7
|
+
NavigationRoute,
|
|
8
|
+
NavigationState,
|
|
9
|
+
NavigationAction,
|
|
10
|
+
RootNavigationState,
|
|
11
|
+
} from '../routers';
|
|
12
|
+
import type { NavigationEvent, NavigationEventType } from './store';
|
|
13
|
+
export interface Navigation<P extends ParamListBase = ParamListBase> {
|
|
14
|
+
navigate(...args: RouteArgs<P>): void;
|
|
15
|
+
goBack(): void;
|
|
16
|
+
goForward(): void;
|
|
17
|
+
canGoBack(): boolean;
|
|
18
|
+
canGoForward(): boolean;
|
|
19
|
+
dispatch(action: NavigationAction): void;
|
|
20
|
+
getState(): NavigationState;
|
|
21
|
+
getParent(): Navigation | undefined;
|
|
22
|
+
isFocused(): boolean;
|
|
23
|
+
addListener(
|
|
24
|
+
type: NavigationEventType,
|
|
25
|
+
listener: (event: NavigationEvent) => void,
|
|
26
|
+
): () => void;
|
|
27
|
+
}
|
|
28
|
+
export interface StackNavigation<P extends ParamListBase = ParamListBase>
|
|
29
|
+
extends Navigation<P> {
|
|
30
|
+
push(...args: RouteArgs<P>): void;
|
|
31
|
+
replace(...args: RouteArgs<P>): void;
|
|
32
|
+
pop(count?: number): void;
|
|
33
|
+
popTo(...args: RouteArgs<P>): void;
|
|
34
|
+
popToRoot(): void;
|
|
35
|
+
}
|
|
36
|
+
export interface SidebarNavigation<P extends ParamListBase = ParamListBase>
|
|
37
|
+
extends Navigation<P> {
|
|
38
|
+
select(name: RouteName<P>): void;
|
|
39
|
+
collapseSidebar(): void;
|
|
40
|
+
expandSidebar(): void;
|
|
41
|
+
toggleSidebar(): void;
|
|
42
|
+
}
|
|
43
|
+
export interface TabNavigation<P extends ParamListBase = ParamListBase>
|
|
44
|
+
extends Navigation<P> {
|
|
45
|
+
select(name: RouteName<P>): void;
|
|
46
|
+
nextTab(): void;
|
|
47
|
+
previousTab(): void;
|
|
48
|
+
}
|
|
49
|
+
export type StackScreenProps<
|
|
50
|
+
P extends ParamListBase,
|
|
51
|
+
N extends RouteName<P>,
|
|
52
|
+
> = { navigation: StackNavigation<P>; route: NavigationRoute<N, P[N]> };
|
|
53
|
+
export type SidebarScreenProps<
|
|
54
|
+
P extends ParamListBase,
|
|
55
|
+
N extends RouteName<P>,
|
|
56
|
+
> = { navigation: SidebarNavigation<P>; route: NavigationRoute<N, P[N]> };
|
|
57
|
+
export type TabScreenProps<P extends ParamListBase, N extends RouteName<P>> = {
|
|
58
|
+
navigation: TabNavigation<P>;
|
|
59
|
+
route: NavigationRoute<N, P[N]>;
|
|
60
|
+
};
|
|
61
|
+
export type FocusBehavior = 'none' | 'first' | 'restore';
|
|
62
|
+
export interface CommonScreenOptions {
|
|
63
|
+
title?: string;
|
|
64
|
+
focusBehavior?: FocusBehavior;
|
|
65
|
+
inactiveBehavior?: 'keep' | 'unmount';
|
|
66
|
+
contentStyle?: StyleProp<ViewStyle>;
|
|
67
|
+
sceneStyle?: StyleProp<ViewStyle>;
|
|
68
|
+
}
|
|
69
|
+
export interface HeaderActionProps {
|
|
70
|
+
navigation: StackNavigation;
|
|
71
|
+
route: NavigationRoute;
|
|
72
|
+
canGoBack: boolean;
|
|
73
|
+
}
|
|
74
|
+
export interface HeaderProps extends HeaderActionProps {
|
|
75
|
+
options: StackScreenOptions;
|
|
76
|
+
}
|
|
77
|
+
export type StackAnimation =
|
|
78
|
+
| 'default'
|
|
79
|
+
| 'none'
|
|
80
|
+
| 'fade'
|
|
81
|
+
| 'slide-horizontal'
|
|
82
|
+
| 'slide-vertical';
|
|
83
|
+
export interface StackScreenOptions extends CommonScreenOptions {
|
|
84
|
+
headerShown?: boolean;
|
|
85
|
+
headerStyle?: StyleProp<ViewStyle>;
|
|
86
|
+
headerTitleStyle?: StyleProp<TextStyle>;
|
|
87
|
+
headerLeftContainerStyle?: StyleProp<ViewStyle>;
|
|
88
|
+
headerRightContainerStyle?: StyleProp<ViewStyle>;
|
|
89
|
+
headerBackButtonStyle?: StyleProp<ViewStyle>;
|
|
90
|
+
headerBackTitleStyle?: StyleProp<TextStyle>;
|
|
91
|
+
headerBackTitle?: string;
|
|
92
|
+
headerTintColor?: string;
|
|
93
|
+
overlayStyle?: StyleProp<ViewStyle>;
|
|
94
|
+
dialogStyle?: StyleProp<ViewStyle>;
|
|
95
|
+
header?: React.ReactNode | ((props: HeaderProps) => React.ReactNode);
|
|
96
|
+
headerLeft?:
|
|
97
|
+
| React.ReactNode
|
|
98
|
+
| ((props: HeaderActionProps) => React.ReactNode);
|
|
99
|
+
headerRight?:
|
|
100
|
+
| React.ReactNode
|
|
101
|
+
| ((props: HeaderActionProps) => React.ReactNode);
|
|
102
|
+
animation?: StackAnimation;
|
|
103
|
+
presentation?: 'card' | 'modal' | 'dialog';
|
|
104
|
+
}
|
|
105
|
+
export interface NavigationItemState {
|
|
106
|
+
selected: boolean;
|
|
107
|
+
/** Keyboard/native focus, independent of selection. */
|
|
108
|
+
focused: boolean;
|
|
109
|
+
pressed: boolean;
|
|
110
|
+
hovered: boolean;
|
|
111
|
+
disabled: boolean;
|
|
112
|
+
collapsed: boolean;
|
|
113
|
+
}
|
|
114
|
+
export type NavigationItemStyle<T extends ViewStyle | TextStyle> =
|
|
115
|
+
| StyleProp<T>
|
|
116
|
+
| ((state: NavigationItemState) => StyleProp<T>);
|
|
117
|
+
export interface NavigationItemContentProps extends NavigationItemState {
|
|
118
|
+
route: NavigationRoute;
|
|
119
|
+
label: string;
|
|
120
|
+
/** Default icon, label, and badge, with their configured styles. */
|
|
121
|
+
children: React.ReactNode;
|
|
122
|
+
}
|
|
123
|
+
export interface SidebarFooterProps {
|
|
124
|
+
collapsed: boolean;
|
|
125
|
+
collapseSidebar(): void;
|
|
126
|
+
expandSidebar(): void;
|
|
127
|
+
toggleSidebar(): void;
|
|
128
|
+
/** The complete default collapse button, or null when it is hidden. */
|
|
129
|
+
children: React.ReactNode;
|
|
130
|
+
}
|
|
131
|
+
export interface SidebarSectionOptions {
|
|
132
|
+
title?: string;
|
|
133
|
+
style?: StyleProp<ViewStyle>;
|
|
134
|
+
titleStyle?: StyleProp<TextStyle>;
|
|
135
|
+
renderTitle?: (props: { title: string }) => React.ReactNode;
|
|
136
|
+
}
|
|
137
|
+
export interface SidebarScreenOptions extends CommonScreenOptions {
|
|
138
|
+
itemStyle?: NavigationItemStyle<ViewStyle>;
|
|
139
|
+
labelStyle?: NavigationItemStyle<TextStyle>;
|
|
140
|
+
iconContainerStyle?: NavigationItemStyle<ViewStyle>;
|
|
141
|
+
badgeStyle?: NavigationItemStyle<TextStyle>;
|
|
142
|
+
renderItemContent?: (props: NavigationItemContentProps) => React.ReactNode;
|
|
143
|
+
label?: string;
|
|
144
|
+
icon?:
|
|
145
|
+
| React.ReactNode
|
|
146
|
+
| ((props: { focused: boolean; disabled: boolean }) => React.ReactNode);
|
|
147
|
+
badge?: React.ReactNode;
|
|
148
|
+
hidden?: boolean;
|
|
149
|
+
disabled?: boolean;
|
|
150
|
+
}
|
|
151
|
+
export interface TabScreenOptions extends SidebarScreenOptions {}
|
|
152
|
+
export interface NavigationTheme {
|
|
153
|
+
dark: boolean;
|
|
154
|
+
colors: {
|
|
155
|
+
background: string;
|
|
156
|
+
surface: string;
|
|
157
|
+
text: string;
|
|
158
|
+
mutedText: string;
|
|
159
|
+
border: string;
|
|
160
|
+
accent: string;
|
|
161
|
+
selectedBackground: string;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export interface NavigationRef<P extends ParamListBase> {
|
|
165
|
+
current: NavigationRefHandle<P> | null;
|
|
166
|
+
isReady(): boolean;
|
|
167
|
+
navigate(...args: RouteArgs<P>): void;
|
|
168
|
+
goBack(): void;
|
|
169
|
+
goForward(): void;
|
|
170
|
+
canGoBack(): boolean;
|
|
171
|
+
canGoForward(): boolean;
|
|
172
|
+
dispatch(action: NavigationAction): void;
|
|
173
|
+
getRootState(): RootNavigationState | undefined;
|
|
174
|
+
}
|
|
175
|
+
export interface NavigationRefHandle<P extends ParamListBase>
|
|
176
|
+
extends Omit<NavigationRef<P>, 'current'> {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export {
|
|
2
|
+
NavigationContainer,
|
|
3
|
+
type NavigationContainerProps,
|
|
4
|
+
type KeyboardShortcutOptions,
|
|
5
|
+
} from './core/NavigationContainer';
|
|
6
|
+
export { createNavigationRef } from './core/navigation';
|
|
7
|
+
export {
|
|
8
|
+
useNavigation,
|
|
9
|
+
useRoute,
|
|
10
|
+
useNavigationState,
|
|
11
|
+
useFocusEffect,
|
|
12
|
+
useIsFocused,
|
|
13
|
+
} from './core/hooks';
|
|
14
|
+
export { NavigationFocusable, type FocusTarget } from './core/focus';
|
|
15
|
+
export { DefaultTheme, useNavigationTheme } from './core/context';
|
|
16
|
+
export {
|
|
17
|
+
NavigationStore,
|
|
18
|
+
serializeNavigationState,
|
|
19
|
+
restoreNavigationState,
|
|
20
|
+
type NavigationEvent,
|
|
21
|
+
type NavigationEventType,
|
|
22
|
+
} from './core/store';
|
|
23
|
+
export * from './core/types';
|
|
24
|
+
export * from './routers';
|
|
25
|
+
export * from './stack';
|
|
26
|
+
export * from './sidebar';
|
|
27
|
+
export * from './tabs';
|
|
28
|
+
export * from './split';
|
|
29
|
+
export { macosAdapter } from './platform/macos';
|
|
30
|
+
export { windowsAdapter } from './platform/windows';
|
|
31
|
+
export {
|
|
32
|
+
type NavigationPlatformAdapter,
|
|
33
|
+
type KeyboardShortcut,
|
|
34
|
+
type DesktopKeyEvent,
|
|
35
|
+
type TransitionPreset,
|
|
36
|
+
} from './platform';
|