@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.
@@ -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});