@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,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.FocusContext = void 0;
|
|
7
|
+
exports.NavigationFocusable = NavigationFocusable;
|
|
8
|
+
const react_1 = __importDefault(require("react"));
|
|
9
|
+
exports.FocusContext = react_1.default.createContext(null);
|
|
10
|
+
/** Wrap a ref-forwarding native control (Pressable, TextInput, or a custom control). */
|
|
11
|
+
function NavigationFocusable({ id, children, }) {
|
|
12
|
+
const registry = react_1.default.useContext(exports.FocusContext);
|
|
13
|
+
const target = react_1.default.useRef(null);
|
|
14
|
+
const childProps = children.props;
|
|
15
|
+
const originalRef = Number(react_1.default.version.split('.')[0]) >= 19
|
|
16
|
+
? childProps.ref
|
|
17
|
+
: children.ref;
|
|
18
|
+
const setRef = react_1.default.useCallback((value) => {
|
|
19
|
+
target.current = value;
|
|
20
|
+
if (typeof originalRef === 'function')
|
|
21
|
+
originalRef(value);
|
|
22
|
+
else if (originalRef)
|
|
23
|
+
originalRef.current =
|
|
24
|
+
value;
|
|
25
|
+
}, [originalRef]);
|
|
26
|
+
react_1.default.useLayoutEffect(() => {
|
|
27
|
+
if (!registry)
|
|
28
|
+
throw new Error('NavigationFocusable must be inside a Screen.');
|
|
29
|
+
if (target.current)
|
|
30
|
+
return registry.register(id, target.current);
|
|
31
|
+
}, [id, registry]);
|
|
32
|
+
return react_1.default.cloneElement(children, {
|
|
33
|
+
ref: setRef,
|
|
34
|
+
onFocus: (...args) => {
|
|
35
|
+
registry?.record(id);
|
|
36
|
+
childProps.onFocus?.(...args);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Navigation } from './types';
|
|
3
|
+
import type { NavigationRoute, NavigationState } from '../routers';
|
|
4
|
+
export declare function useNavigation<N = Navigation>(): N;
|
|
5
|
+
export declare function useRoute<R extends NavigationRoute = NavigationRoute>(): R;
|
|
6
|
+
export declare function useNavigationState<T>(selector: (state: NavigationState) => T): T;
|
|
7
|
+
export declare function useIsFocused(): boolean;
|
|
8
|
+
export declare function useFocusEffect(effect: React.EffectCallback): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.useNavigation = useNavigation;
|
|
7
|
+
exports.useRoute = useRoute;
|
|
8
|
+
exports.useNavigationState = useNavigationState;
|
|
9
|
+
exports.useIsFocused = useIsFocused;
|
|
10
|
+
exports.useFocusEffect = useFocusEffect;
|
|
11
|
+
const react_1 = __importDefault(require("react"));
|
|
12
|
+
const builder_1 = require("./builder");
|
|
13
|
+
const context_1 = require("./context");
|
|
14
|
+
function useNavigation() {
|
|
15
|
+
const screen = react_1.default.useContext(context_1.ScreenContext);
|
|
16
|
+
if (!screen)
|
|
17
|
+
throw new Error('useNavigation must be used inside a Screen.');
|
|
18
|
+
return screen.navigation;
|
|
19
|
+
}
|
|
20
|
+
function useRoute() {
|
|
21
|
+
const screen = react_1.default.useContext(context_1.ScreenContext);
|
|
22
|
+
if (!screen)
|
|
23
|
+
throw new Error('useRoute must be used inside a Screen.');
|
|
24
|
+
return screen.route;
|
|
25
|
+
}
|
|
26
|
+
function useNavigationState(selector) {
|
|
27
|
+
const store = (0, context_1.useStore)();
|
|
28
|
+
const { nodeId } = react_1.default.useContext(context_1.ScopeContext);
|
|
29
|
+
const snapshot = react_1.default.useCallback(() => store.getNode(nodeId ?? store.getState().rootNodeId)?.state, [store, nodeId]);
|
|
30
|
+
const state = react_1.default.useSyncExternalStore(store.subscribe, snapshot, snapshot);
|
|
31
|
+
// Screen render can precede the navigator's layout-effect registration.
|
|
32
|
+
const initial = react_1.default.useContext(builder_1.NavigatorStateContext);
|
|
33
|
+
const value = state ?? initial;
|
|
34
|
+
if (!value)
|
|
35
|
+
throw new Error('useNavigationState requires a registered navigator.');
|
|
36
|
+
return selector(value);
|
|
37
|
+
}
|
|
38
|
+
function useIsFocused() {
|
|
39
|
+
const screen = react_1.default.useContext(context_1.ScreenContext);
|
|
40
|
+
return screen?.focused ?? false;
|
|
41
|
+
}
|
|
42
|
+
function useFocusEffect(effect) {
|
|
43
|
+
const focused = useIsFocused();
|
|
44
|
+
react_1.default.useEffect(() => {
|
|
45
|
+
if (focused)
|
|
46
|
+
return effect();
|
|
47
|
+
}, [focused, effect]);
|
|
48
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { NavigationStore } from './store';
|
|
2
|
+
import type { ParamListBase } from '../routers';
|
|
3
|
+
import type { StackNavigation, SidebarNavigation, TabNavigation, NavigationRef } from './types';
|
|
4
|
+
export type AnyNavigation<P extends ParamListBase = ParamListBase> = StackNavigation<P> & SidebarNavigation<P> & TabNavigation<P>;
|
|
5
|
+
export declare function createNavigation<P extends ParamListBase = ParamListBase>(store: NavigationStore, id: string, routeKey?: string): AnyNavigation<P>;
|
|
6
|
+
export declare function createNavigationRef<P extends ParamListBase = ParamListBase>(): NavigationRef<P>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createNavigation = createNavigation;
|
|
4
|
+
exports.createNavigationRef = createNavigationRef;
|
|
5
|
+
function createNavigation(store, id, routeKey) {
|
|
6
|
+
const dispatch = (action) => {
|
|
7
|
+
store.dispatch({ ...action, target: action.target ?? id });
|
|
8
|
+
};
|
|
9
|
+
const routeAction = (type) => (...[name, params]) => dispatch({ type, payload: { name, params } });
|
|
10
|
+
return {
|
|
11
|
+
navigate: routeAction('navigate'),
|
|
12
|
+
push: routeAction('push'),
|
|
13
|
+
replace: routeAction('replace'),
|
|
14
|
+
popTo: routeAction('popTo'),
|
|
15
|
+
dispatch,
|
|
16
|
+
goBack: () => dispatch({ type: 'back' }),
|
|
17
|
+
goForward: () => dispatch({ type: 'forward' }),
|
|
18
|
+
canGoBack: () => store.canDispatch('back', id),
|
|
19
|
+
canGoForward: () => store.canDispatch('forward', id),
|
|
20
|
+
pop: (count) => dispatch({ type: 'pop', payload: { count } }),
|
|
21
|
+
popToRoot: () => dispatch({ type: 'popToRoot' }),
|
|
22
|
+
select: (name) => dispatch({ type: 'select', payload: { name } }),
|
|
23
|
+
collapseSidebar: () => dispatch({ type: 'collapseSidebar' }),
|
|
24
|
+
expandSidebar: () => dispatch({ type: 'expandSidebar' }),
|
|
25
|
+
toggleSidebar: () => dispatch({ type: 'toggleSidebar' }),
|
|
26
|
+
nextTab: () => dispatch({ type: 'nextTab' }),
|
|
27
|
+
previousTab: () => dispatch({ type: 'previousTab' }),
|
|
28
|
+
getState: () => {
|
|
29
|
+
const node = store.getNode(id);
|
|
30
|
+
if (!node)
|
|
31
|
+
throw new Error('Navigator is not mounted.');
|
|
32
|
+
return node.state;
|
|
33
|
+
},
|
|
34
|
+
getParent: () => {
|
|
35
|
+
const node = store.getNode(id);
|
|
36
|
+
return node?.parentId
|
|
37
|
+
? createNavigation(store, node.parentId, node.parentRouteKey)
|
|
38
|
+
: undefined;
|
|
39
|
+
},
|
|
40
|
+
isFocused: () => routeKey ? store.isRouteFocused(id, routeKey) : store.isNodeFocused(id),
|
|
41
|
+
addListener: (type, listener) => store.addListener(routeKey ?? store.getNode(id)?.state.activeRouteKey ?? id, type, listener),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function createNavigationRef() {
|
|
45
|
+
const ref = {
|
|
46
|
+
current: null,
|
|
47
|
+
isReady: () => ref.current?.isReady() ?? false,
|
|
48
|
+
navigate: (...args) => ref.current?.navigate(...args),
|
|
49
|
+
goBack: () => ref.current?.goBack(),
|
|
50
|
+
goForward: () => ref.current?.goForward(),
|
|
51
|
+
canGoBack: () => ref.current?.canGoBack() ?? false,
|
|
52
|
+
canGoForward: () => ref.current?.canGoForward() ?? false,
|
|
53
|
+
dispatch: (action) => ref.current?.dispatch(action),
|
|
54
|
+
getRootState: () => ref.current?.getRootState(),
|
|
55
|
+
};
|
|
56
|
+
return ref;
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type NavigationAction, type NavigationNode, type RootNavigationState, type RouterOptions } from '../routers';
|
|
2
|
+
export type NavigationEventType = 'focus' | 'blur' | 'beforeRemove' | 'state';
|
|
3
|
+
export interface NavigationEvent {
|
|
4
|
+
type: NavigationEventType;
|
|
5
|
+
target: string;
|
|
6
|
+
action?: NavigationAction;
|
|
7
|
+
defaultPrevented: boolean;
|
|
8
|
+
preventDefault(): void;
|
|
9
|
+
}
|
|
10
|
+
type Listener = (event: NavigationEvent) => void;
|
|
11
|
+
export declare function serializeNavigationState(state: RootNavigationState): string;
|
|
12
|
+
export declare function restoreNavigationState(input: string | RootNavigationState): RootNavigationState;
|
|
13
|
+
export declare class NavigationStore {
|
|
14
|
+
private snapshot;
|
|
15
|
+
private subscribers;
|
|
16
|
+
private events;
|
|
17
|
+
private configs;
|
|
18
|
+
private mounted;
|
|
19
|
+
constructor(initialState?: RootNavigationState);
|
|
20
|
+
getState: () => RootNavigationState;
|
|
21
|
+
getNode: (id: string) => NavigationNode | undefined;
|
|
22
|
+
subscribe: (listener: () => void) => () => void;
|
|
23
|
+
isReady: () => boolean;
|
|
24
|
+
register(node: NavigationNode, options: RouterOptions): () => void;
|
|
25
|
+
updateOptions(id: string, options: RouterOptions): void;
|
|
26
|
+
addListener(routeKey: string, type: NavigationEventType, listener: Listener): () => void;
|
|
27
|
+
private emit;
|
|
28
|
+
isNodeFocused(id: string): boolean;
|
|
29
|
+
isRouteFocused(id: string, routeKey: string): boolean;
|
|
30
|
+
private focusedRouteKeys;
|
|
31
|
+
private publishFocus;
|
|
32
|
+
private commit;
|
|
33
|
+
focusRoute(id: string, routeKey: string): void;
|
|
34
|
+
setFocusedNode(id: string): void;
|
|
35
|
+
getActiveNodeId(): string;
|
|
36
|
+
canDispatch(type: 'back' | 'forward', target?: string): boolean;
|
|
37
|
+
dispatch: (original: NavigationAction) => boolean;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NavigationStore = void 0;
|
|
4
|
+
exports.serializeNavigationState = serializeNavigationState;
|
|
5
|
+
exports.restoreNavigationState = restoreNavigationState;
|
|
6
|
+
const routers_1 = require("../routers");
|
|
7
|
+
function assertSerializable(value, seen = new Set()) {
|
|
8
|
+
if (value === undefined ||
|
|
9
|
+
value === null ||
|
|
10
|
+
typeof value === 'string' ||
|
|
11
|
+
typeof value === 'boolean')
|
|
12
|
+
return;
|
|
13
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
14
|
+
return;
|
|
15
|
+
if (typeof value !== 'object')
|
|
16
|
+
throw new Error('Navigation params must be JSON serializable.');
|
|
17
|
+
if (seen.has(value))
|
|
18
|
+
throw new Error('Navigation state contains a circular reference.');
|
|
19
|
+
if (!Array.isArray(value) &&
|
|
20
|
+
Object.getPrototypeOf(value) !== Object.prototype)
|
|
21
|
+
throw new Error('Navigation params must contain only plain objects and arrays.');
|
|
22
|
+
seen.add(value);
|
|
23
|
+
for (const child of Object.values(value))
|
|
24
|
+
assertSerializable(child, seen);
|
|
25
|
+
seen.delete(value);
|
|
26
|
+
}
|
|
27
|
+
function serializeNavigationState(state) {
|
|
28
|
+
assertSerializable(state);
|
|
29
|
+
return JSON.stringify(state);
|
|
30
|
+
}
|
|
31
|
+
function restoreNavigationState(input) {
|
|
32
|
+
const state = typeof input === 'string'
|
|
33
|
+
? JSON.parse(input)
|
|
34
|
+
: JSON.parse(serializeNavigationState(input));
|
|
35
|
+
if (!state ||
|
|
36
|
+
state.version !== 1 ||
|
|
37
|
+
typeof state.rootNodeId !== 'string' ||
|
|
38
|
+
!state.nodes ||
|
|
39
|
+
!state.nodes[state.rootNodeId])
|
|
40
|
+
throw new Error('Invalid navigation snapshot (expected version 1).');
|
|
41
|
+
for (const [id, node] of Object.entries(state.nodes)) {
|
|
42
|
+
const s = node.state;
|
|
43
|
+
if (id !== node.id ||
|
|
44
|
+
s?.key !== id ||
|
|
45
|
+
node.type !== s.type ||
|
|
46
|
+
!['stack', 'sidebar', 'tabs', 'split'].includes(s.type) ||
|
|
47
|
+
!Array.isArray(s.routes) ||
|
|
48
|
+
!s.routes.length)
|
|
49
|
+
throw new Error(`Invalid navigation node: ${id}`);
|
|
50
|
+
const keys = s.routes.map((r) => r.key);
|
|
51
|
+
if (new Set(keys).size !== keys.length ||
|
|
52
|
+
!keys.includes(s.activeRouteKey) ||
|
|
53
|
+
s.routes.some((r) => typeof r.key !== 'string' || typeof r.name !== 'string'))
|
|
54
|
+
throw new Error(`Invalid routes: ${id}`);
|
|
55
|
+
if (s.type === 'stack' &&
|
|
56
|
+
(!Number.isInteger(s.nextRouteId) || s.nextRouteId < 1))
|
|
57
|
+
throw new Error('Invalid stack route counter.');
|
|
58
|
+
if ((s.type === 'sidebar' || s.type === 'tabs') &&
|
|
59
|
+
typeof s.collapsed !== 'boolean')
|
|
60
|
+
throw new Error('Invalid selection state.');
|
|
61
|
+
if (s.type === 'stack' &&
|
|
62
|
+
s.activeRouteKey !== s.routes[s.routes.length - 1].key)
|
|
63
|
+
throw new Error('Stack active route must be the last route.');
|
|
64
|
+
if (s.type === 'stack' && s.history) {
|
|
65
|
+
for (const routes of [...s.history.past, ...s.history.future]) {
|
|
66
|
+
if (!Array.isArray(routes) ||
|
|
67
|
+
!routes.length ||
|
|
68
|
+
routes.some((r) => typeof r.key !== 'string' || typeof r.name !== 'string'))
|
|
69
|
+
throw new Error('Invalid stack history.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (s.type === 'split' &&
|
|
73
|
+
(!s.widths ||
|
|
74
|
+
!Array.isArray(s.visibleColumnIds) ||
|
|
75
|
+
!s.visibleColumnIds.length ||
|
|
76
|
+
new Set(s.visibleColumnIds).size !== s.visibleColumnIds.length ||
|
|
77
|
+
s.visibleColumnIds.some((name) => !s.routes.some((r) => r.name === name)) ||
|
|
78
|
+
s.routes.some((r) => !Number.isFinite(s.widths[r.name]) || s.widths[r.name] < 0)))
|
|
79
|
+
throw new Error('Invalid split state.');
|
|
80
|
+
if (s.type === 'split' && s.collapsedColumns !== undefined) {
|
|
81
|
+
if (!s.collapsedColumns ||
|
|
82
|
+
typeof s.collapsedColumns !== 'object' ||
|
|
83
|
+
Array.isArray(s.collapsedColumns) ||
|
|
84
|
+
Object.entries(s.collapsedColumns).some(([name, column]) => !s.routes.some((r) => r.name === name) ||
|
|
85
|
+
!column ||
|
|
86
|
+
!Number.isFinite(column.width) ||
|
|
87
|
+
column.width < 0 ||
|
|
88
|
+
!Number.isFinite(column.expandedWidth) ||
|
|
89
|
+
column.expandedWidth < 0 ||
|
|
90
|
+
s.widths[name] !== column.width))
|
|
91
|
+
throw new Error('Invalid collapsed split state.');
|
|
92
|
+
}
|
|
93
|
+
const visited = new Set([id]);
|
|
94
|
+
let current = node;
|
|
95
|
+
while (current.parentId) {
|
|
96
|
+
const parent = state.nodes[current.parentId];
|
|
97
|
+
if (!parent ||
|
|
98
|
+
visited.has(parent.id) ||
|
|
99
|
+
!retainedRoutes(parent.state).some((r) => r.key === current.parentRouteKey))
|
|
100
|
+
throw new Error('Invalid navigation parent relationship.');
|
|
101
|
+
visited.add(parent.id);
|
|
102
|
+
current = parent;
|
|
103
|
+
}
|
|
104
|
+
if (current.id !== state.rootNodeId)
|
|
105
|
+
throw new Error('Navigation snapshot has multiple roots.');
|
|
106
|
+
}
|
|
107
|
+
if (state.activeNodeId && !state.nodes[state.activeNodeId])
|
|
108
|
+
throw new Error('Invalid active navigator.');
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
function retainedRoutes(state) {
|
|
112
|
+
return state.type === 'stack' && state.history
|
|
113
|
+
? [
|
|
114
|
+
...state.routes,
|
|
115
|
+
...state.history.past.flat(),
|
|
116
|
+
...state.history.future.flat(),
|
|
117
|
+
]
|
|
118
|
+
: state.routes;
|
|
119
|
+
}
|
|
120
|
+
class NavigationStore {
|
|
121
|
+
snapshot;
|
|
122
|
+
subscribers = new Set();
|
|
123
|
+
events = new Map();
|
|
124
|
+
configs = new Map();
|
|
125
|
+
mounted = new Set();
|
|
126
|
+
constructor(initialState) {
|
|
127
|
+
this.snapshot = initialState
|
|
128
|
+
? restoreNavigationState(initialState)
|
|
129
|
+
: { version: 1, rootNodeId: 'root', nodes: {} };
|
|
130
|
+
}
|
|
131
|
+
getState = () => this.snapshot;
|
|
132
|
+
getNode = (id) => this.snapshot.nodes[id];
|
|
133
|
+
subscribe = (listener) => {
|
|
134
|
+
this.subscribers.add(listener);
|
|
135
|
+
return () => {
|
|
136
|
+
this.subscribers.delete(listener);
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
isReady = () => this.mounted.has(this.snapshot.rootNodeId);
|
|
140
|
+
register(node, options) {
|
|
141
|
+
assertSerializable(node.state);
|
|
142
|
+
if (this.mounted.has(node.id))
|
|
143
|
+
throw new Error(`Duplicate navigator id: ${node.id}. Supply a unique id for sibling navigators.`);
|
|
144
|
+
const saved = this.snapshot.nodes[node.id];
|
|
145
|
+
if (saved &&
|
|
146
|
+
(saved.type !== node.type ||
|
|
147
|
+
retainedRoutes(saved.state).some((r) => !options.routes.some((c) => c.name === r.name))))
|
|
148
|
+
throw new Error(`Saved state does not match navigator ${node.id}.`);
|
|
149
|
+
const previousFocus = this.focusedRouteKeys();
|
|
150
|
+
this.configs.set(node.id, options);
|
|
151
|
+
this.mounted.add(node.id);
|
|
152
|
+
this.commit({
|
|
153
|
+
...this.snapshot,
|
|
154
|
+
rootNodeId: node.parentId ? this.snapshot.rootNodeId : node.id,
|
|
155
|
+
nodes: { ...this.snapshot.nodes, [node.id]: saved ?? node },
|
|
156
|
+
}, undefined, previousFocus);
|
|
157
|
+
return () => {
|
|
158
|
+
const oldFocus = this.focusedRouteKeys();
|
|
159
|
+
this.mounted.delete(node.id);
|
|
160
|
+
this.configs.delete(node.id);
|
|
161
|
+
this.publishFocus(oldFocus);
|
|
162
|
+
for (const listener of this.subscribers)
|
|
163
|
+
listener();
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
updateOptions(id, options) {
|
|
167
|
+
this.configs.set(id, options);
|
|
168
|
+
}
|
|
169
|
+
addListener(routeKey, type, listener) {
|
|
170
|
+
let types = this.events.get(routeKey);
|
|
171
|
+
if (!types)
|
|
172
|
+
this.events.set(routeKey, (types = new Map()));
|
|
173
|
+
let listeners = types.get(type);
|
|
174
|
+
if (!listeners)
|
|
175
|
+
types.set(type, (listeners = new Set()));
|
|
176
|
+
listeners.add(listener);
|
|
177
|
+
return () => {
|
|
178
|
+
listeners.delete(listener);
|
|
179
|
+
if (!listeners.size)
|
|
180
|
+
types.delete(type);
|
|
181
|
+
if (!types.size)
|
|
182
|
+
this.events.delete(routeKey);
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
emit(target, type, action) {
|
|
186
|
+
const event = {
|
|
187
|
+
type,
|
|
188
|
+
target,
|
|
189
|
+
action,
|
|
190
|
+
defaultPrevented: false,
|
|
191
|
+
preventDefault() {
|
|
192
|
+
if (type === 'beforeRemove')
|
|
193
|
+
this.defaultPrevented = true;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
for (const listener of this.events.get(target)?.get(type) ?? [])
|
|
197
|
+
listener(event);
|
|
198
|
+
return event.defaultPrevented;
|
|
199
|
+
}
|
|
200
|
+
isNodeFocused(id) {
|
|
201
|
+
const node = this.snapshot.nodes[id];
|
|
202
|
+
if (!node || !this.mounted.has(id))
|
|
203
|
+
return false;
|
|
204
|
+
if (!node.parentId)
|
|
205
|
+
return true;
|
|
206
|
+
const parent = this.snapshot.nodes[node.parentId];
|
|
207
|
+
return (!!parent &&
|
|
208
|
+
parent.state.activeRouteKey === node.parentRouteKey &&
|
|
209
|
+
this.isNodeFocused(parent.id));
|
|
210
|
+
}
|
|
211
|
+
isRouteFocused(id, routeKey) {
|
|
212
|
+
return (this.getNode(id)?.state.activeRouteKey === routeKey &&
|
|
213
|
+
this.isNodeFocused(id));
|
|
214
|
+
}
|
|
215
|
+
focusedRouteKeys() {
|
|
216
|
+
const depth = (node) => node.parentId && this.getNode(node.parentId)
|
|
217
|
+
? 1 + depth(this.getNode(node.parentId))
|
|
218
|
+
: 0;
|
|
219
|
+
return Object.values(this.snapshot.nodes)
|
|
220
|
+
.filter((n) => this.isNodeFocused(n.id))
|
|
221
|
+
.sort((a, b) => depth(a) - depth(b))
|
|
222
|
+
.map((n) => n.state.activeRouteKey);
|
|
223
|
+
}
|
|
224
|
+
publishFocus(previous) {
|
|
225
|
+
const next = this.focusedRouteKeys();
|
|
226
|
+
for (const key of [...previous].reverse())
|
|
227
|
+
if (!next.includes(key))
|
|
228
|
+
this.emit(key, 'blur');
|
|
229
|
+
for (const key of next)
|
|
230
|
+
if (!previous.includes(key))
|
|
231
|
+
this.emit(key, 'focus');
|
|
232
|
+
}
|
|
233
|
+
commit(next, action, previous = this.focusedRouteKeys()) {
|
|
234
|
+
this.snapshot = next;
|
|
235
|
+
this.publishFocus(previous);
|
|
236
|
+
for (const listener of this.subscribers)
|
|
237
|
+
listener();
|
|
238
|
+
for (const node of Object.values(next.nodes))
|
|
239
|
+
for (const route of node.state.routes)
|
|
240
|
+
this.emit(route.key, 'state', action);
|
|
241
|
+
}
|
|
242
|
+
focusRoute(id, routeKey) {
|
|
243
|
+
const node = this.getNode(id);
|
|
244
|
+
if (node?.state.type === 'split' &&
|
|
245
|
+
node.state.activeRouteKey !== routeKey) {
|
|
246
|
+
const route = node.state.routes.find((r) => r.key === routeKey);
|
|
247
|
+
if (!route || !node.state.visibleColumnIds.includes(route.name))
|
|
248
|
+
return;
|
|
249
|
+
this.commit({
|
|
250
|
+
...this.snapshot,
|
|
251
|
+
nodes: {
|
|
252
|
+
...this.snapshot.nodes,
|
|
253
|
+
[id]: { ...node, state: { ...node.state, activeRouteKey: routeKey } },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// Native focus bubbles: an ancestor Scene must not replace the focused leaf.
|
|
258
|
+
let active = this.getNode(this.getActiveNodeId());
|
|
259
|
+
while (active) {
|
|
260
|
+
if (active.id === id && this.isNodeFocused(this.getActiveNodeId()))
|
|
261
|
+
return;
|
|
262
|
+
active = active.parentId ? this.getNode(active.parentId) : undefined;
|
|
263
|
+
}
|
|
264
|
+
this.setFocusedNode(id);
|
|
265
|
+
}
|
|
266
|
+
setFocusedNode(id) {
|
|
267
|
+
if (!this.mounted.has(id))
|
|
268
|
+
return;
|
|
269
|
+
let node = this.getNode(id);
|
|
270
|
+
const nodes = { ...this.snapshot.nodes };
|
|
271
|
+
while (node?.parentId) {
|
|
272
|
+
const parent = nodes[node.parentId];
|
|
273
|
+
if (!parent)
|
|
274
|
+
return;
|
|
275
|
+
if (parent.state.type === 'split') {
|
|
276
|
+
const route = parent.state.routes.find((r) => r.key === node.parentRouteKey);
|
|
277
|
+
if (!route || !parent.state.visibleColumnIds.includes(route.name))
|
|
278
|
+
return;
|
|
279
|
+
nodes[parent.id] = {
|
|
280
|
+
...parent,
|
|
281
|
+
state: { ...parent.state, activeRouteKey: route.key },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
else if (parent.state.activeRouteKey !== node.parentRouteKey)
|
|
285
|
+
return;
|
|
286
|
+
node = parent;
|
|
287
|
+
}
|
|
288
|
+
if (this.snapshot.activeNodeId === id &&
|
|
289
|
+
Object.keys(nodes).every((key) => nodes[key] === this.snapshot.nodes[key]))
|
|
290
|
+
return;
|
|
291
|
+
this.commit({ ...this.snapshot, nodes, activeNodeId: id });
|
|
292
|
+
}
|
|
293
|
+
getActiveNodeId() {
|
|
294
|
+
const preferred = this.snapshot.activeNodeId;
|
|
295
|
+
let id = preferred && this.isNodeFocused(preferred)
|
|
296
|
+
? preferred
|
|
297
|
+
: this.snapshot.rootNodeId;
|
|
298
|
+
while (true) {
|
|
299
|
+
const child = Object.values(this.snapshot.nodes).find((n) => n.parentId === id && this.isNodeFocused(n.id));
|
|
300
|
+
if (!child)
|
|
301
|
+
return id;
|
|
302
|
+
id = child.id;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
canDispatch(type, target = this.getActiveNodeId()) {
|
|
306
|
+
let node = this.getNode(target);
|
|
307
|
+
while (node) {
|
|
308
|
+
const s = node.state;
|
|
309
|
+
if (s.type === 'stack' &&
|
|
310
|
+
(type === 'back'
|
|
311
|
+
? s.history
|
|
312
|
+
? s.history.past.length > 0
|
|
313
|
+
: s.routes.length > 1
|
|
314
|
+
: !!s.history?.future.length))
|
|
315
|
+
return true;
|
|
316
|
+
node = node.parentId ? this.getNode(node.parentId) : undefined;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
dispatch = (original) => {
|
|
321
|
+
let action = original;
|
|
322
|
+
if ('payload' in action)
|
|
323
|
+
assertSerializable(action.payload);
|
|
324
|
+
if (action.type === 'navigate' ||
|
|
325
|
+
action.type === 'push' ||
|
|
326
|
+
action.type === 'replace')
|
|
327
|
+
action = {
|
|
328
|
+
...action,
|
|
329
|
+
payload: {
|
|
330
|
+
...action.payload,
|
|
331
|
+
key: action.payload.key ?? (0, routers_1.createRouteKey)(action.payload.name),
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
let node = this.getNode(action.target ?? this.getActiveNodeId());
|
|
335
|
+
while (node) {
|
|
336
|
+
const options = this.configs.get(node.id);
|
|
337
|
+
const next = options
|
|
338
|
+
? (0, routers_1.reduceNavigation)(node.state, action, options)
|
|
339
|
+
: null;
|
|
340
|
+
if (next) {
|
|
341
|
+
if (next === node.state)
|
|
342
|
+
return true;
|
|
343
|
+
const removed = node.state.routes
|
|
344
|
+
.filter((r) => !next.routes.some((n) => n.key === r.key))
|
|
345
|
+
.map((r) => r.key);
|
|
346
|
+
const removedNodes = new Set();
|
|
347
|
+
let changed = true;
|
|
348
|
+
while (changed) {
|
|
349
|
+
changed = false;
|
|
350
|
+
for (const child of Object.values(this.snapshot.nodes))
|
|
351
|
+
if (!removedNodes.has(child.id) &&
|
|
352
|
+
((child.parentId === node.id &&
|
|
353
|
+
removed.includes(child.parentRouteKey)) ||
|
|
354
|
+
removedNodes.has(child.parentId))) {
|
|
355
|
+
removedNodes.add(child.id);
|
|
356
|
+
removed.push(...child.state.routes.map((r) => r.key));
|
|
357
|
+
changed = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
let blocked = false;
|
|
361
|
+
for (const key of [...removed].reverse())
|
|
362
|
+
blocked = this.emit(key, 'beforeRemove', action) || blocked;
|
|
363
|
+
if (blocked)
|
|
364
|
+
return true;
|
|
365
|
+
const nodes = {
|
|
366
|
+
...this.snapshot.nodes,
|
|
367
|
+
[node.id]: { ...node, state: next },
|
|
368
|
+
};
|
|
369
|
+
// Keep history-owned subtrees so Back/Forward can restore nested navigator state.
|
|
370
|
+
let pruned = true;
|
|
371
|
+
while (pruned) {
|
|
372
|
+
pruned = false;
|
|
373
|
+
for (const child of Object.values(nodes))
|
|
374
|
+
if (child.parentId &&
|
|
375
|
+
(!nodes[child.parentId] ||
|
|
376
|
+
!retainedRoutes(nodes[child.parentId].state).some((r) => r.key === child.parentRouteKey))) {
|
|
377
|
+
delete nodes[child.id];
|
|
378
|
+
pruned = true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
this.commit({ ...this.snapshot, nodes, activeNodeId: node.id }, action);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
node = node.parentId ? this.getNode(node.parentId) : undefined;
|
|
385
|
+
}
|
|
386
|
+
if (['navigate', 'push', 'replace', 'popTo', 'select'].includes(action.type))
|
|
387
|
+
console.warn(`Unhandled navigation action: ${action.type}${'payload' in action && action.payload && 'name' in action.payload ? ` (${action.payload.name})` : ''}`);
|
|
388
|
+
return false;
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
exports.NavigationStore = NavigationStore;
|