@terreno/ui 0.8.3 → 0.9.0
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/dist/Common.d.ts +106 -0
- package/dist/Common.js +10 -0
- package/dist/Common.js.map +1 -1
- package/dist/DateTimeField.js +2 -2
- package/dist/DateTimeField.js.map +1 -1
- package/dist/SidebarNavigation.d.ts +15 -0
- package/dist/SidebarNavigation.js +143 -0
- package/dist/SidebarNavigation.js.map +1 -0
- package/dist/SidebarNavigation.native.d.ts +22 -0
- package/dist/SidebarNavigation.native.js +171 -0
- package/dist/SidebarNavigation.native.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/Common.ts +111 -0
- package/src/DateTimeField.tsx +2 -2
- package/src/SidebarNavigation.native.tsx +331 -0
- package/src/SidebarNavigation.tsx +291 -0
- package/src/__snapshots__/Field.test.tsx.snap +13 -13
- package/src/index.tsx +1 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import {TabRouter} from "@react-navigation/native";
|
|
2
|
+
import {Navigator, Slot} from "expo-router";
|
|
3
|
+
import {type FC, useCallback, useEffect, useRef, useState} from "react";
|
|
4
|
+
import {Animated, Dimensions, Pressable, type StyleProp, View, type ViewStyle} from "react-native";
|
|
5
|
+
|
|
6
|
+
import {Badge} from "./Badge";
|
|
7
|
+
import type {
|
|
8
|
+
SidebarNavigationItem,
|
|
9
|
+
SidebarNavigationPanelProps,
|
|
10
|
+
SidebarNavigationProps,
|
|
11
|
+
} from "./Common";
|
|
12
|
+
import {SIDEBAR_BADGE_STATUS_MAP} from "./Common";
|
|
13
|
+
import {Icon} from "./Icon";
|
|
14
|
+
import {Text} from "./Text";
|
|
15
|
+
import {useTheme} from "./Theme";
|
|
16
|
+
|
|
17
|
+
const DRAWER_WIDTH = 280;
|
|
18
|
+
const ITEM_HEIGHT = 48;
|
|
19
|
+
const ICON_SIZE = 20;
|
|
20
|
+
const BACKDROP_OPACITY = 0.5;
|
|
21
|
+
const ANIMATION_DURATION = 250;
|
|
22
|
+
|
|
23
|
+
const SidebarItem: FC<{
|
|
24
|
+
item: SidebarNavigationItem;
|
|
25
|
+
isActive: boolean;
|
|
26
|
+
onPress: (route: string) => void;
|
|
27
|
+
itemStyle?: StyleProp<ViewStyle>;
|
|
28
|
+
}> = ({item, isActive, onPress, itemStyle}) => {
|
|
29
|
+
const {theme} = useTheme();
|
|
30
|
+
|
|
31
|
+
const handlePress = useCallback(() => {
|
|
32
|
+
onPress(item.route);
|
|
33
|
+
}, [onPress, item.route]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Pressable
|
|
37
|
+
accessibilityLabel={item.label}
|
|
38
|
+
accessibilityRole="button"
|
|
39
|
+
onPress={handlePress}
|
|
40
|
+
style={[
|
|
41
|
+
{
|
|
42
|
+
alignItems: "center",
|
|
43
|
+
backgroundColor: isActive ? theme.surface.secondaryLight : "transparent",
|
|
44
|
+
borderRadius: theme.radius.default,
|
|
45
|
+
flexDirection: "row",
|
|
46
|
+
gap: 14,
|
|
47
|
+
height: ITEM_HEIGHT,
|
|
48
|
+
marginHorizontal: 12,
|
|
49
|
+
paddingHorizontal: 14,
|
|
50
|
+
},
|
|
51
|
+
itemStyle,
|
|
52
|
+
]}
|
|
53
|
+
>
|
|
54
|
+
<View style={{alignItems: "center", justifyContent: "center", width: ICON_SIZE}}>
|
|
55
|
+
<Icon color={isActive ? "primary" : "secondaryDark"} iconName={item.iconName} size="md" />
|
|
56
|
+
{Boolean(item.badge) && (
|
|
57
|
+
<View
|
|
58
|
+
style={{
|
|
59
|
+
bottom: item.badge === true ? -4 : undefined,
|
|
60
|
+
position: "absolute",
|
|
61
|
+
right: -6,
|
|
62
|
+
top: item.badge === true ? undefined : -4,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<Badge
|
|
66
|
+
maxValue={99}
|
|
67
|
+
status={SIDEBAR_BADGE_STATUS_MAP[item.badgeStatus ?? "error"]}
|
|
68
|
+
value={item.badge === true ? undefined : String(item.badge)}
|
|
69
|
+
variant={item.badge === true ? "iconOnly" : "numberOnly"}
|
|
70
|
+
/>
|
|
71
|
+
</View>
|
|
72
|
+
)}
|
|
73
|
+
</View>
|
|
74
|
+
<Text bold={isActive} color={isActive ? "primary" : "secondaryDark"} size="md">
|
|
75
|
+
{item.label}
|
|
76
|
+
</Text>
|
|
77
|
+
</Pressable>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Renders the hamburger button, drawer overlay, and children. Works without expo-router Navigator context.
|
|
83
|
+
*/
|
|
84
|
+
export const SidebarNavigationPanel: FC<SidebarNavigationPanelProps> = ({
|
|
85
|
+
topItems,
|
|
86
|
+
bottomItems,
|
|
87
|
+
activeRoute,
|
|
88
|
+
onNavigate,
|
|
89
|
+
children,
|
|
90
|
+
panelStyle,
|
|
91
|
+
itemStyle,
|
|
92
|
+
}) => {
|
|
93
|
+
const {theme} = useTheme();
|
|
94
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
95
|
+
const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
|
96
|
+
const backdropAnim = useRef(new Animated.Value(0)).current;
|
|
97
|
+
|
|
98
|
+
// Animate drawer open/close
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (isOpen) {
|
|
101
|
+
Animated.parallel([
|
|
102
|
+
Animated.timing(slideAnim, {
|
|
103
|
+
duration: ANIMATION_DURATION,
|
|
104
|
+
toValue: 0,
|
|
105
|
+
useNativeDriver: true,
|
|
106
|
+
}),
|
|
107
|
+
Animated.timing(backdropAnim, {
|
|
108
|
+
duration: ANIMATION_DURATION,
|
|
109
|
+
toValue: BACKDROP_OPACITY,
|
|
110
|
+
useNativeDriver: true,
|
|
111
|
+
}),
|
|
112
|
+
]).start();
|
|
113
|
+
} else {
|
|
114
|
+
Animated.parallel([
|
|
115
|
+
Animated.timing(slideAnim, {
|
|
116
|
+
duration: ANIMATION_DURATION,
|
|
117
|
+
toValue: -DRAWER_WIDTH,
|
|
118
|
+
useNativeDriver: true,
|
|
119
|
+
}),
|
|
120
|
+
Animated.timing(backdropAnim, {
|
|
121
|
+
duration: ANIMATION_DURATION,
|
|
122
|
+
toValue: 0,
|
|
123
|
+
useNativeDriver: true,
|
|
124
|
+
}),
|
|
125
|
+
]).start();
|
|
126
|
+
}
|
|
127
|
+
}, [isOpen, slideAnim, backdropAnim]);
|
|
128
|
+
|
|
129
|
+
const handleOpen = useCallback(() => setIsOpen(true), []);
|
|
130
|
+
const handleClose = useCallback(() => setIsOpen(false), []);
|
|
131
|
+
|
|
132
|
+
const handleNavigate = useCallback(
|
|
133
|
+
(route: string) => {
|
|
134
|
+
setIsOpen(false);
|
|
135
|
+
onNavigate(route);
|
|
136
|
+
},
|
|
137
|
+
[onNavigate]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const screenHeight = Dimensions.get("window").height;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<View style={{flex: 1}}>
|
|
144
|
+
{children}
|
|
145
|
+
|
|
146
|
+
{/* Hamburger button */}
|
|
147
|
+
<Pressable
|
|
148
|
+
accessibilityLabel="Open navigation menu"
|
|
149
|
+
accessibilityRole="button"
|
|
150
|
+
onPress={handleOpen}
|
|
151
|
+
style={{
|
|
152
|
+
alignItems: "center",
|
|
153
|
+
backgroundColor: theme.surface.primary,
|
|
154
|
+
borderRadius: theme.radius.full,
|
|
155
|
+
elevation: 4,
|
|
156
|
+
height: 44,
|
|
157
|
+
justifyContent: "center",
|
|
158
|
+
left: 16,
|
|
159
|
+
position: "absolute",
|
|
160
|
+
shadowColor: "#000",
|
|
161
|
+
shadowOffset: {height: 2, width: 0},
|
|
162
|
+
shadowOpacity: 0.25,
|
|
163
|
+
shadowRadius: 4,
|
|
164
|
+
top: 16,
|
|
165
|
+
width: 44,
|
|
166
|
+
zIndex: 10,
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<Icon color="inverted" iconName="bars" size="md" />
|
|
170
|
+
</Pressable>
|
|
171
|
+
|
|
172
|
+
{/* Backdrop */}
|
|
173
|
+
{isOpen && (
|
|
174
|
+
<Pressable
|
|
175
|
+
onPress={handleClose}
|
|
176
|
+
style={{
|
|
177
|
+
bottom: 0,
|
|
178
|
+
left: 0,
|
|
179
|
+
position: "absolute",
|
|
180
|
+
right: 0,
|
|
181
|
+
top: 0,
|
|
182
|
+
zIndex: 100,
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<Animated.View
|
|
186
|
+
style={{
|
|
187
|
+
backgroundColor: "#000",
|
|
188
|
+
flex: 1,
|
|
189
|
+
opacity: backdropAnim,
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
</Pressable>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Drawer */}
|
|
196
|
+
<Animated.View
|
|
197
|
+
style={[
|
|
198
|
+
{
|
|
199
|
+
backgroundColor: theme.surface.base,
|
|
200
|
+
borderColor: theme.border.default,
|
|
201
|
+
borderRightWidth: 1,
|
|
202
|
+
height: screenHeight,
|
|
203
|
+
justifyContent: "space-between",
|
|
204
|
+
left: 0,
|
|
205
|
+
paddingBottom: 32,
|
|
206
|
+
paddingTop: 20,
|
|
207
|
+
position: "absolute",
|
|
208
|
+
top: 0,
|
|
209
|
+
transform: [{translateX: slideAnim}],
|
|
210
|
+
width: DRAWER_WIDTH,
|
|
211
|
+
zIndex: 200,
|
|
212
|
+
},
|
|
213
|
+
panelStyle,
|
|
214
|
+
]}
|
|
215
|
+
>
|
|
216
|
+
{/* Close button */}
|
|
217
|
+
<View>
|
|
218
|
+
<Pressable
|
|
219
|
+
accessibilityLabel="Close navigation menu"
|
|
220
|
+
accessibilityRole="button"
|
|
221
|
+
onPress={handleClose}
|
|
222
|
+
style={{
|
|
223
|
+
alignItems: "center",
|
|
224
|
+
alignSelf: "flex-end",
|
|
225
|
+
height: 40,
|
|
226
|
+
justifyContent: "center",
|
|
227
|
+
marginRight: 12,
|
|
228
|
+
width: 40,
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
<Icon color="secondaryDark" iconName="xmark" size="md" />
|
|
232
|
+
</Pressable>
|
|
233
|
+
<View style={{gap: 4, marginTop: 8}}>
|
|
234
|
+
{topItems.map((item) => (
|
|
235
|
+
<SidebarItem
|
|
236
|
+
isActive={activeRoute === item.route}
|
|
237
|
+
item={item}
|
|
238
|
+
itemStyle={itemStyle}
|
|
239
|
+
key={item.route}
|
|
240
|
+
onPress={handleNavigate}
|
|
241
|
+
/>
|
|
242
|
+
))}
|
|
243
|
+
</View>
|
|
244
|
+
</View>
|
|
245
|
+
|
|
246
|
+
<View style={{gap: 4}}>
|
|
247
|
+
{bottomItems.map((item) => (
|
|
248
|
+
<SidebarItem
|
|
249
|
+
isActive={activeRoute === item.route}
|
|
250
|
+
item={item}
|
|
251
|
+
key={item.route}
|
|
252
|
+
onPress={handleNavigate}
|
|
253
|
+
/>
|
|
254
|
+
))}
|
|
255
|
+
</View>
|
|
256
|
+
</Animated.View>
|
|
257
|
+
</View>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reads active route from Navigator context and renders the drawer + Slot.
|
|
263
|
+
*/
|
|
264
|
+
const SidebarNavigatorContent: FC<{
|
|
265
|
+
topItems: SidebarNavigationItem[];
|
|
266
|
+
bottomItems: SidebarNavigationItem[];
|
|
267
|
+
onNavigate?: (route: string) => void;
|
|
268
|
+
panelStyle?: StyleProp<ViewStyle>;
|
|
269
|
+
itemStyle?: StyleProp<ViewStyle>;
|
|
270
|
+
}> = ({topItems, bottomItems, onNavigate, panelStyle, itemStyle}) => {
|
|
271
|
+
const {state, navigation} = Navigator.useContext();
|
|
272
|
+
const activeRoute = state.routes[state.index]?.name;
|
|
273
|
+
|
|
274
|
+
const handleNavigate = useCallback(
|
|
275
|
+
(route: string) => {
|
|
276
|
+
navigation.navigate(route);
|
|
277
|
+
onNavigate?.(route);
|
|
278
|
+
},
|
|
279
|
+
[navigation, onNavigate]
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<SidebarNavigationPanel
|
|
284
|
+
activeRoute={activeRoute}
|
|
285
|
+
bottomItems={bottomItems}
|
|
286
|
+
itemStyle={itemStyle}
|
|
287
|
+
onNavigate={handleNavigate}
|
|
288
|
+
panelStyle={panelStyle}
|
|
289
|
+
topItems={topItems}
|
|
290
|
+
>
|
|
291
|
+
<Slot />
|
|
292
|
+
</SidebarNavigationPanel>
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Custom expo-router navigator with a hamburger-triggered slide-in drawer.
|
|
298
|
+
* Use in _layout.tsx files:
|
|
299
|
+
*
|
|
300
|
+
* ```tsx
|
|
301
|
+
* export default function SidebarLayout() {
|
|
302
|
+
* return (
|
|
303
|
+
* <SidebarNavigation
|
|
304
|
+
* topItems={[{label: "Home", route: "index", iconName: "house"}]}
|
|
305
|
+
* bottomItems={[{label: "Settings", route: "settings", iconName: "gear"}]}
|
|
306
|
+
* />
|
|
307
|
+
* );
|
|
308
|
+
* }
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
|
312
|
+
topItems,
|
|
313
|
+
bottomItems,
|
|
314
|
+
onNavigate,
|
|
315
|
+
initialRouteName,
|
|
316
|
+
screenOptions,
|
|
317
|
+
panelStyle,
|
|
318
|
+
itemStyle,
|
|
319
|
+
}) => {
|
|
320
|
+
return (
|
|
321
|
+
<Navigator initialRouteName={initialRouteName} router={TabRouter} screenOptions={screenOptions}>
|
|
322
|
+
<SidebarNavigatorContent
|
|
323
|
+
bottomItems={bottomItems}
|
|
324
|
+
itemStyle={itemStyle}
|
|
325
|
+
onNavigate={onNavigate}
|
|
326
|
+
panelStyle={panelStyle}
|
|
327
|
+
topItems={topItems}
|
|
328
|
+
/>
|
|
329
|
+
</Navigator>
|
|
330
|
+
);
|
|
331
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {TabRouter} from "@react-navigation/native";
|
|
2
|
+
import {Navigator, Slot} from "expo-router";
|
|
3
|
+
// Screen is not exported from expo-router's public API (exports.d.ts only exposes ScreenProps).
|
|
4
|
+
// Stack.Screen and Tabs.Screen use this same internal path. If expo-router upgrades break this,
|
|
5
|
+
// update the import path here — this is the only place in the codebase that references it.
|
|
6
|
+
// eslint-disable-next-line import/no-internal-modules
|
|
7
|
+
import {Screen} from "expo-router/build/views/Screen";
|
|
8
|
+
import {type FC, useCallback, useMemo, useState} from "react";
|
|
9
|
+
import {Pressable, type StyleProp, View, type ViewStyle} from "react-native";
|
|
10
|
+
|
|
11
|
+
import {Badge} from "./Badge";
|
|
12
|
+
import {Box} from "./Box";
|
|
13
|
+
import type {
|
|
14
|
+
SidebarNavigationItem,
|
|
15
|
+
SidebarNavigationPanelProps,
|
|
16
|
+
SidebarNavigationProps,
|
|
17
|
+
} from "./Common";
|
|
18
|
+
import {SIDEBAR_BADGE_STATUS_MAP} from "./Common";
|
|
19
|
+
import {Icon} from "./Icon";
|
|
20
|
+
import {Text} from "./Text";
|
|
21
|
+
import {useTheme} from "./Theme";
|
|
22
|
+
|
|
23
|
+
const COLLAPSED_WIDTH = 65;
|
|
24
|
+
const EXPANDED_WIDTH = 220;
|
|
25
|
+
const ITEM_HEIGHT = 44;
|
|
26
|
+
const ICON_SIZE = 20;
|
|
27
|
+
|
|
28
|
+
const SidebarItem: FC<{
|
|
29
|
+
item: SidebarNavigationItem;
|
|
30
|
+
isActive: boolean;
|
|
31
|
+
isExpanded: boolean;
|
|
32
|
+
onNavigate: (route: string) => void;
|
|
33
|
+
itemStyle?: StyleProp<ViewStyle>;
|
|
34
|
+
}> = ({item, isActive, isExpanded, onNavigate, itemStyle}) => {
|
|
35
|
+
const {theme} = useTheme();
|
|
36
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
37
|
+
|
|
38
|
+
const handlePress = useCallback(() => {
|
|
39
|
+
onNavigate(item.route);
|
|
40
|
+
}, [onNavigate, item.route]);
|
|
41
|
+
|
|
42
|
+
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
|
43
|
+
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
|
44
|
+
|
|
45
|
+
const backgroundColor = useMemo(() => {
|
|
46
|
+
if (isActive) {
|
|
47
|
+
return theme.surface.neutralLight;
|
|
48
|
+
}
|
|
49
|
+
if (isHovered) {
|
|
50
|
+
return theme.primitives.neutral050;
|
|
51
|
+
}
|
|
52
|
+
return "transparent";
|
|
53
|
+
}, [isActive, isHovered, theme]);
|
|
54
|
+
|
|
55
|
+
// Active = near-black (neutral900), hovered = light grey (neutral500), inactive = medium grey (neutral600)
|
|
56
|
+
const iconColor = isActive ? "primary" : isHovered ? "extraLight" : "secondaryLight";
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Pressable
|
|
60
|
+
accessibilityLabel={item.label}
|
|
61
|
+
accessibilityRole="button"
|
|
62
|
+
onHoverIn={handleHoverIn}
|
|
63
|
+
onHoverOut={handleHoverOut}
|
|
64
|
+
onPress={handlePress}
|
|
65
|
+
style={[
|
|
66
|
+
{
|
|
67
|
+
alignItems: "center",
|
|
68
|
+
backgroundColor,
|
|
69
|
+
borderRadius: theme.radius.default,
|
|
70
|
+
flexDirection: "row",
|
|
71
|
+
gap: 12,
|
|
72
|
+
height: ITEM_HEIGHT,
|
|
73
|
+
justifyContent: isExpanded ? undefined : "center",
|
|
74
|
+
marginHorizontal: 8,
|
|
75
|
+
paddingHorizontal: 12,
|
|
76
|
+
},
|
|
77
|
+
itemStyle,
|
|
78
|
+
]}
|
|
79
|
+
>
|
|
80
|
+
<View style={{alignItems: "center", justifyContent: "center", width: ICON_SIZE}}>
|
|
81
|
+
<Icon color={iconColor} iconName={item.iconName} size="lg" />
|
|
82
|
+
{Boolean(item.badge) && (
|
|
83
|
+
<Box marginLeft={5} marginTop={5} position="absolute">
|
|
84
|
+
<Badge
|
|
85
|
+
maxValue={99}
|
|
86
|
+
status={SIDEBAR_BADGE_STATUS_MAP[item.badgeStatus ?? "error"]}
|
|
87
|
+
value={item.badge === true ? undefined : String(item.badge)}
|
|
88
|
+
variant={item.badge === true ? "iconOnly" : "numberOnly"}
|
|
89
|
+
/>
|
|
90
|
+
</Box>
|
|
91
|
+
)}
|
|
92
|
+
</View>
|
|
93
|
+
{isExpanded && (
|
|
94
|
+
<Text bold={isActive} color={iconColor} size="md">
|
|
95
|
+
{item.label}
|
|
96
|
+
</Text>
|
|
97
|
+
)}
|
|
98
|
+
</Pressable>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Renders the sidebar rail + children in a row. Works without expo-router Navigator context.
|
|
104
|
+
*/
|
|
105
|
+
export const SidebarNavigationPanel: FC<SidebarNavigationPanelProps> = ({
|
|
106
|
+
topItems,
|
|
107
|
+
bottomItems,
|
|
108
|
+
activeRoute,
|
|
109
|
+
onNavigate,
|
|
110
|
+
children,
|
|
111
|
+
panelStyle,
|
|
112
|
+
itemStyle,
|
|
113
|
+
}) => {
|
|
114
|
+
const {theme} = useTheme();
|
|
115
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
116
|
+
|
|
117
|
+
const handleHoverIn = useCallback(() => {
|
|
118
|
+
setIsExpanded(true);
|
|
119
|
+
}, []);
|
|
120
|
+
const handleHoverOut = useCallback(() => {
|
|
121
|
+
setIsExpanded(false);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const width = isExpanded ? EXPANDED_WIDTH : COLLAPSED_WIDTH;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<View style={{flex: 1}}>
|
|
128
|
+
<View style={{flex: 1, marginLeft: COLLAPSED_WIDTH}}>{children}</View>
|
|
129
|
+
<View
|
|
130
|
+
{...({onMouseEnter: handleHoverIn, onMouseLeave: handleHoverOut} as any)}
|
|
131
|
+
style={[
|
|
132
|
+
{
|
|
133
|
+
backgroundColor: theme.surface.base,
|
|
134
|
+
borderColor: theme.border.default,
|
|
135
|
+
borderRightWidth: 1,
|
|
136
|
+
bottom: 0,
|
|
137
|
+
flexDirection: "column",
|
|
138
|
+
justifyContent: "space-between",
|
|
139
|
+
left: 0,
|
|
140
|
+
overflow: "hidden",
|
|
141
|
+
paddingVertical: 12,
|
|
142
|
+
position: "absolute",
|
|
143
|
+
top: 0,
|
|
144
|
+
width,
|
|
145
|
+
zIndex: 10,
|
|
146
|
+
},
|
|
147
|
+
// Web-only CSS transitions for smooth expand/collapse
|
|
148
|
+
{
|
|
149
|
+
transitionDuration: "150ms",
|
|
150
|
+
transitionProperty: "width",
|
|
151
|
+
transitionTimingFunction: "ease-in-out",
|
|
152
|
+
} as any,
|
|
153
|
+
panelStyle,
|
|
154
|
+
]}
|
|
155
|
+
>
|
|
156
|
+
<View style={{gap: 4}}>
|
|
157
|
+
{topItems.map((item) => (
|
|
158
|
+
<SidebarItem
|
|
159
|
+
isActive={activeRoute === item.route}
|
|
160
|
+
isExpanded={isExpanded}
|
|
161
|
+
item={item}
|
|
162
|
+
itemStyle={itemStyle}
|
|
163
|
+
key={item.route}
|
|
164
|
+
onNavigate={onNavigate}
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
</View>
|
|
168
|
+
<View style={{gap: 4}}>
|
|
169
|
+
{bottomItems.map((item) => (
|
|
170
|
+
<SidebarItem
|
|
171
|
+
isActive={activeRoute === item.route}
|
|
172
|
+
isExpanded={isExpanded}
|
|
173
|
+
item={item}
|
|
174
|
+
itemStyle={itemStyle}
|
|
175
|
+
key={item.route}
|
|
176
|
+
onNavigate={onNavigate}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
</View>
|
|
180
|
+
</View>
|
|
181
|
+
</View>
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Reads active route from Navigator context and renders the sidebar + Slot.
|
|
187
|
+
*/
|
|
188
|
+
const SidebarNavigatorContent: FC<{
|
|
189
|
+
topItems: SidebarNavigationItem[];
|
|
190
|
+
bottomItems: SidebarNavigationItem[];
|
|
191
|
+
onNavigate?: (route: string) => void;
|
|
192
|
+
panelStyle?: StyleProp<ViewStyle>;
|
|
193
|
+
itemStyle?: StyleProp<ViewStyle>;
|
|
194
|
+
}> = ({topItems, bottomItems, onNavigate, panelStyle, itemStyle}) => {
|
|
195
|
+
const {theme} = useTheme();
|
|
196
|
+
const {state, navigation, descriptors} = Navigator.useContext();
|
|
197
|
+
const activeRoute = state.routes[state.index];
|
|
198
|
+
const {headerLeft, headerRight, title} = (descriptors[activeRoute?.key]?.options ?? {}) as any;
|
|
199
|
+
|
|
200
|
+
const handleNavigate = useCallback(
|
|
201
|
+
(route: string) => {
|
|
202
|
+
navigation.navigate(route);
|
|
203
|
+
onNavigate?.(route);
|
|
204
|
+
},
|
|
205
|
+
[navigation, onNavigate]
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<View style={{flex: 1}}>
|
|
210
|
+
{(title || headerLeft || headerRight) && (
|
|
211
|
+
<View
|
|
212
|
+
style={{
|
|
213
|
+
alignItems: "center",
|
|
214
|
+
backgroundColor: theme.surface.base,
|
|
215
|
+
borderBottomColor: theme.border.default,
|
|
216
|
+
borderBottomWidth: 1,
|
|
217
|
+
flexDirection: "row",
|
|
218
|
+
justifyContent: "space-between",
|
|
219
|
+
minHeight: 52,
|
|
220
|
+
paddingHorizontal: 16,
|
|
221
|
+
paddingVertical: 12,
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
<View style={{alignItems: "center", flexDirection: "row", gap: 12}}>
|
|
225
|
+
{headerLeft?.({})}
|
|
226
|
+
<Text bold size="lg">
|
|
227
|
+
{title}
|
|
228
|
+
</Text>
|
|
229
|
+
</View>
|
|
230
|
+
<View style={{alignItems: "flex-end"}}>{headerRight?.({})}</View>
|
|
231
|
+
</View>
|
|
232
|
+
)}
|
|
233
|
+
<SidebarNavigationPanel
|
|
234
|
+
activeRoute={activeRoute?.name}
|
|
235
|
+
bottomItems={bottomItems}
|
|
236
|
+
itemStyle={itemStyle}
|
|
237
|
+
onNavigate={handleNavigate}
|
|
238
|
+
panelStyle={panelStyle}
|
|
239
|
+
topItems={topItems}
|
|
240
|
+
>
|
|
241
|
+
<Slot />
|
|
242
|
+
</SidebarNavigationPanel>
|
|
243
|
+
</View>
|
|
244
|
+
);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Custom expo-router navigator with a collapsible sidebar rail.
|
|
249
|
+
* Use in _layout.tsx files:
|
|
250
|
+
*
|
|
251
|
+
* ```tsx
|
|
252
|
+
* export default function SidebarLayout() {
|
|
253
|
+
* return (
|
|
254
|
+
* <SidebarNavigation
|
|
255
|
+
* topItems={[{label: "Home", route: "index", iconName: "house"}]}
|
|
256
|
+
* bottomItems={[{label: "Settings", route: "settings", iconName: "gear"}]}
|
|
257
|
+
* />
|
|
258
|
+
* );
|
|
259
|
+
* }
|
|
260
|
+
* ```
|
|
261
|
+
*/
|
|
262
|
+
const SidebarNavigationBase: FC<SidebarNavigationProps> = ({
|
|
263
|
+
topItems,
|
|
264
|
+
bottomItems,
|
|
265
|
+
onNavigate,
|
|
266
|
+
initialRouteName,
|
|
267
|
+
screenOptions,
|
|
268
|
+
panelStyle,
|
|
269
|
+
itemStyle,
|
|
270
|
+
children,
|
|
271
|
+
}) => {
|
|
272
|
+
return (
|
|
273
|
+
<Navigator initialRouteName={initialRouteName} router={TabRouter} screenOptions={screenOptions}>
|
|
274
|
+
<SidebarNavigatorContent
|
|
275
|
+
bottomItems={bottomItems}
|
|
276
|
+
itemStyle={itemStyle}
|
|
277
|
+
onNavigate={onNavigate}
|
|
278
|
+
panelStyle={panelStyle}
|
|
279
|
+
topItems={topItems}
|
|
280
|
+
/>
|
|
281
|
+
{children}
|
|
282
|
+
</Navigator>
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Custom expo-router navigator with a collapsible sidebar rail.
|
|
288
|
+
* Supports per-screen options via SidebarNavigation.Screen, matching the
|
|
289
|
+
* Stack.Screen / Tabs.Screen pattern.
|
|
290
|
+
*/
|
|
291
|
+
export const SidebarNavigation = Object.assign(SidebarNavigationBase, {Screen});
|