@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,149 @@
1
+ import React from 'react';
2
+ import {
3
+ Pressable,
4
+ Text,
5
+ View,
6
+ StyleSheet,
7
+ type ViewStyle,
8
+ type TextStyle,
9
+ } from 'react-native';
10
+ import type { NavigationRoute } from '../routers';
11
+ import type { FocusTarget } from './focus';
12
+ import type {
13
+ NavigationItemState,
14
+ NavigationItemStyle,
15
+ SidebarScreenOptions,
16
+ } from './types';
17
+ import { useNavigationTheme } from './context';
18
+
19
+ function resolveStyle<T extends ViewStyle | TextStyle>(
20
+ style: NavigationItemStyle<T> | undefined,
21
+ state: NavigationItemState,
22
+ ) {
23
+ return typeof style === 'function' ? style(state) : style;
24
+ }
25
+ export interface NavigationItemProps {
26
+ route: NavigationRoute;
27
+ options: SidebarScreenOptions;
28
+ selected: boolean;
29
+ focused: boolean;
30
+ collapsed: boolean;
31
+ sidebar: boolean;
32
+ onFocus: () => void;
33
+ onBlur: () => void;
34
+ onPress: () => void;
35
+ itemRef: (target: FocusTarget | null) => void;
36
+ }
37
+ /** The interactive shell remains owned by navigation when its content is customized. */
38
+ export function NavigationItem({
39
+ route,
40
+ options,
41
+ selected,
42
+ focused,
43
+ collapsed,
44
+ sidebar,
45
+ onFocus,
46
+ onBlur,
47
+ onPress,
48
+ itemRef,
49
+ }: NavigationItemProps) {
50
+ const { colors } = useNavigationTheme();
51
+ const [pressed, setPressed] = React.useState(false);
52
+ const [hovered, setHovered] = React.useState(false);
53
+ const state: NavigationItemState = {
54
+ selected,
55
+ focused,
56
+ collapsed,
57
+ hovered,
58
+ pressed: pressed && !options.disabled,
59
+ disabled: !!options.disabled,
60
+ };
61
+ const label = options.label ?? options.title ?? route.name;
62
+ const content = (
63
+ <>
64
+ {options.icon != null && (
65
+ <View style={resolveStyle(options.iconContainerStyle, state)}>
66
+ {typeof options.icon === 'function'
67
+ ? options.icon({ focused: selected, disabled: state.disabled })
68
+ : options.icon}
69
+ </View>
70
+ )}
71
+ {(!collapsed || !options.icon) && (
72
+ <Text
73
+ numberOfLines={1}
74
+ style={[
75
+ { color: colors.text, flexShrink: 1 },
76
+ resolveStyle(options.labelStyle, state),
77
+ ]}
78
+ >
79
+ {collapsed ? label.slice(0, 1) : label}
80
+ </Text>
81
+ )}
82
+ {!collapsed &&
83
+ options.badge != null &&
84
+ (typeof options.badge === 'string' ||
85
+ typeof options.badge === 'number' ? (
86
+ <Text
87
+ style={[
88
+ { color: colors.mutedText },
89
+ resolveStyle(options.badgeStyle, state),
90
+ ]}
91
+ >
92
+ {options.badge}
93
+ </Text>
94
+ ) : (
95
+ options.badge
96
+ ))}
97
+ </>
98
+ );
99
+ return (
100
+ <Pressable
101
+ ref={(target) => itemRef(target as unknown as FocusTarget | null)}
102
+ accessible
103
+ focusable={!options.disabled}
104
+ disabled={options.disabled}
105
+ accessibilityRole={sidebar ? 'menuitem' : 'tab'}
106
+ accessibilityLabel={label}
107
+ accessibilityState={{ selected, disabled: state.disabled }}
108
+ onFocus={onFocus}
109
+ onBlur={() => {
110
+ setPressed(false);
111
+ onBlur();
112
+ }}
113
+ onPress={onPress}
114
+ onHoverIn={() => setHovered(true)}
115
+ onHoverOut={() => setHovered(false)}
116
+ onPressIn={() => setPressed(true)}
117
+ onPressOut={() => setPressed(false)}
118
+ style={[
119
+ navigationItemStyle,
120
+ {
121
+ opacity: state.disabled ? 0.45 : 1,
122
+ backgroundColor: selected ? colors.selectedBackground : 'transparent',
123
+ borderColor: focused ? colors.accent : 'transparent',
124
+ },
125
+ resolveStyle(options.itemStyle, state),
126
+ ]}
127
+ >
128
+ {options.renderItemContent
129
+ ? options.renderItemContent({
130
+ ...state,
131
+ route,
132
+ label,
133
+ children: content,
134
+ })
135
+ : content}
136
+ </Pressable>
137
+ );
138
+ }
139
+ export const navigationItemStyle = StyleSheet.create({
140
+ item: {
141
+ flexDirection: 'row',
142
+ alignItems: 'center',
143
+ gap: 8,
144
+ padding: 10,
145
+ margin: 2,
146
+ borderRadius: 4,
147
+ borderWidth: 1,
148
+ },
149
+ }).item;
@@ -0,0 +1,194 @@
1
+ import React from 'react';
2
+ import {
3
+ Animated,
4
+ StyleSheet,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ } from 'react-native';
8
+ import type { NavigationRoute } from '../routers';
9
+ import { DesktopView } from '../platform';
10
+ import {
11
+ PlatformContext,
12
+ ScopeContext,
13
+ ScreenContext,
14
+ useStore,
15
+ useNavigationTheme,
16
+ } from './context';
17
+ import { createNavigation } from './navigation';
18
+ import { FocusContext, type FocusRegistry, type FocusTarget } from './focus';
19
+ import type { CommonScreenOptions, StackAnimation } from './types';
20
+ export interface SceneProps {
21
+ nodeId: string;
22
+ route: NavigationRoute;
23
+ component: React.ComponentType<any>;
24
+ visible: boolean;
25
+ /** Native content slots must be laid out before restoring keyboard focus. */
26
+ focusReady?: boolean;
27
+ options: CommonScreenOptions;
28
+ animation?: StackAnimation;
29
+ overlay?: 'modal' | 'dialog';
30
+ overlayStyle?: StyleProp<ViewStyle>;
31
+ dialogStyle?: StyleProp<ViewStyle>;
32
+ children?: React.ReactNode;
33
+ }
34
+ export function Scene({
35
+ nodeId,
36
+ route,
37
+ component: Component,
38
+ visible,
39
+ focusReady = true,
40
+ options,
41
+ animation = 'none',
42
+ overlay,
43
+ overlayStyle,
44
+ dialogStyle,
45
+ children,
46
+ }: SceneProps) {
47
+ const store = useStore();
48
+ const { colors } = useNavigationTheme();
49
+ const platform = React.useContext(PlatformContext);
50
+ const snapshot = React.useCallback(
51
+ () => store.isRouteFocused(nodeId, route.key),
52
+ [store, nodeId, route.key],
53
+ );
54
+ const focused = React.useSyncExternalStore(
55
+ store.subscribe,
56
+ snapshot,
57
+ snapshot,
58
+ );
59
+ const navigation = React.useMemo(
60
+ () => createNavigation(store, nodeId, route.key),
61
+ [store, nodeId, route.key],
62
+ );
63
+ const targets = React.useRef(new Map<string, FocusTarget>());
64
+ const last = React.useRef<string | undefined>(undefined);
65
+ const registry = React.useMemo<FocusRegistry>(
66
+ () => ({
67
+ register(id, target) {
68
+ targets.current.set(id, target);
69
+ return () => {
70
+ targets.current.delete(id);
71
+ };
72
+ },
73
+ record(id) {
74
+ last.current = id;
75
+ store.setFocusedNode(nodeId);
76
+ },
77
+ }),
78
+ [store, nodeId],
79
+ );
80
+ const mounted = visible || options.inactiveBehavior !== 'unmount';
81
+ React.useEffect(() => {
82
+ if (!focused || !visible || !focusReady || options.focusBehavior === 'none')
83
+ return;
84
+ const previous =
85
+ options.focusBehavior !== 'first' && last.current
86
+ ? targets.current.get(last.current)
87
+ : undefined;
88
+ (previous ?? targets.current.values().next().value)?.focus();
89
+ }, [focused, visible, focusReady, options.focusBehavior, mounted]);
90
+ const [progress] = React.useState(() => new Animated.Value(1));
91
+ React.useEffect(() => {
92
+ if (!visible || animation === 'none') {
93
+ progress.setValue(1);
94
+ return;
95
+ }
96
+ progress.setValue(0);
97
+ const transition = Animated.timing(progress, {
98
+ toValue: 1,
99
+ duration: platform.defaultTransitions.push.duration,
100
+ // react-native-macos's Fabric/bridgeless host does not reliably
101
+ // connect native-driven Animated values, leaving views stuck at
102
+ // their initial (invisible) frame. Drive this on the JS thread
103
+ // instead so the entrance transition actually completes.
104
+ useNativeDriver: false,
105
+ });
106
+ transition.start();
107
+ return () => transition.stop();
108
+ }, [visible, animation, progress, platform]);
109
+ if (!mounted) return null;
110
+ const offset = platform.defaultTransitions.push.offset;
111
+ const animatedStyle =
112
+ animation === 'none'
113
+ ? undefined
114
+ : {
115
+ opacity: progress,
116
+ transform:
117
+ animation === 'fade'
118
+ ? []
119
+ : [
120
+ {
121
+ [animation === 'slide-vertical'
122
+ ? 'translateY'
123
+ : 'translateX']: progress.interpolate({
124
+ inputRange: [0, 1],
125
+ outputRange: [offset, 0],
126
+ }),
127
+ },
128
+ ],
129
+ };
130
+ return (
131
+ <ScopeContext.Provider value={{ nodeId, routeKey: route.key }}>
132
+ <ScreenContext.Provider value={{ navigation, route, focused }}>
133
+ <FocusContext.Provider value={registry}>
134
+ <DesktopView
135
+ testID={`scene-${route.name}`}
136
+ onFocus={() => store.focusRoute(nodeId, route.key)}
137
+ pointerEvents={visible ? 'auto' : 'none'}
138
+ accessibilityElementsHidden={!visible}
139
+ importantForAccessibility={visible ? 'auto' : 'no-hide-descendants'}
140
+ accessibilityViewIsModal={!!overlay && visible}
141
+ style={[
142
+ styles.scene,
143
+ options.sceneStyle,
144
+ overlay && styles.overlay,
145
+ overlay === 'dialog' && styles.dialogBackdrop,
146
+ overlay && overlayStyle,
147
+ !visible && styles.hidden,
148
+ ]}
149
+ >
150
+ <Animated.View
151
+ style={[
152
+ styles.content,
153
+ overlay === 'dialog' && [
154
+ styles.dialog,
155
+ { backgroundColor: colors.surface },
156
+ ],
157
+ options.contentStyle,
158
+ overlay === 'dialog' && dialogStyle,
159
+ animatedStyle as never,
160
+ ]}
161
+ >
162
+ {children}
163
+ <Component navigation={navigation} route={route} />
164
+ </Animated.View>
165
+ </DesktopView>
166
+ </FocusContext.Provider>
167
+ </ScreenContext.Provider>
168
+ </ScopeContext.Provider>
169
+ );
170
+ }
171
+ const styles = StyleSheet.create({
172
+ scene: { flex: 1 },
173
+ content: { flex: 1 },
174
+ hidden: { display: 'none' },
175
+ overlay: {
176
+ ...StyleSheet.absoluteFillObject,
177
+ backgroundColor: 'rgba(0,0,0,0.25)',
178
+ zIndex: 1,
179
+ },
180
+ dialogBackdrop: {
181
+ alignItems: 'center',
182
+ justifyContent: 'center',
183
+ padding: 32,
184
+ },
185
+ dialog: {
186
+ flex: 0,
187
+ width: '80%',
188
+ maxWidth: 640,
189
+ maxHeight: '90%',
190
+ minHeight: 160,
191
+ borderRadius: 8,
192
+ overflow: 'hidden',
193
+ },
194
+ });
@@ -0,0 +1,371 @@
1
+ import React from 'react';
2
+ import {
3
+ Pressable,
4
+ ScrollView,
5
+ Text,
6
+ View,
7
+ StyleSheet,
8
+ type StyleProp,
9
+ type ViewStyle,
10
+ type TextStyle,
11
+ } from 'react-native';
12
+ import { type SelectionNavigationState } from '../routers';
13
+ import {
14
+ useNavigator,
15
+ resolveOptions,
16
+ NavigatorStateContext,
17
+ type Definition,
18
+ } from './builder';
19
+ import { NavigationItem, navigationItemStyle } from './NavigationItem';
20
+ import { Scene } from './Scene';
21
+ import { createNavigation } from './navigation';
22
+ import {
23
+ PlatformContext,
24
+ ScopeContext,
25
+ SplitColumnContext,
26
+ useNavigationTheme,
27
+ } from './context';
28
+ import type { SidebarScreenOptions, SidebarFooterProps } from './types';
29
+ import {
30
+ consumeKey,
31
+ DesktopView,
32
+ eventHandled,
33
+ matchesShortcut,
34
+ } from '../platform';
35
+ import type { FocusTarget } from './focus';
36
+ export interface SelectionNavigatorProps {
37
+ id?: string;
38
+ initialRouteName?: string;
39
+ screenOptions?: SidebarScreenOptions;
40
+ style?: StyleProp<ViewStyle>;
41
+ barStyle?: StyleProp<ViewStyle>;
42
+ barContentStyle?: StyleProp<ViewStyle>;
43
+ contentStyle?: StyleProp<ViewStyle>;
44
+ sectionStyle?: StyleProp<ViewStyle>;
45
+ sectionTitleStyle?: StyleProp<TextStyle>;
46
+ /** Hide the default button independently of programmatic collapse actions. */
47
+ collapseButtonShown?: boolean;
48
+ sidebarFooterStyle?: StyleProp<ViewStyle>;
49
+ /** Replace the entire footer. Return null to remove it without reserving space. */
50
+ renderSidebarFooter?: (props: SidebarFooterProps) => React.ReactNode;
51
+ collapseButtonStyle?: StyleProp<ViewStyle>;
52
+ collapseLabelStyle?: StyleProp<TextStyle>;
53
+ renderCollapseButtonContent?: (props: {
54
+ collapsed: boolean;
55
+ children: React.ReactNode;
56
+ }) => React.ReactNode;
57
+ position?: 'left' | 'right';
58
+ /** Use 'fill' to follow the width of a containing Split.Column. */
59
+ width?: number | 'fill';
60
+ minWidth?: number;
61
+ maxWidth?: number;
62
+ collapsible?: boolean;
63
+ collapsedWidth?: number;
64
+ defaultCollapsed?: boolean;
65
+ }
66
+ export function SelectionNavigator({
67
+ type,
68
+ definitions,
69
+ ...props
70
+ }: SelectionNavigatorProps & {
71
+ type: 'sidebar' | 'tabs';
72
+ definitions: Definition<SidebarScreenOptions>[];
73
+ }) {
74
+ const platform = React.useContext(PlatformContext);
75
+ const { colors } = useNavigationTheme();
76
+ const configs = definitions.map((d) => ({
77
+ ...d,
78
+ ...props.screenOptions,
79
+ ...(typeof d.options === 'object' ? d.options : {}),
80
+ }));
81
+ const { id, state, store, navigation } =
82
+ useNavigator<SelectionNavigationState>(
83
+ type,
84
+ {
85
+ routes: configs,
86
+ initialRouteName: props.initialRouteName,
87
+ defaultCollapsed: props.defaultCollapsed,
88
+ },
89
+ props.id,
90
+ );
91
+ const items = state.routes.map((route) => {
92
+ const definition = definitions.find((d) => d.name === route.name);
93
+ if (!definition)
94
+ throw new Error(
95
+ `Missing Screen: ${route.name}. Remount to change the screen list.`,
96
+ );
97
+ return {
98
+ route,
99
+ definition,
100
+ options: resolveOptions(
101
+ definition,
102
+ route,
103
+ createNavigation(store, id, route.key),
104
+ props.screenOptions,
105
+ ),
106
+ };
107
+ });
108
+ React.useLayoutEffect(() => {
109
+ store.updateOptions(id, {
110
+ key: id,
111
+ routes: items.map((i) => ({ name: i.route.name, ...i.options })),
112
+ });
113
+ const current = items.find((i) => i.route.key === state.activeRouteKey);
114
+ if (current?.options.hidden || current?.options.disabled) {
115
+ const next = items.find((i) => !i.options.hidden && !i.options.disabled);
116
+ if (next) navigation.select(next.route.name);
117
+ }
118
+ });
119
+ const visibleItems = items.filter((i) => !i.options.hidden);
120
+ const enabled = visibleItems.filter((i) => !i.options.disabled);
121
+ const [focusedKey, setFocusedKey] = React.useState('');
122
+ const refs = React.useRef(new Map<string, FocusTarget>());
123
+ const sidebar = type === 'sidebar';
124
+ const collapsed = sidebar && state.collapsed;
125
+ const splitColumn = React.useContext(SplitColumnContext);
126
+ const scope = React.useContext(ScopeContext);
127
+ const syncColumn =
128
+ sidebar && splitColumn && scope.nodeId === splitColumn.nodeId
129
+ ? splitColumn.onSidebarChange
130
+ : undefined;
131
+ const columnId = splitColumn?.columnId;
132
+ const collapsedWidth = props.collapsedWidth ?? 56;
133
+ React.useLayoutEffect(() => {
134
+ if (syncColumn && columnId)
135
+ syncColumn(columnId, collapsed ? collapsedWidth : null);
136
+ }, [syncColumn, columnId, collapsed, collapsedWidth]);
137
+ React.useLayoutEffect(
138
+ () => () => {
139
+ if (syncColumn && columnId) syncColumn(columnId, null);
140
+ },
141
+ [syncColumn, columnId],
142
+ );
143
+ const focusItem = (index: number) => {
144
+ const item = enabled[(index + enabled.length) % enabled.length];
145
+ if (!item) return;
146
+ setFocusedKey(item.route.key);
147
+ refs.current.get(item.route.key)?.focus();
148
+ };
149
+ const bindings = sidebar
150
+ ? ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter'].map((key) => ({ key }))
151
+ : [
152
+ ...['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter'].map((key) => ({
153
+ key,
154
+ })),
155
+ ...platform.keyboardShortcuts.nextTab,
156
+ ...platform.keyboardShortcuts.previousTab,
157
+ ];
158
+ const collapseContent = (
159
+ <Text style={[{ color: colors.text }, props.collapseLabelStyle]}>
160
+ {collapsed ? '»' : '«'}
161
+ </Text>
162
+ );
163
+ const collapseButton =
164
+ sidebar &&
165
+ props.collapsible !== false &&
166
+ props.collapseButtonShown !== false ? (
167
+ <Pressable
168
+ accessible
169
+ focusable
170
+ accessibilityRole="button"
171
+ accessibilityLabel={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
172
+ onPress={() => navigation.toggleSidebar()}
173
+ style={[navigationItemStyle, props.collapseButtonStyle]}
174
+ >
175
+ {props.renderCollapseButtonContent
176
+ ? props.renderCollapseButtonContent({
177
+ collapsed,
178
+ children: collapseContent,
179
+ })
180
+ : collapseContent}
181
+ </Pressable>
182
+ ) : null;
183
+ const footer =
184
+ sidebar && props.renderSidebarFooter
185
+ ? props.renderSidebarFooter({
186
+ collapsed,
187
+ collapseSidebar: navigation.collapseSidebar,
188
+ expandSidebar: navigation.expandSidebar,
189
+ toggleSidebar: navigation.toggleSidebar,
190
+ children: collapseButton,
191
+ })
192
+ : collapseButton;
193
+ const rail = (
194
+ <DesktopView
195
+ accessibilityRole={sidebar ? 'menu' : 'tablist'}
196
+ keyDownEvents={bindings}
197
+ focusable
198
+ onKeyDown={(event) => {
199
+ if (
200
+ eventHandled(event) ||
201
+ platform.isTextInputEvent(event) ||
202
+ !bindings.some((key) => matchesShortcut(event, key))
203
+ )
204
+ return;
205
+ store.setFocusedNode(id);
206
+ const index = enabled.findIndex(
207
+ (i) => i.route.key === (focusedKey || state.activeRouteKey),
208
+ );
209
+ const key = event.nativeEvent.key;
210
+ if (
211
+ !sidebar &&
212
+ platform.keyboardShortcuts.nextTab.some((k) =>
213
+ matchesShortcut(event, k),
214
+ )
215
+ )
216
+ navigation.nextTab();
217
+ else if (
218
+ !sidebar &&
219
+ platform.keyboardShortcuts.previousTab.some((k) =>
220
+ matchesShortcut(event, k),
221
+ )
222
+ )
223
+ navigation.previousTab();
224
+ else if (key === 'ArrowUp' || key === 'ArrowLeft') focusItem(index - 1);
225
+ else if (key === 'ArrowDown' || key === 'ArrowRight')
226
+ focusItem(index + 1);
227
+ else if (key === 'Home') focusItem(0);
228
+ else if (key === 'End') focusItem(enabled.length - 1);
229
+ else if (key === 'Enter') {
230
+ const item = enabled.find(
231
+ (i) => i.route.key === (focusedKey || state.activeRouteKey),
232
+ );
233
+ if (item) navigation.select(item.route.name);
234
+ }
235
+ consumeKey(event);
236
+ }}
237
+ style={[
238
+ { backgroundColor: colors.surface, borderColor: colors.border },
239
+ sidebar
240
+ ? {
241
+ width: collapsed
242
+ ? (props.collapsedWidth ?? 56)
243
+ : props.width === 'fill'
244
+ ? '100%'
245
+ : Math.min(
246
+ props.maxWidth ?? Infinity,
247
+ Math.max(
248
+ props.minWidth ?? 120,
249
+ props.width ?? platform.sidebar.defaultWidth,
250
+ ),
251
+ ),
252
+ maxWidth: '100%',
253
+ ...(props.position === 'right'
254
+ ? { borderLeftWidth: StyleSheet.hairlineWidth }
255
+ : { borderRightWidth: StyleSheet.hairlineWidth }),
256
+ }
257
+ : { borderBottomWidth: StyleSheet.hairlineWidth },
258
+ props.barStyle,
259
+ ]}
260
+ >
261
+ <ScrollView
262
+ horizontal={!sidebar}
263
+ contentContainerStyle={[
264
+ !sidebar && styles.tabItems,
265
+ props.barContentStyle,
266
+ ]}
267
+ >
268
+ {visibleItems.map((item, index) => {
269
+ const { route, options, definition } = item;
270
+ const selected = route.key === state.activeRouteKey;
271
+ const section = definition.section;
272
+
273
+ return (
274
+ <React.Fragment key={route.key}>
275
+ {sidebar &&
276
+ !collapsed &&
277
+ section?.title &&
278
+ visibleItems[index - 1]?.definition.section?.key !==
279
+ section.key && (
280
+ <View style={[props.sectionStyle, section.style]}>
281
+ {section.renderTitle ? (
282
+ section.renderTitle({ title: section.title })
283
+ ) : (
284
+ <Text
285
+ accessibilityRole="header"
286
+ style={[
287
+ styles.section,
288
+ { color: colors.mutedText },
289
+ props.sectionTitleStyle,
290
+ section.titleStyle,
291
+ ]}
292
+ >
293
+ {section.title}
294
+ </Text>
295
+ )}
296
+ </View>
297
+ )}
298
+ <NavigationItem
299
+ route={route}
300
+ options={options}
301
+ selected={selected}
302
+ focused={focusedKey === route.key}
303
+ collapsed={collapsed}
304
+ sidebar={sidebar}
305
+ itemRef={(value) => {
306
+ if (value) refs.current.set(route.key, value);
307
+ else refs.current.delete(route.key);
308
+ }}
309
+ onFocus={() => {
310
+ setFocusedKey(route.key);
311
+ store.setFocusedNode(id);
312
+ }}
313
+ onBlur={() =>
314
+ setFocusedKey((current) =>
315
+ current === route.key ? '' : current,
316
+ )
317
+ }
318
+ onPress={() => {
319
+ store.setFocusedNode(id);
320
+ navigation.select(route.name);
321
+ }}
322
+ />
323
+ </React.Fragment>
324
+ );
325
+ })}
326
+ </ScrollView>
327
+ {sidebar && footer != null && footer !== false && (
328
+ <View testID="sidebar-footer" style={props.sidebarFooterStyle}>
329
+ {footer}
330
+ </View>
331
+ )}
332
+ </DesktopView>
333
+ );
334
+ return (
335
+ <NavigatorStateContext.Provider value={state}>
336
+ <View
337
+ style={[
338
+ styles.container,
339
+ sidebar && {
340
+ flexDirection: props.position === 'right' ? 'row-reverse' : 'row',
341
+ },
342
+ props.style,
343
+ ]}
344
+ >
345
+ {rail}
346
+ <View style={[styles.content, props.contentStyle]}>
347
+ {items.map(({ route, definition, options }) => (
348
+ <Scene
349
+ key={route.key}
350
+ nodeId={id}
351
+ route={route}
352
+ component={definition.component}
353
+ visible={
354
+ route.key === state.activeRouteKey &&
355
+ !options.hidden &&
356
+ !options.disabled
357
+ }
358
+ options={options}
359
+ />
360
+ ))}
361
+ </View>
362
+ </View>
363
+ </NavigatorStateContext.Provider>
364
+ );
365
+ }
366
+ const styles = StyleSheet.create({
367
+ container: { flex: 1 },
368
+ content: { flex: 1, minWidth: 0 },
369
+ tabItems: { flexDirection: 'row' },
370
+ section: { padding: 10, fontSize: 12, fontWeight: '600' },
371
+ });