@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.
Files changed (103) hide show
  1. package/DesktopNavigation.podspec +18 -0
  2. package/LICENSE +21 -0
  3. package/NATIVE.md +300 -0
  4. package/README.md +127 -0
  5. package/dist/core/NavigationContainer.d.ts +23 -0
  6. package/dist/core/NavigationContainer.js +68 -0
  7. package/dist/core/NavigationItem.d.ts +27 -0
  8. package/dist/core/NavigationItem.js +71 -0
  9. package/dist/core/Scene.d.ts +19 -0
  10. package/dist/core/Scene.js +124 -0
  11. package/dist/core/SelectionNavigator.d.ts +38 -0
  12. package/dist/core/SelectionNavigator.js +194 -0
  13. package/dist/core/builder.d.ts +40 -0
  14. package/dist/core/builder.js +81 -0
  15. package/dist/core/context.d.ts +26 -0
  16. package/dist/core/context.js +36 -0
  17. package/dist/core/focus.d.ts +14 -0
  18. package/dist/core/focus.js +39 -0
  19. package/dist/core/hooks.d.ts +8 -0
  20. package/dist/core/hooks.js +48 -0
  21. package/dist/core/navigation.d.ts +6 -0
  22. package/dist/core/navigation.js +57 -0
  23. package/dist/core/store.d.ts +39 -0
  24. package/dist/core/store.js +391 -0
  25. package/dist/core/types.d.ts +155 -0
  26. package/dist/core/types.js +2 -0
  27. package/dist/index.d.ts +15 -0
  28. package/dist/index.js +46 -0
  29. package/dist/native/host.d.ts +100 -0
  30. package/dist/native/host.js +190 -0
  31. package/dist/native/icons.d.ts +56 -0
  32. package/dist/native/icons.js +55 -0
  33. package/dist/native/index.d.ts +13 -0
  34. package/dist/native/index.js +44 -0
  35. package/dist/native/sidebar.d.ts +39 -0
  36. package/dist/native/sidebar.js +126 -0
  37. package/dist/native/specs/DesktopNavigationHostNativeComponent.d.ts +10 -0
  38. package/dist/native/specs/DesktopNavigationHostNativeComponent.js +4 -0
  39. package/dist/native/split.d.ts +27 -0
  40. package/dist/native/split.js +105 -0
  41. package/dist/native/stack.d.ts +26 -0
  42. package/dist/native/stack.js +66 -0
  43. package/dist/platform/index.d.ts +52 -0
  44. package/dist/platform/index.js +28 -0
  45. package/dist/platform/macos.d.ts +2 -0
  46. package/dist/platform/macos.js +18 -0
  47. package/dist/platform/windows.d.ts +2 -0
  48. package/dist/platform/windows.js +18 -0
  49. package/dist/routers/index.d.ts +23 -0
  50. package/dist/routers/index.js +393 -0
  51. package/dist/routers/types.d.ts +116 -0
  52. package/dist/routers/types.js +2 -0
  53. package/dist/sidebar/index.d.ts +17 -0
  54. package/dist/sidebar/index.js +18 -0
  55. package/dist/split/index.d.ts +42 -0
  56. package/dist/split/index.js +199 -0
  57. package/dist/stack/index.d.ts +23 -0
  58. package/dist/stack/index.js +88 -0
  59. package/dist/tabs/index.d.ts +13 -0
  60. package/dist/tabs/index.js +15 -0
  61. package/docs/GUIDE.md +384 -0
  62. package/macos/DDNNavigationComponentView.h +4 -0
  63. package/macos/DDNNavigationComponentView.mm +40 -0
  64. package/macos/DDNNavigationView.swift +398 -0
  65. package/macos/DDNNavigationViewManager.mm +11 -0
  66. package/package.json +82 -0
  67. package/react-native.config.js +19 -0
  68. package/src/core/NavigationContainer.tsx +137 -0
  69. package/src/core/NavigationItem.tsx +149 -0
  70. package/src/core/Scene.tsx +194 -0
  71. package/src/core/SelectionNavigator.tsx +371 -0
  72. package/src/core/builder.tsx +147 -0
  73. package/src/core/context.tsx +43 -0
  74. package/src/core/focus.tsx +50 -0
  75. package/src/core/hooks.ts +42 -0
  76. package/src/core/navigation.ts +77 -0
  77. package/src/core/store.ts +471 -0
  78. package/src/core/types.ts +176 -0
  79. package/src/index.ts +36 -0
  80. package/src/native/host.tsx +377 -0
  81. package/src/native/icons.ts +110 -0
  82. package/src/native/index.ts +56 -0
  83. package/src/native/sidebar.tsx +230 -0
  84. package/src/native/specs/DesktopNavigationHostNativeComponent.ts +12 -0
  85. package/src/native/split.tsx +160 -0
  86. package/src/native/stack.tsx +157 -0
  87. package/src/platform/index.tsx +76 -0
  88. package/src/platform/macos.ts +15 -0
  89. package/src/platform/windows.ts +15 -0
  90. package/src/routers/index.ts +442 -0
  91. package/src/routers/types.ts +117 -0
  92. package/src/sidebar/index.tsx +42 -0
  93. package/src/split/index.tsx +350 -0
  94. package/src/stack/index.tsx +213 -0
  95. package/src/tabs/index.tsx +40 -0
  96. package/windows/DesktopNavigation/DesktopNavigation.def +3 -0
  97. package/windows/DesktopNavigation/DesktopNavigation.vcxproj +125 -0
  98. package/windows/DesktopNavigation/NavigationHost.cpp +432 -0
  99. package/windows/DesktopNavigation/ReactPackageProvider.cpp +9 -0
  100. package/windows/DesktopNavigation/ReactPackageProvider.h +10 -0
  101. package/windows/DesktopNavigation/ReactPackageProvider.idl +6 -0
  102. package/windows/DesktopNavigation/pch.cpp +1 -0
  103. package/windows/DesktopNavigation/pch.h +27 -0
