@terreno/ui 0.11.4-beta.6 → 0.11.5

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.
@@ -58,7 +58,7 @@ export const MarkdownEditorField: React.FC<MarkdownEditorFieldProps> = ({
58
58
 
59
59
  return (
60
60
  <View testID={testID}>
61
- {title && <FieldTitle text={title} />}
61
+ {Boolean(title) && <FieldTitle text={title!} />}
62
62
  <Box
63
63
  border={errorText ? "error" : "default"}
64
64
  direction={isWeb ? "row" : "column"}
@@ -151,8 +151,8 @@ export const MarkdownEditorField: React.FC<MarkdownEditorFieldProps> = ({
151
151
  </Box>
152
152
  </ScrollView>
153
153
  </Box>
154
- {errorText && <FieldError text={errorText} />}
155
- {helperText && <FieldHelperText text={helperText} />}
154
+ {Boolean(errorText) && <FieldError text={errorText!} />}
155
+ {Boolean(helperText) && <FieldHelperText text={helperText!} />}
156
156
  </View>
157
157
  );
158
158
  };
package/src/Modal.tsx CHANGED
@@ -102,7 +102,7 @@ const ModalContent: FC<{
102
102
  <Icon iconName="x" size="sm" />
103
103
  </Pressable>
104
104
  </View>
105
- {title && (
105
+ {Boolean(title) && (
106
106
  <View
107
107
  accessibilityHint="Modal title"
108
108
  aria-label={title}
@@ -112,7 +112,7 @@ const ModalContent: FC<{
112
112
  <Heading size="lg">{title}</Heading>
113
113
  </View>
114
114
  )}
115
- {subtitle && (
115
+ {Boolean(subtitle) && (
116
116
  <View
117
117
  accessibilityHint="Modal Sub Heading Text"
118
118
  aria-label={subtitle}
@@ -122,7 +122,7 @@ const ModalContent: FC<{
122
122
  <Text size="lg">{subtitle}</Text>
123
123
  </View>
124
124
  )}
125
- {text && (
125
+ {Boolean(text) && (
126
126
  <View
127
127
  accessibilityHint="Modal body text"
128
128
  aria-label={text}
@@ -20,7 +20,7 @@ export const SelectField: FC<SelectFieldProps> = ({
20
20
 
21
21
  return (
22
22
  <View>
23
- {title && <FieldTitle text={title} />}
23
+ {Boolean(title) && <FieldTitle text={title!} />}
24
24
  {Boolean(errorText) && <FieldError text={errorText!} />}
25
25
  <RNPickerSelect
26
26
  disabled={disabled}
@@ -35,7 +35,7 @@ export const SelectField: FC<SelectFieldProps> = ({
35
35
  placeholder={!requireValue ? clearOption : {}}
36
36
  value={value ?? ""}
37
37
  />
38
- {helperText && <FieldHelperText text={helperText} />}
38
+ {Boolean(helperText) && <FieldHelperText text={helperText!} />}
39
39
  </View>
40
40
  );
41
41
  };
@@ -1,21 +1,7 @@
1
1
  import {TabRouter} from "@react-navigation/native";
2
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, useEffect, useMemo, useRef, useState} from "react";
9
- import {
10
- Animated,
11
- Dimensions,
12
- PanResponder,
13
- Pressable,
14
- type StyleProp,
15
- View,
16
- type ViewStyle,
17
- } from "react-native";
18
- import {useSafeAreaInsets} from "react-native-safe-area-context";
3
+ import {type FC, useCallback, useEffect, useRef, useState} from "react";
4
+ import {Animated, Dimensions, Pressable, type StyleProp, View, type ViewStyle} from "react-native";
19
5
 
20
6
  import {Badge} from "./Badge";
21
7
  import type {
@@ -28,11 +14,11 @@ import {Icon} from "./Icon";
28
14
  import {Text} from "./Text";
29
15
  import {useTheme} from "./Theme";
30
16
 
31
- const ITEM_HEIGHT = 44;
17
+ const DRAWER_WIDTH = 280;
18
+ const ITEM_HEIGHT = 48;
32
19
  const ICON_SIZE = 20;
33
20
  const BACKDROP_OPACITY = 0.5;
34
- const ANIMATION_DURATION = 300;
35
- const DISMISS_THRESHOLD = 0.3;
21
+ const ANIMATION_DURATION = 250;
36
22
 
37
23
  const SidebarItem: FC<{
38
24
  item: SidebarNavigationItem;
@@ -54,19 +40,19 @@ const SidebarItem: FC<{
54
40
  style={[
55
41
  {
56
42
  alignItems: "center",
57
- backgroundColor: isActive ? theme.surface.neutralLight : "transparent",
43
+ backgroundColor: isActive ? theme.surface.secondaryLight : "transparent",
58
44
  borderRadius: theme.radius.default,
59
45
  flexDirection: "row",
60
- gap: 12,
46
+ gap: 14,
61
47
  height: ITEM_HEIGHT,
62
- marginHorizontal: 8,
63
- paddingHorizontal: 12,
48
+ marginHorizontal: 12,
49
+ paddingHorizontal: 14,
64
50
  },
65
51
  itemStyle,
66
52
  ]}
67
53
  >
68
54
  <View style={{alignItems: "center", justifyContent: "center", width: ICON_SIZE}}>
69
- <Icon color={isActive ? "primary" : "secondaryLight"} iconName={item.iconName} size="lg" />
55
+ <Icon color={isActive ? "primary" : "secondaryDark"} iconName={item.iconName} size="md" />
70
56
  {Boolean(item.badge) && (
71
57
  <View
72
58
  style={{
@@ -85,30 +71,15 @@ const SidebarItem: FC<{
85
71
  </View>
86
72
  )}
87
73
  </View>
88
- <Text bold={isActive} color={isActive ? "primary" : "secondaryLight"} size="md">
74
+ <Text bold={isActive} color={isActive ? "primary" : "secondaryDark"} size="md">
89
75
  {item.label}
90
76
  </Text>
91
77
  </Pressable>
92
78
  );
93
79
  };
94
80
 
95
- const SidebarHamburger: FC<{onOpen: () => void}> = ({onOpen}) => (
96
- <Pressable
97
- accessibilityLabel="Open navigation menu"
98
- accessibilityRole="button"
99
- onPress={onOpen}
100
- style={{alignItems: "center", height: 40, justifyContent: "center", width: 40}}
101
- >
102
- <Icon color="primary" iconName="bars" size="md" />
103
- </Pressable>
104
- );
105
-
106
81
  /**
107
- * Renders the bottom sheet overlay and children. Works without expo-router Navigator context.
108
- *
109
- * Supports two modes:
110
- * - Uncontrolled (default): manages open state internally and shows a floating hamburger button.
111
- * - Controlled: caller provides isOpen + onOpenChange and owns the trigger (e.g. a header button).
82
+ * Renders the hamburger button, drawer overlay, and children. Works without expo-router Navigator context.
112
83
  */
113
84
  export const SidebarNavigationPanel: FC<SidebarNavigationPanelProps> = ({
114
85
  topItems,
@@ -118,262 +89,187 @@ export const SidebarNavigationPanel: FC<SidebarNavigationPanelProps> = ({
118
89
  children,
119
90
  panelStyle,
120
91
  itemStyle,
121
- isOpen: isOpenProp,
122
- onOpenChange,
123
92
  }) => {
124
93
  const {theme} = useTheme();
125
- const insets = useSafeAreaInsets();
126
- const isControlled = isOpenProp !== undefined;
127
- const [isOpenInternal, setIsOpenInternal] = useState(false);
128
- const isOpen = isControlled ? isOpenProp : isOpenInternal;
129
-
130
- const sheetHeight = useMemo(() => Dimensions.get("window").height * 0.65, []);
131
- const slideAnim = useRef(new Animated.Value(sheetHeight)).current;
94
+ const [isOpen, setIsOpen] = useState(false);
95
+ const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
132
96
  const backdropAnim = useRef(new Animated.Value(0)).current;
133
- const capturedSlideValue = useRef(0);
134
97
 
135
- // Play open animation whenever isOpen becomes true
98
+ // Animate drawer open/close
136
99
  useEffect(() => {
137
- if (!isOpen) {
138
- return;
139
- }
140
- slideAnim.setValue(sheetHeight);
141
- backdropAnim.setValue(0);
142
- Animated.parallel([
143
- Animated.timing(slideAnim, {
144
- duration: ANIMATION_DURATION,
145
- toValue: 0,
146
- useNativeDriver: true,
147
- }),
148
- Animated.timing(backdropAnim, {
149
- duration: ANIMATION_DURATION,
150
- toValue: BACKDROP_OPACITY,
151
- useNativeDriver: true,
152
- }),
153
- ]).start();
154
- }, [isOpen, slideAnim, backdropAnim, sheetHeight]);
155
-
156
- // Play close animation then update state
157
- const handleClose = useCallback(() => {
158
- Animated.parallel([
159
- Animated.timing(slideAnim, {
160
- duration: ANIMATION_DURATION,
161
- toValue: sheetHeight,
162
- useNativeDriver: true,
163
- }),
164
- Animated.timing(backdropAnim, {
165
- duration: ANIMATION_DURATION,
166
- toValue: 0,
167
- useNativeDriver: true,
168
- }),
169
- ]).start(() => {
170
- if (isControlled) {
171
- onOpenChange?.(false);
172
- } else {
173
- setIsOpenInternal(false);
174
- }
175
- });
176
- }, [isControlled, onOpenChange, slideAnim, backdropAnim, sheetHeight]);
177
-
178
- const handleOpen = useCallback(() => {
179
- if (isControlled) {
180
- onOpenChange?.(true);
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();
181
113
  } else {
182
- setIsOpenInternal(true);
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();
183
126
  }
184
- }, [isControlled, onOpenChange]);
127
+ }, [isOpen, slideAnim, backdropAnim]);
128
+
129
+ const handleOpen = useCallback(() => setIsOpen(true), []);
130
+ const handleClose = useCallback(() => setIsOpen(false), []);
185
131
 
186
132
  const handleNavigate = useCallback(
187
133
  (route: string) => {
188
- handleClose();
134
+ setIsOpen(false);
189
135
  onNavigate(route);
190
136
  },
191
- [handleClose, onNavigate]
137
+ [onNavigate]
192
138
  );
193
139
 
194
- const panResponder = useMemo(
195
- () =>
196
- PanResponder.create({
197
- onMoveShouldSetPanResponder: (_, {dx, dy}) => Math.abs(dy) > Math.abs(dx) && dy > 4,
198
- onPanResponderGrant: () => {
199
- slideAnim.stopAnimation((value) => {
200
- capturedSlideValue.current = value;
201
- });
202
- backdropAnim.stopAnimation();
203
- },
204
- onPanResponderMove: (_, {dy}) => {
205
- const next = capturedSlideValue.current + dy;
206
- if (next < 0) {
207
- return;
208
- }
209
- slideAnim.setValue(next);
210
- backdropAnim.setValue(BACKDROP_OPACITY * Math.max(0, 1 - next / sheetHeight));
211
- },
212
- onPanResponderRelease: (_, {dy, vy}) => {
213
- if (dy > sheetHeight * DISMISS_THRESHOLD || vy > 0.5) {
214
- handleClose();
215
- } else {
216
- Animated.parallel([
217
- Animated.timing(slideAnim, {
218
- duration: 200,
219
- toValue: 0,
220
- useNativeDriver: true,
221
- }),
222
- Animated.timing(backdropAnim, {
223
- duration: 200,
224
- toValue: BACKDROP_OPACITY,
225
- useNativeDriver: true,
226
- }),
227
- ]).start();
228
- }
229
- },
230
- }),
231
- [slideAnim, backdropAnim, sheetHeight, handleClose]
232
- );
140
+ const screenHeight = Dimensions.get("window").height;
233
141
 
234
142
  return (
235
143
  <View style={{flex: 1}}>
236
144
  {children}
237
145
 
238
- {/* Floating hamburger — only shown in uncontrolled (standalone) mode */}
239
- {!isControlled && (
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 && (
240
174
  <Pressable
241
- accessibilityLabel="Open navigation menu"
242
- accessibilityRole="button"
243
- onPress={handleOpen}
175
+ onPress={handleClose}
244
176
  style={{
245
- alignItems: "center",
246
- height: 44,
247
- justifyContent: "center",
248
- left: 16,
177
+ bottom: 0,
178
+ left: 0,
249
179
  position: "absolute",
250
- top: insets.top + 16,
251
- width: 44,
252
- zIndex: 10,
180
+ right: 0,
181
+ top: 0,
182
+ zIndex: 100,
253
183
  }}
254
184
  >
255
- <Icon color="primary" iconName="bars" size="md" />
185
+ <Animated.View
186
+ style={{
187
+ backgroundColor: "#000",
188
+ flex: 1,
189
+ opacity: backdropAnim,
190
+ }}
191
+ />
256
192
  </Pressable>
257
193
  )}
258
194
 
259
- {isOpen && (
260
- <>
261
- {/* Backdrop */}
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>
262
218
  <Pressable
263
- accessibilityElementsHidden
219
+ accessibilityLabel="Close navigation menu"
220
+ accessibilityRole="button"
264
221
  onPress={handleClose}
265
- style={{bottom: 0, left: 0, position: "absolute", right: 0, top: 0, zIndex: 100}}
222
+ style={{
223
+ alignItems: "center",
224
+ alignSelf: "flex-end",
225
+ height: 40,
226
+ justifyContent: "center",
227
+ marginRight: 12,
228
+ width: 40,
229
+ }}
266
230
  >
267
- <Animated.View style={{backgroundColor: "#000", flex: 1, opacity: backdropAnim}} />
231
+ <Icon color="secondaryDark" iconName="xmark" size="md" />
268
232
  </Pressable>
269
-
270
- {/* Bottom sheet */}
271
- <Animated.View
272
- style={[
273
- {
274
- backgroundColor: theme.surface.base,
275
- borderTopLeftRadius: 16,
276
- borderTopRightRadius: 16,
277
- bottom: 0,
278
- height: sheetHeight,
279
- left: 0,
280
- position: "absolute",
281
- right: 0,
282
- transform: [{translateY: slideAnim}],
283
- zIndex: 200,
284
- },
285
- panelStyle,
286
- ]}
287
- >
288
- {/* Drag bar */}
289
- <View
290
- {...panResponder.panHandlers}
291
- accessibilityHint="Drag down to close"
292
- accessibilityLabel="Navigation menu drag handle"
293
- accessibilityRole="adjustable"
294
- style={{alignItems: "center", paddingBottom: 8, paddingTop: 12}}
295
- >
296
- <View
297
- style={{
298
- backgroundColor: theme.border.default,
299
- borderRadius: 2,
300
- height: 4,
301
- width: 36,
302
- }}
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}
303
241
  />
304
- </View>
305
-
306
- {/* Nav items */}
307
- <View style={{gap: 4, paddingBottom: insets.bottom + 8}}>
308
- {[...topItems, ...bottomItems].map((item) => (
309
- <SidebarItem
310
- isActive={activeRoute === item.route}
311
- item={item}
312
- itemStyle={itemStyle}
313
- key={item.route}
314
- onPress={handleNavigate}
315
- />
316
- ))}
317
- </View>
318
- </Animated.View>
319
- </>
320
- )}
321
- </View>
322
- );
323
- };
324
-
325
- const SidebarHeader: FC<{onOpen: () => void}> = ({onOpen}) => {
326
- const {theme} = useTheme();
327
- const insets = useSafeAreaInsets();
328
- const {state, descriptors} = Navigator.useContext();
329
- const activeRoute = state.routes[state.index];
330
- const {headerLeft, headerRight, title} = (descriptors[activeRoute?.key]?.options ?? {}) as any;
242
+ ))}
243
+ </View>
244
+ </View>
331
245
 
332
- return (
333
- <View
334
- style={{
335
- backgroundColor: theme.surface.base,
336
- borderBottomColor: theme.border.default,
337
- borderBottomWidth: 1,
338
- paddingTop: insets.top,
339
- }}
340
- >
341
- <View
342
- style={{
343
- alignItems: "center",
344
- flexDirection: "row",
345
- height: 44,
346
- justifyContent: "space-between",
347
- paddingHorizontal: 16,
348
- }}
349
- >
350
- <View style={{alignItems: "center", flexDirection: "row", gap: 12}}>
351
- <SidebarHamburger onOpen={onOpen} />
352
- {headerLeft?.({})}
353
- {Boolean(title) && (
354
- <Text bold size="lg">
355
- {title}
356
- </Text>
357
- )}
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
+ ))}
358
255
  </View>
359
- {Boolean(headerRight) && <View style={{alignItems: "flex-end"}}>{headerRight?.({})}</View>}
360
- </View>
256
+ </Animated.View>
361
257
  </View>
362
258
  );
363
259
  };
364
260
 
365
- /** Renders the content panel and bottom sheet for the active screen. */
261
+ /**
262
+ * Reads active route from Navigator context and renders the drawer + Slot.
263
+ */
366
264
  const SidebarNavigatorContent: FC<{
367
265
  topItems: SidebarNavigationItem[];
368
266
  bottomItems: SidebarNavigationItem[];
369
- isOpen: boolean;
370
- onOpenChange: (isOpen: boolean) => void;
371
267
  onNavigate?: (route: string) => void;
372
268
  panelStyle?: StyleProp<ViewStyle>;
373
269
  itemStyle?: StyleProp<ViewStyle>;
374
- }> = ({topItems, bottomItems, isOpen, onOpenChange, onNavigate, panelStyle, itemStyle}) => {
270
+ }> = ({topItems, bottomItems, onNavigate, panelStyle, itemStyle}) => {
375
271
  const {state, navigation} = Navigator.useContext();
376
- const activeRoute = state.routes[state.index];
272
+ const activeRoute = state.routes[state.index]?.name;
377
273
 
378
274
  const handleNavigate = useCallback(
379
275
  (route: string) => {
@@ -385,12 +281,10 @@ const SidebarNavigatorContent: FC<{
385
281
 
386
282
  return (
387
283
  <SidebarNavigationPanel
388
- activeRoute={activeRoute?.name}
284
+ activeRoute={activeRoute}
389
285
  bottomItems={bottomItems}
390
- isOpen={isOpen}
391
286
  itemStyle={itemStyle}
392
287
  onNavigate={handleNavigate}
393
- onOpenChange={onOpenChange}
394
288
  panelStyle={panelStyle}
395
289
  topItems={topItems}
396
290
  >
@@ -400,7 +294,7 @@ const SidebarNavigatorContent: FC<{
400
294
  };
401
295
 
402
296
  /**
403
- * Custom expo-router navigator with a header bar and hamburger-triggered bottom sheet.
297
+ * Custom expo-router navigator with a hamburger-triggered slide-in drawer.
404
298
  * Use in _layout.tsx files:
405
299
  *
406
300
  * ```tsx
@@ -414,7 +308,7 @@ const SidebarNavigatorContent: FC<{
414
308
  * }
415
309
  * ```
416
310
  */
417
- const SidebarNavigationBase: FC<SidebarNavigationProps> = ({
311
+ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
418
312
  topItems,
419
313
  bottomItems,
420
314
  onNavigate,
@@ -422,27 +316,16 @@ const SidebarNavigationBase: FC<SidebarNavigationProps> = ({
422
316
  screenOptions,
423
317
  panelStyle,
424
318
  itemStyle,
425
- children,
426
319
  }) => {
427
- const [isSheetOpen, setIsSheetOpen] = useState(false);
428
-
429
320
  return (
430
321
  <Navigator initialRouteName={initialRouteName} router={TabRouter} screenOptions={screenOptions}>
431
- <View style={{flex: 1}}>
432
- <SidebarHeader onOpen={() => setIsSheetOpen(true)} />
433
- <SidebarNavigatorContent
434
- bottomItems={bottomItems}
435
- isOpen={isSheetOpen}
436
- itemStyle={itemStyle}
437
- onNavigate={onNavigate}
438
- onOpenChange={setIsSheetOpen}
439
- panelStyle={panelStyle}
440
- topItems={topItems}
441
- />
442
- </View>
443
- {children}
322
+ <SidebarNavigatorContent
323
+ bottomItems={bottomItems}
324
+ itemStyle={itemStyle}
325
+ onNavigate={onNavigate}
326
+ panelStyle={panelStyle}
327
+ topItems={topItems}
328
+ />
444
329
  </Navigator>
445
330
  );
446
331
  };
447
-
448
- export const SidebarNavigation = Object.assign(SidebarNavigationBase, {Screen});