@@ -0,0 +1,230 @@
1
+ import React from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+ import type {
4
+ ParamListBase,
5
+ RouteName,
6
+ SelectionNavigationState,
7
+ } from '../routers';
8
+ import {
9
+ useNavigator,
10
+ readScreens,
11
+ resolveOptions,
12
+ NavigatorStateContext,
13
+ type ScreenConfig,
14
+ } from '../core/builder';
15
+ import { Scene } from '../core/Scene';
16
+ import { createNavigation } from '../core/navigation';
17
+ import type {
18
+ CommonScreenOptions,
19
+ SidebarFooterProps,
20
+ SidebarNavigation,
21
+ } from '../core/types';
22
+ import { NativeSurface, type NativeAppearance } from './host';
23
+ import { resolveNativeIcon, type NativeSidebarIconOption } from './icons';
24
+
25
+ export interface NativeSidebarScreenOptions extends CommonScreenOptions {
26
+ label?: string;
27
+ icon?: NativeSidebarIconOption;
28
+ /** Square slot size for React icons, in points / DIPs (default 24). */
29
+ iconSize?: number;
30
+ hidden?: boolean;
31
+ disabled?: boolean;
32
+ /** Native item badge. Numbers <= 0 and empty strings hide it. */
33
+ badge?: number | string;
34
+ }
35
+ export interface NativeSidebarNavigatorProps {
36
+ children: React.ReactNode;
37
+ id?: string;
38
+ initialRouteName?: string;
39
+ defaultCollapsed?: boolean;
40
+ width?: number;
41
+ style?: StyleProp<ViewStyle>;
42
+ appearance?: NativeAppearance;
43
+ screenOptions?: NativeSidebarScreenOptions;
44
+ /** React content below the native item list. `children` is always null. */
45
+ renderSidebarFooter?: (props: SidebarFooterProps) => React.ReactNode;
46
+ }
47
+ function resolveBadge(badge: number | string | undefined) {
48
+ if (typeof badge === 'number')
49
+ return Number.isFinite(badge) && badge > 0 ? String(badge) : undefined;
50
+ return typeof badge === 'string' && badge !== '' ? badge : undefined;
51
+ }
52
+ export function createSidebarNavigator<
53
+ P extends ParamListBase = ParamListBase,
54
+ >() {
55
+ function Screen<N extends RouteName<P>>(
56
+ _props: ScreenConfig<
57
+ P,
58
+ N,
59
+ NativeSidebarScreenOptions,
60
+ SidebarNavigation<P>
61
+ >,
62
+ ) {
63
+ return null;
64
+ }
65
+ function Section(_props: { title?: string; children: React.ReactNode }) {
66
+ return null;
67
+ }
68
+ function Navigator(
69
+ props: Omit<NativeSidebarNavigatorProps, 'initialRouteName'> & {
70
+ initialRouteName?: RouteName<P>;
71
+ },
72
+ ) {
73
+ if (
74
+ props.width !== undefined &&
75
+ (!Number.isFinite(props.width) || props.width < 100)
76
+ )
77
+ throw new Error(
78
+ 'Native sidebar width must be a finite number of at least 100.',
79
+ );
80
+ const definitions = readScreens<NativeSidebarScreenOptions>(
81
+ props.children,
82
+ Screen,
83
+ Section,
84
+ );
85
+ const { id, state, store, navigation } =
86
+ useNavigator<SelectionNavigationState>(
87
+ 'sidebar',
88
+ {
89
+ routes: definitions.map((d) => ({
90
+ ...d,
91
+ ...props.screenOptions,
92
+ ...(typeof d.options === 'object' ? d.options : {}),
93
+ })),
94
+ initialRouteName: props.initialRouteName,
95
+ defaultCollapsed: props.defaultCollapsed,
96
+ },
97
+ props.id,
98
+ );
99
+ const [footerHeight, setFooterHeight] = React.useState(0);
100
+ const collapseSidebar = () =>
101
+ store.dispatch({ target: id, type: 'collapseSidebar' });
102
+ const expandSidebar = () =>
103
+ store.dispatch({ target: id, type: 'expandSidebar' });
104
+ const footer = props.renderSidebarFooter?.({
105
+ collapsed: state.collapsed,
106
+ collapseSidebar,
107
+ expandSidebar,
108
+ toggleSidebar: () =>
109
+ state.collapsed ? expandSidebar() : collapseSidebar(),
110
+ children: null,
111
+ });
112
+ const hasFooter = footer != null && footer !== false;
113
+ const items = state.routes.map((route) => {
114
+ const definition = definitions.find((d) => d.name === route.name);
115
+ if (!definition)
116
+ throw new Error(
117
+ `Missing Screen: ${route.name}. Remount to change the screen list.`,
118
+ );
119
+ const options = resolveOptions(
120
+ definition,
121
+ route,
122
+ createNavigation(store, id, route.key),
123
+ props.screenOptions,
124
+ );
125
+ const iconState = {
126
+ focused: route.key === state.activeRouteKey,
127
+ disabled: options.disabled ?? false,
128
+ };
129
+ const icon =
130
+ typeof options.icon === 'function'
131
+ ? options.icon(iconState)
132
+ : options.icon;
133
+ return {
134
+ route,
135
+ definition,
136
+ options,
137
+ icon,
138
+ nativeIcon: resolveNativeIcon(icon, iconState, options.iconSize),
139
+ };
140
+ });
141
+ React.useLayoutEffect(() => {
142
+ store.updateOptions(id, {
143
+ key: id,
144
+ routes: items.map((i) => ({ name: i.route.name, ...i.options })),
145
+ });
146
+ const active = items.find((i) => i.route.key === state.activeRouteKey);
147
+ if (active?.options.disabled || active?.options.hidden) {
148
+ const next = items.find(
149
+ (i) => !i.options.disabled && !i.options.hidden,
150
+ );
151
+ if (next) navigation.select(next.route.name);
152
+ }
153
+ });
154
+ return (
155
+ <NavigatorStateContext.Provider value={state}>
156
+ <NativeSurface
157
+ style={props.style}
158
+ configuration={{
159
+ mode: 'sidebar',
160
+ items: items.map((i) => ({
161
+ key: i.route.key,
162
+ title: i.options.label ?? i.options.title ?? i.route.name,
163
+ hidden: i.options.hidden,
164
+ disabled: i.options.disabled,
165
+ section: i.definition.section?.title,
166
+ icon: i.nativeIcon,
167
+ badge: resolveBadge(i.options.badge),
168
+ })),
169
+ activeKey: state.activeRouteKey,
170
+ collapsed: state.collapsed,
171
+ paneWidth: props.width ?? 240,
172
+ footerHeight:
173
+ hasFooter && footerHeight > 0 ? footerHeight : undefined,
174
+ appearance: props.appearance,
175
+ }}
176
+ footer={hasFooter ? footer : undefined}
177
+ onFooterHeight={(height) =>
178
+ setFooterHeight((previous) =>
179
+ // Sub-point changes would only churn native revisions.
180
+ Math.abs(previous - height) < 0.5 ? previous : Math.ceil(height),
181
+ )
182
+ }
183
+ icons={items.flatMap((i) =>
184
+ React.isValidElement(i.icon) && !i.options.hidden
185
+ ? [{ key: i.route.key, content: i.icon }]
186
+ : [],
187
+ )}
188
+ onRequest={(request) => {
189
+ if (request.type === 'collapse')
190
+ request.collapsed ? collapseSidebar() : expandSidebar();
191
+ if (request.type === 'select') {
192
+ const item = items.find(
193
+ (i) =>
194
+ i.route.key === request.key &&
195
+ !i.options.hidden &&
196
+ !i.options.disabled,
197
+ );
198
+ if (item) {
199
+ store.setFocusedNode(id);
200
+ navigation.select(item.route.name);
201
+ }
202
+ }
203
+ }}
204
+ slots={items.map(({ route, definition, options }) => ({
205
+ key: route.key,
206
+ visible:
207
+ route.key === state.activeRouteKey &&
208
+ !options.hidden &&
209
+ !options.disabled,
210
+ content: (ready) => (
211
+ <Scene
212
+ focusReady={ready}
213
+ nodeId={id}
214
+ route={route}
215
+ component={definition.component}
216
+ visible={
217
+ route.key === state.activeRouteKey &&
218
+ !options.hidden &&
219
+ !options.disabled
220
+ }
221
+ options={options}
222
+ />
223
+ ),
224
+ }))}
225
+ />
226
+ </NavigatorStateContext.Provider>
227
+ );
228
+ }
229
+ return { Navigator, Screen, Section };
230
+ }
@@ -0,0 +1,12 @@
1
+ import type { HostComponent, ViewProps } from 'react-native';
2
+ import { codegenNativeComponent } from 'react-native';
3
+ import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes';
4
+
5
+ export interface NativeProps extends ViewProps {
6
+ configuration: string;
7
+ onNavigationEvent?: DirectEventHandler<Readonly<{ payload: string }>>;
8
+ }
9
+
10
+ export default codegenNativeComponent<NativeProps>(
11
+ 'DesktopNavigationHost',
12
+ ) as HostComponent<NativeProps>;
@@ -0,0 +1,160 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { splitColumnConfig, type SplitNavigationState } from '../routers';
4
+ import { useNavigator, NavigatorStateContext } from '../core/builder';
5
+ import { Scene } from '../core/Scene';
6
+ import { ScopeContext, SplitColumnContext } from '../core/context';
7
+ import { NativeSurface, type NativeAppearance } from './host';
8
+
9
+ export interface NativeSplitColumnProps {
10
+ id: string;
11
+ component: React.ComponentType;
12
+ defaultWidth?: number;
13
+ minWidth?: number;
14
+ maxWidth?: number;
15
+ style?: StyleProp<ViewStyle>;
16
+ contentStyle?: StyleProp<ViewStyle>;
17
+ }
18
+ export interface NativeSplitNavigatorProps {
19
+ children: React.ReactNode;
20
+ id?: string;
21
+ style?: StyleProp<ViewStyle>;
22
+ appearance?: NativeAppearance;
23
+ layout?: (size: { width: number; height: number }) => string[];
24
+ onColumnResize?: (id: string, width: number) => void;
25
+ }
26
+ export function createSplitNavigator() {
27
+ function Column(_props: NativeSplitColumnProps) {
28
+ return null;
29
+ }
30
+ function Navigator(props: NativeSplitNavigatorProps) {
31
+ const columns: NativeSplitColumnProps[] = [];
32
+ React.Children.forEach(props.children, (child) => {
33
+ if (child == null || typeof child === 'boolean') return;
34
+ if (!React.isValidElement(child) || child.type !== Column)
35
+ throw new Error(
36
+ 'Split.Navigator children must be Split.Column elements.',
37
+ );
38
+ columns.push(child.props as NativeSplitColumnProps);
39
+ });
40
+ const { id, state, store } = useNavigator<SplitNavigationState>(
41
+ 'split',
42
+ { routes: columns.map((c) => ({ ...c, name: c.id })) },
43
+ props.id,
44
+ );
45
+ const [size, setSize] = React.useState<{
46
+ width: number;
47
+ height: number;
48
+ } | null>(null);
49
+ React.useLayoutEffect(() => {
50
+ if (!size) return;
51
+ const ids = props.layout ? props.layout(size) : columns.map((c) => c.id);
52
+ store.dispatch({ type: 'layout', target: id, payload: { ids } });
53
+ });
54
+ return (
55
+ <NavigatorStateContext.Provider value={state}>
56
+ <NativeSurface
57
+ style={props.style}
58
+ onMeasurements={(frames) => {
59
+ const before = store.getNode(id)?.state as
60
+ | SplitNavigationState
61
+ | undefined;
62
+ if (!before) return;
63
+ const widths = Object.fromEntries(
64
+ before.visibleColumnIds
65
+ .filter((key) => frames[key]?.width > 0)
66
+ .map((key) => [key, frames[key].width]),
67
+ );
68
+ store.dispatch({
69
+ type: 'layout',
70
+ target: id,
71
+ payload: { ids: before.visibleColumnIds, widths },
72
+ });
73
+ const after = store.getNode(id)!.state as SplitNavigationState;
74
+ for (const key of before.visibleColumnIds)
75
+ if (after.widths[key] !== before.widths[key])
76
+ props.onColumnResize?.(key, after.widths[key]);
77
+ }}
78
+ onLayout={(event) => {
79
+ const { width, height } = event.nativeEvent.layout;
80
+ setSize((previous) =>
81
+ previous?.width === width && previous.height === height
82
+ ? previous
83
+ : { width, height },
84
+ );
85
+ }}
86
+ configuration={{
87
+ mode: 'split',
88
+ items: columns.map((c) => ({ key: c.id, title: c.id })),
89
+ activeKey: state.activeRouteKey,
90
+ appearance: props.appearance,
91
+ columns: state.visibleColumnIds.map((key) => {
92
+ const c = columns.find((c) => c.id === key)!;
93
+ const constraints = splitColumnConfig(state, {
94
+ ...c,
95
+ name: c.id,
96
+ });
97
+ return {
98
+ key,
99
+ width: state.widths[key],
100
+ minWidth: constraints.minWidth ?? 100,
101
+ maxWidth: constraints.maxWidth,
102
+ };
103
+ }),
104
+ }}
105
+ onRequest={(request) => {
106
+ if (
107
+ request.type !== 'resize' ||
108
+ !state.visibleColumnIds.includes(request.key)
109
+ )
110
+ return;
111
+ const before = store.getNode(id)!.state as SplitNavigationState;
112
+ store.dispatch({
113
+ type: 'resize',
114
+ target: id,
115
+ payload: { id: request.key, width: request.width },
116
+ });
117
+ const after = store.getNode(id)!.state as SplitNavigationState;
118
+ if (before.widths[request.key] !== after.widths[request.key])
119
+ props.onColumnResize?.(request.key, after.widths[request.key]);
120
+ }}
121
+ slots={state.routes.map((route) => {
122
+ const column = columns.find((c) => c.id === route.name)!;
123
+ if (!column)
124
+ throw new Error(
125
+ `Missing Column: ${route.name}. Remount to change the column list.`,
126
+ );
127
+ const visible = state.visibleColumnIds.includes(route.name);
128
+ return {
129
+ key: route.name,
130
+ visible,
131
+ content: (ready) => (
132
+ <SplitColumnContext.Provider value={null}>
133
+ <ScopeContext.Provider
134
+ value={{ nodeId: id, routeKey: route.key }}
135
+ >
136
+ <View style={[{ flex: 1 }, column.style]}>
137
+ <Scene
138
+ focusReady={ready}
139
+ nodeId={id}
140
+ route={route}
141
+ component={column.component}
142
+ visible={visible}
143
+ options={{
144
+ focusBehavior: 'none',
145
+ inactiveBehavior: 'keep',
146
+ contentStyle: column.contentStyle,
147
+ }}
148
+ />
149
+ </View>
150
+ </ScopeContext.Provider>
151
+ </SplitColumnContext.Provider>
152
+ ),
153
+ };
154
+ })}
155
+ />
156
+ </NavigatorStateContext.Provider>
157
+ );
158
+ }
159
+ return { Navigator, Column };
160
+ }
@@ -0,0 +1,157 @@
1
+ import React from 'react';
2
+ import type {
3
+ HistoryBehavior,
4
+ ParamListBase,
5
+ RouteName,
6
+ StackNavigationState,
7
+ } from '../routers';
8
+ import {
9
+ useNavigator,
10
+ readScreens,
11
+ resolveOptions,
12
+ NavigatorStateContext,
13
+ type ScreenConfig,
14
+ } from '../core/builder';
15
+ import { Scene } from '../core/Scene';
16
+ import { createNavigation } from '../core/navigation';
17
+ import type { StackNavigation, StackScreenOptions } from '../core/types';
18
+ import { StackHeader } from '../stack';
19
+ import { NativeSurface, type NativeAppearance } from './host';
20
+ import type { StyleProp, ViewStyle } from 'react-native';
21
+
22
+ /** Native stacks currently support card presentation without RN entrance animations. */
23
+ export type NativeStackScreenOptions = Omit<
24
+ StackScreenOptions,
25
+ 'animation' | 'presentation' | 'overlayStyle' | 'dialogStyle'
26
+ >;
27
+ export interface NativeStackNavigatorProps {
28
+ children: React.ReactNode;
29
+ id?: string;
30
+ initialRouteName?: string;
31
+ historyBehavior?: HistoryBehavior;
32
+ screenOptions?:
33
+ | NativeStackScreenOptions
34
+ | ((context: {
35
+ navigation: StackNavigation;
36
+ route: import('../routers').NavigationRoute;
37
+ }) => NativeStackScreenOptions);
38
+ style?: StyleProp<ViewStyle>;
39
+ appearance?: NativeAppearance;
40
+ }
41
+ export function createStackNavigator<
42
+ P extends ParamListBase = ParamListBase,
43
+ >() {
44
+ function Screen<N extends RouteName<P>>(
45
+ _props: ScreenConfig<P, N, NativeStackScreenOptions, StackNavigation<P>>,
46
+ ) {
47
+ return null;
48
+ }
49
+ function Navigator(
50
+ props: Omit<NativeStackNavigatorProps, 'initialRouteName'> & {
51
+ initialRouteName?: RouteName<P>;
52
+ },
53
+ ) {
54
+ const definitions = readScreens<NativeStackScreenOptions>(
55
+ props.children,
56
+ Screen,
57
+ );
58
+ const { id, state, store } = useNavigator<StackNavigationState>(
59
+ 'stack',
60
+ {
61
+ routes: definitions,
62
+ initialRouteName: props.initialRouteName,
63
+ historyBehavior: props.historyBehavior,
64
+ },
65
+ props.id,
66
+ );
67
+ const snapshot = React.useCallback(
68
+ () => store.canDispatch('back', id),
69
+ [store, id],
70
+ );
71
+ const canGoBack = React.useSyncExternalStore(
72
+ store.subscribe,
73
+ snapshot,
74
+ snapshot,
75
+ );
76
+ const scenes = state.routes.map((route) => {
77
+ const definition = definitions.find((d) => d.name === route.name);
78
+ if (!definition)
79
+ throw new Error(
80
+ `Missing Screen: ${route.name}. Remount to change the screen list.`,
81
+ );
82
+ const navigation = createNavigation(store, id, route.key);
83
+ const options = resolveOptions(
84
+ definition,
85
+ route,
86
+ navigation,
87
+ props.screenOptions,
88
+ );
89
+ // Any RN header customization selects the existing customizable header.
90
+ const customHeader = Object.keys(options).some(
91
+ (key) =>
92
+ key.startsWith('header') &&
93
+ !['headerShown', 'headerBackTitle'].includes(key),
94
+ );
95
+ return { route, definition, navigation, options, customHeader };
96
+ });
97
+ const active = scenes.find(
98
+ (scene) => scene.route.key === state.activeRouteKey,
99
+ )!;
100
+ return (
101
+ <NavigatorStateContext.Provider value={state}>
102
+ <NativeSurface
103
+ style={props.style}
104
+ configuration={{
105
+ mode: 'stack',
106
+ items: scenes.map((s) => ({
107
+ key: s.route.key,
108
+ title: s.options.title ?? s.route.name,
109
+ })),
110
+ activeKey: state.activeRouteKey,
111
+ appearance: props.appearance,
112
+ headerShown:
113
+ active.options.headerShown !== false && !active.customHeader,
114
+ canGoBack,
115
+ backTitle: active.options.headerBackTitle ?? 'Back',
116
+ }}
117
+ onRequest={(request) => {
118
+ if (request.type === 'back')
119
+ store.dispatch({ type: 'back', target: id });
120
+ if (request.type === 'pop' && request.count < state.routes.length)
121
+ store.dispatch({
122
+ type: 'pop',
123
+ target: id,
124
+ payload: { count: request.count },
125
+ });
126
+ }}
127
+ slots={scenes.map(
128
+ ({ route, definition, navigation, options, customHeader }) => ({
129
+ key: route.key,
130
+ visible: route.key === state.activeRouteKey,
131
+ content: (ready) => (
132
+ <Scene
133
+ focusReady={ready}
134
+ nodeId={id}
135
+ route={route}
136
+ component={definition.component}
137
+ visible={route.key === state.activeRouteKey}
138
+ options={options}
139
+ >
140
+ {customHeader && (
141
+ <StackHeader
142
+ route={route}
143
+ navigation={navigation}
144
+ options={options}
145
+ canGoBack={canGoBack}
146
+ />
147
+ )}
148
+ </Scene>
149
+ ),
150
+ }),
151
+ )}
152
+ />
153
+ </NavigatorStateContext.Provider>
154
+ );
155
+ }
156
+ return { Navigator, Screen };
157
+ }
@@ -0,0 +1,76 @@
1
+ import React from 'react';
2
+ import { Platform, TextInput, View, type ViewProps } from 'react-native';
3
+ import { macosAdapter } from './macos';
4
+ import { windowsAdapter } from './windows';
5
+ export interface KeyboardShortcut {
6
+ key: string;
7
+ metaKey?: boolean;
8
+ ctrlKey?: boolean;
9
+ altKey?: boolean;
10
+ shiftKey?: boolean;
11
+ }
12
+ export interface DesktopKeyEvent {
13
+ nativeEvent: KeyboardShortcut & {
14
+ handled?: boolean;
15
+ isTextInput?: boolean;
16
+ target?: unknown;
17
+ };
18
+ defaultPrevented?: boolean;
19
+ isPropagationStopped?: () => boolean;
20
+ preventDefault?: () => void;
21
+ stopPropagation?: () => void;
22
+ }
23
+ export interface TransitionPreset {
24
+ duration: number;
25
+ offset: number;
26
+ }
27
+ export interface NavigationPlatformAdapter {
28
+ defaultTransitions: { push: TransitionPreset; pop: TransitionPreset };
29
+ keyboardShortcuts: {
30
+ back: KeyboardShortcut[];
31
+ forward: KeyboardShortcut[];
32
+ nextTab: KeyboardShortcut[];
33
+ previousTab: KeyboardShortcut[];
34
+ };
35
+ sidebar: { defaultWidth: number };
36
+ isTextInputEvent(event: DesktopKeyEvent): boolean;
37
+ }
38
+ export const defaultPlatformAdapter =
39
+ Platform.OS === 'windows' ? windowsAdapter : macosAdapter;
40
+ export function isTextInputEvent(event: DesktopKeyEvent) {
41
+ return (
42
+ event.nativeEvent.isTextInput === true ||
43
+ TextInput.State.currentlyFocusedInput() != null
44
+ );
45
+ }
46
+ export function matchesShortcut(
47
+ event: DesktopKeyEvent,
48
+ shortcut: KeyboardShortcut,
49
+ ) {
50
+ return (
51
+ event.nativeEvent.key === shortcut.key &&
52
+ (['metaKey', 'ctrlKey', 'altKey', 'shiftKey'] as const).every(
53
+ (key) => !!event.nativeEvent[key] === !!shortcut[key],
54
+ )
55
+ );
56
+ }
57
+ export const eventHandled = (event: DesktopKeyEvent) =>
58
+ !!(
59
+ event.defaultPrevented ||
60
+ event.nativeEvent.handled ||
61
+ event.isPropagationStopped?.()
62
+ );
63
+ export function consumeKey(event: DesktopKeyEvent) {
64
+ event.preventDefault?.();
65
+ event.stopPropagation?.();
66
+ }
67
+ export interface DesktopViewProps extends ViewProps {
68
+ onKeyDown?: (event: DesktopKeyEvent) => void;
69
+ keyDownEvents?: KeyboardShortcut[];
70
+ onFocus?: () => void;
71
+ focusable?: boolean;
72
+ }
73
+ // RN desktop adds these native View props. Keep the platform type extension in one place.
74
+ export const DesktopView = View as unknown as React.ComponentType<
75
+ DesktopViewProps & React.RefAttributes<View>
76
+ >;
@@ -0,0 +1,15 @@
1
+ import { isTextInputEvent, type NavigationPlatformAdapter } from './index';
2
+ export const macosAdapter: NavigationPlatformAdapter = {
3
+ defaultTransitions: {
4
+ push: { duration: 160, offset: 12 },
5
+ pop: { duration: 120, offset: -12 },
6
+ },
7
+ keyboardShortcuts: {
8
+ back: [{ key: '[', metaKey: true }],
9
+ forward: [{ key: ']', metaKey: true }],
10
+ nextTab: [{ key: 'Tab', ctrlKey: true }],
11
+ previousTab: [{ key: 'Tab', ctrlKey: true, shiftKey: true }],
12
+ },
13
+ sidebar: { defaultWidth: 220 },
14
+ isTextInputEvent,
15
+ };
@@ -0,0 +1,15 @@
1
+ import { isTextInputEvent, type NavigationPlatformAdapter } from './index';
2
+ export const windowsAdapter: NavigationPlatformAdapter = {
3
+ defaultTransitions: {
4
+ push: { duration: 160, offset: 16 },
5
+ pop: { duration: 120, offset: -16 },
6
+ },
7
+ keyboardShortcuts: {
8
+ back: [{ key: 'ArrowLeft', altKey: true }],
9
+ forward: [{ key: 'ArrowRight', altKey: true }],
10
+ nextTab: [{ key: 'Tab', ctrlKey: true }],
11
+ previousTab: [{ key: 'Tab', ctrlKey: true, shiftKey: true }],
12
+ },
13
+ sidebar: { defaultWidth: 240 },
14
+ isTextInputEvent,
15
+ };