@retray-dev/ui-kit 5.4.0 → 6.1.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,228 @@
1
+ import React, { useRef } from 'react'
2
+ import {
3
+ TouchableOpacity,
4
+ Animated,
5
+ View,
6
+ Text,
7
+ StyleSheet,
8
+ ViewStyle,
9
+ TextStyle,
10
+ Platform,
11
+ } from 'react-native'
12
+ import { Entypo } from '@expo/vector-icons'
13
+ import { selectionAsync as hapticSelection } from '../../utils/haptics'
14
+ import { useTheme } from '../../theme'
15
+ import { s, vs, ms } from '../../utils/scaling'
16
+ import { renderIcon } from '../../utils/icons'
17
+ import { RADIUS } from '../../tokens'
18
+
19
+ const nativeDriver = Platform.OS !== 'web'
20
+
21
+ export type MenuItemVariant = 'plain' | 'card'
22
+
23
+ export interface MenuItemProps {
24
+ label: string
25
+ /** Secondary text rendered below the label. */
26
+ subtitle?: string
27
+ /**
28
+ * Icon name from `@expo/vector-icons` rendered on the left.
29
+ * See https://icons.expo.fyi.
30
+ */
31
+ iconName?: string
32
+ /** Custom icon node rendered on the left. */
33
+ icon?: React.ReactNode
34
+ /** Override icon color. Defaults to `foreground`. */
35
+ iconColor?: string
36
+ /**
37
+ * Custom content rendered on the right.
38
+ * When provided, replaces the default chevron.
39
+ * Use for checkboxes, switches, badges, or other controls.
40
+ */
41
+ rightRender?: React.ReactNode
42
+ /**
43
+ * Show chevron on the right. Defaults to `true`.
44
+ * Ignored when `rightRender` is provided.
45
+ */
46
+ showChevron?: boolean
47
+ onPress: () => void
48
+ disabled?: boolean
49
+ /**
50
+ * - `plain` (default): no background — sits inside a parent surface.
51
+ * - `card`: standalone surface with background + border.
52
+ */
53
+ variant?: MenuItemVariant
54
+ /** Visual separator line at the bottom. */
55
+ showSeparator?: boolean
56
+ /** Style applied to the outer container. */
57
+ style?: ViewStyle
58
+ /** Style applied to the label Text. */
59
+ labelStyle?: TextStyle
60
+ }
61
+
62
+ export function MenuItem({
63
+ label,
64
+ subtitle,
65
+ iconName,
66
+ icon,
67
+ iconColor,
68
+ rightRender,
69
+ showChevron = true,
70
+ onPress,
71
+ disabled = false,
72
+ variant = 'plain',
73
+ showSeparator = false,
74
+ style,
75
+ labelStyle,
76
+ }: MenuItemProps) {
77
+ const { colors } = useTheme()
78
+ const scale = useRef(new Animated.Value(1)).current
79
+
80
+ const handlePressIn = () => {
81
+ if (disabled) return
82
+ Animated.spring(scale, {
83
+ toValue: 0.97,
84
+ useNativeDriver: nativeDriver,
85
+ stiffness: 350,
86
+ damping: 28,
87
+ mass: 0.9,
88
+ }).start()
89
+ }
90
+
91
+ const handlePressOut = () => {
92
+ Animated.spring(scale, {
93
+ toValue: 1,
94
+ useNativeDriver: nativeDriver,
95
+ stiffness: 220,
96
+ damping: 20,
97
+ mass: 0.9,
98
+ }).start()
99
+ }
100
+
101
+ const handlePress = () => {
102
+ hapticSelection()
103
+ onPress()
104
+ }
105
+
106
+ const resolvedIcon: React.ReactNode = iconName
107
+ ? renderIcon(iconName, 22, iconColor ?? colors.foreground)
108
+ : icon
109
+
110
+ const cardStyle: ViewStyle =
111
+ variant === 'card'
112
+ ? {
113
+ backgroundColor: colors.card,
114
+ borderRadius: RADIUS.md,
115
+ borderWidth: 1,
116
+ borderColor: colors.border,
117
+ shadowColor: '#000',
118
+ shadowOffset: { width: 0, height: 2 },
119
+ shadowOpacity: 0.06,
120
+ shadowRadius: 6,
121
+ elevation: 2,
122
+ }
123
+ : {}
124
+
125
+ return (
126
+ <Animated.View style={[{ transform: [{ scale }] }, disabled && styles.disabled]}>
127
+ <TouchableOpacity
128
+ style={[styles.container, cardStyle, style]}
129
+ onPress={handlePress}
130
+ onPressIn={handlePressIn}
131
+ onPressOut={handlePressOut}
132
+ disabled={disabled}
133
+ activeOpacity={1}
134
+ touchSoundDisabled={true}
135
+ >
136
+ {resolvedIcon ? (
137
+ <View style={styles.iconContainer}>{resolvedIcon}</View>
138
+ ) : null}
139
+
140
+ <View style={styles.labelContainer}>
141
+ <Text
142
+ style={[styles.label, { color: colors.foreground }, labelStyle]}
143
+ numberOfLines={1}
144
+ allowFontScaling={true}
145
+ >
146
+ {label}
147
+ </Text>
148
+ {subtitle ? (
149
+ <Text
150
+ style={[styles.subtitle, { color: colors.foregroundMuted }]}
151
+ numberOfLines={1}
152
+ allowFontScaling={true}
153
+ >
154
+ {subtitle}
155
+ </Text>
156
+ ) : null}
157
+ </View>
158
+
159
+ {rightRender !== undefined ? (
160
+ <View
161
+ style={styles.rightContainer}
162
+ onStartShouldSetResponder={() => true}
163
+ onResponderRelease={() => {}}
164
+ >
165
+ {rightRender}
166
+ </View>
167
+ ) : showChevron ? (
168
+ <Entypo name="chevron-right" size={18} color={colors.foregroundMuted} />
169
+ ) : null}
170
+ </TouchableOpacity>
171
+
172
+ {showSeparator ? (
173
+ <View
174
+ style={[
175
+ styles.separator,
176
+ {
177
+ backgroundColor: colors.border,
178
+ marginLeft: resolvedIcon ? s(22) + s(12) : 0,
179
+ opacity: 0.6,
180
+ },
181
+ ]}
182
+ />
183
+ ) : null}
184
+ </Animated.View>
185
+ )
186
+ }
187
+
188
+ const styles = StyleSheet.create({
189
+ container: {
190
+ flexDirection: 'row',
191
+ alignItems: 'center',
192
+ paddingHorizontal: 0,
193
+ paddingVertical: vs(16),
194
+ minHeight: vs(54),
195
+ gap: s(12),
196
+ },
197
+ iconContainer: {
198
+ width: s(22),
199
+ alignItems: 'center',
200
+ justifyContent: 'center',
201
+ flexShrink: 0,
202
+ },
203
+ labelContainer: {
204
+ flex: 1,
205
+ justifyContent: 'center',
206
+ },
207
+ label: {
208
+ fontFamily: 'Poppins-Medium',
209
+ fontSize: ms(15),
210
+ },
211
+ subtitle: {
212
+ fontFamily: 'Poppins-Regular',
213
+ fontSize: ms(12),
214
+ marginTop: vs(1),
215
+ },
216
+ rightContainer: {
217
+ alignItems: 'flex-end',
218
+ justifyContent: 'center',
219
+ flexShrink: 0,
220
+ },
221
+ separator: {
222
+ height: StyleSheet.hairlineWidth,
223
+ marginRight: 0,
224
+ },
225
+ disabled: {
226
+ opacity: 0.45,
227
+ },
228
+ })
@@ -0,0 +1,2 @@
1
+ export { MenuItem } from './MenuItem'
2
+ export type { MenuItemProps, MenuItemVariant } from './MenuItem'
@@ -109,7 +109,7 @@ export function Select({
109
109
  >
110
110
  {selected?.label ?? placeholder}
111
111
  </Text>
112
- <Entypo name="chevron-with-circle-down" size={20} color={colors.foregroundMuted} />
112
+ <Entypo name="chevron-down" size={20} color={colors.foregroundMuted} />
113
113
  </TouchableOpacity>
114
114
  </Animated.View>
115
115
  ) : null}
@@ -25,9 +25,11 @@ const styles = StyleSheet.create({
25
25
  horizontal: {
26
26
  height: 1,
27
27
  width: '100%',
28
+ opacity: 0.7,
28
29
  },
29
30
  vertical: {
30
31
  width: 1,
31
32
  height: '100%',
33
+ opacity: 0.7,
32
34
  },
33
35
  })
@@ -1,118 +1,214 @@
1
- import React, { useEffect, useRef } from 'react'
2
- import { View, Text, StyleSheet, ViewStyle, KeyboardAvoidingView, Platform } from 'react-native'
1
+ import React, { useCallback, useEffect, useRef } from 'react'
2
+ import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, Dimensions, Platform } from 'react-native'
3
3
  import {
4
4
  BottomSheetModal,
5
5
  BottomSheetView,
6
6
  BottomSheetScrollView,
7
7
  BottomSheetBackdrop,
8
8
  BottomSheetModalProvider,
9
+ BottomSheetTextInput,
10
+ BottomSheetFooter,
9
11
  type BottomSheetBackdropProps,
12
+ type BottomSheetFooterProps,
10
13
  } from '@gorhom/bottom-sheet'
11
- import { impactLight } from '../../utils/haptics'
14
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
15
+ import { AntDesign } from '@expo/vector-icons'
16
+ import { impactMedium } from '../../utils/haptics'
12
17
  import { useTheme } from '../../theme'
13
18
  import { s, vs, ms, mvs } from '../../utils/scaling'
14
19
 
20
+ const SCREEN_HEIGHT = Dimensions.get('window').height
21
+ const DEFAULT_MAX_HEIGHT = SCREEN_HEIGHT * 0.85
22
+ const isAndroid = Platform.OS === 'android'
23
+
15
24
  export { BottomSheetModalProvider }
25
+ // Re-export BottomSheetTextInput as SheetTextInput for consumer convenience
26
+ export { BottomSheetTextInput as SheetTextInput }
16
27
 
17
28
  export interface SheetProps {
18
29
  open: boolean
19
30
  onClose: () => void
20
31
  title?: string
32
+ /** Secondary text below title. */
33
+ subtitle?: string
34
+ /** @deprecated Use `subtitle` instead. */
21
35
  description?: string
36
+ /** Show an X close button in the header. */
37
+ showCloseButton?: boolean
22
38
  children?: React.ReactNode
23
39
  /** Style for the inner content container. */
24
40
  style?: ViewStyle
25
- /** Render children inside BottomSheetScrollView so gestures are handled correctly on both platforms. */
41
+ /** Style for the content wrapper (outside the scroll container). */
42
+ contentStyle?: ViewStyle
43
+ /** Render children inside BottomSheetScrollView. */
26
44
  scrollable?: boolean
27
45
  /** Cap sheet height (dp). Children scroll when content exceeds this value. */
28
46
  maxHeight?: number
29
- /** Wrap content in KeyboardAvoidingView. Defaults to platform-appropriate behavior when set. */
30
- keyboardBehavior?: 'padding' | 'height' | 'position' | 'none'
31
- /** Extra vertical offset for the keyboard avoiding view. */
32
- keyboardOffset?: number
47
+ /**
48
+ * Keyboard behavior how the sheet responds to keyboard appearance.
49
+ * - 'interactive': offset sheet by keyboard size (default, works on both platforms)
50
+ * - 'fillParent': extend sheet to fill parent view (can cause restore issues with dynamic sizing)
51
+ * - 'extend': extend sheet to maximum snap point
52
+ *
53
+ * Default: 'interactive' on both platforms.
54
+ */
55
+ keyboardBehavior?: 'extend' | 'fillParent' | 'interactive'
56
+ /**
57
+ * Keyboard blur behavior — what happens when keyboard dismisses.
58
+ * - 'none': do nothing
59
+ * - 'restore': restore sheet to previous position (default)
60
+ */
61
+ keyboardBlurBehavior?: 'none' | 'restore'
62
+ /**
63
+ * Blur keyboard when user starts dragging the sheet down.
64
+ * Default: true (recommended for better UX)
65
+ */
66
+ enableBlurKeyboardOnGesture?: boolean
67
+ /**
68
+ * Android-only: defines keyboard input mode.
69
+ * - 'adjustPan': pan the sheet content (default, fixes restore issues with dynamic sizing)
70
+ * - 'adjustResize': resize the sheet container (can cause transparent gap on dismiss)
71
+ */
72
+ android_keyboardInputMode?: 'adjustPan' | 'adjustResize'
73
+ /** Sticky footer rendered below the scroll area. */
74
+ footer?: React.ReactNode
75
+ /**
76
+ * Array of snap points for the sheet (e.g., ['50%', '85%'] or [200, 500]).
77
+ * When provided, disables enableDynamicSizing.
78
+ * When omitted, sheet uses dynamic sizing (auto-fits content).
79
+ */
80
+ snapPoints?: (string | number)[]
33
81
  }
34
82
 
35
83
  export function Sheet({
36
84
  open,
37
85
  onClose,
38
86
  title,
87
+ subtitle,
39
88
  description,
89
+ showCloseButton = false,
40
90
  children,
41
91
  style,
92
+ contentStyle,
42
93
  scrollable,
43
94
  maxHeight,
44
95
  keyboardBehavior,
45
- keyboardOffset = 0,
96
+ keyboardBlurBehavior = 'restore',
97
+ enableBlurKeyboardOnGesture = true,
98
+ android_keyboardInputMode = 'adjustPan',
99
+ footer,
100
+ snapPoints,
46
101
  }: SheetProps) {
47
102
  const { colors } = useTheme()
103
+ const insets = useSafeAreaInsets()
48
104
  const ref = useRef<BottomSheetModal>(null)
105
+
106
+ // 'interactive' + 'adjustPan' works properly with enableDynamicSizing on both platforms
107
+ // 'fillParent' + 'adjustResize' causes restore issues (transparent gap when keyboard dismisses)
108
+ const effectiveKeyboardBehavior = keyboardBehavior ?? 'interactive'
49
109
 
50
110
  useEffect(() => {
51
111
  if (open) {
52
- impactLight()
112
+ impactMedium()
53
113
  ref.current?.present()
54
114
  } else {
55
115
  ref.current?.dismiss()
56
116
  }
57
117
  }, [open])
58
118
 
59
- const renderBackdrop = (props: BottomSheetBackdropProps) => (
119
+ const renderBackdrop = useCallback((props: BottomSheetBackdropProps) => (
60
120
  <BottomSheetBackdrop
61
121
  {...props}
62
122
  disappearsOnIndex={-1}
63
123
  appearsOnIndex={0}
64
124
  pressBehavior="close"
65
125
  />
66
- )
126
+ ), [])
127
+
128
+ const renderFooter = useCallback((props: BottomSheetFooterProps) => {
129
+ if (!footer) return null
130
+ return (
131
+ <BottomSheetFooter {...props}>
132
+ {footer}
133
+ </BottomSheetFooter>
134
+ )
135
+ }, [footer])
136
+
137
+ const effectiveSubtitle = subtitle ?? description
67
138
 
68
- const headerNode = (title || description) ? (
139
+ const showHeader = !!(title || effectiveSubtitle || showCloseButton)
140
+
141
+ const headerNode = showHeader ? (
69
142
  <View style={styles.header}>
70
- {title ? (
71
- <Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>{title}</Text>
72
- ) : null}
73
- {description ? (
74
- <Text style={[styles.description, { color: colors.foregroundMuted }]} allowFontScaling={true}>
75
- {description}
143
+ <View style={styles.headerRow}>
144
+ {title ? (
145
+ <Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>
146
+ {title}
147
+ </Text>
148
+ ) : <View style={{ flex: 1 }} />}
149
+ {showCloseButton ? (
150
+ <TouchableOpacity
151
+ onPress={onClose}
152
+ style={styles.closeButton}
153
+ activeOpacity={0.6}
154
+ touchSoundDisabled={true}
155
+ >
156
+ <AntDesign name="close" size={ms(18)} color={colors.foregroundMuted} />
157
+ </TouchableOpacity>
158
+ ) : null}
159
+ </View>
160
+ {effectiveSubtitle ? (
161
+ <Text style={[styles.subtitle, { color: colors.foregroundMuted }]} allowFontScaling={true}>
162
+ {effectiveSubtitle}
76
163
  </Text>
77
164
  ) : null}
78
165
  </View>
79
166
  ) : null
80
167
 
81
168
  const useScroll = scrollable || !!maxHeight
82
-
83
- const wrapKeyboard = (node: React.ReactNode) => {
84
- if (!keyboardBehavior || keyboardBehavior === 'none') return node
85
- const behavior = keyboardBehavior ?? Platform.select({ ios: 'padding', android: 'height' }) as 'padding' | 'height'
86
- return (
87
- <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset}>
88
- {node}
89
- </KeyboardAvoidingView>
90
- )
91
- }
92
-
169
+ const effectiveMaxHeight = maxHeight ?? DEFAULT_MAX_HEIGHT
170
+
171
+ // If snapPoints provided, disable dynamic sizing. Otherwise use dynamic sizing.
172
+ const useDynamicSizing = !snapPoints
173
+
93
174
  return (
94
175
  <BottomSheetModal
95
176
  ref={ref}
96
- enableDynamicSizing
177
+ enableDynamicSizing={useDynamicSizing}
178
+ snapPoints={snapPoints}
179
+ maxDynamicContentSize={useDynamicSizing ? effectiveMaxHeight : undefined}
97
180
  onDismiss={onClose}
98
181
  backdropComponent={renderBackdrop}
182
+ footerComponent={footer ? renderFooter : undefined}
99
183
  backgroundStyle={[styles.background, { backgroundColor: colors.card }]}
100
184
  handleIndicatorStyle={[styles.handle, { backgroundColor: colors.border }]}
101
185
  enablePanDownToClose
186
+ topInset={insets.top}
187
+ keyboardBehavior={effectiveKeyboardBehavior}
188
+ keyboardBlurBehavior={keyboardBlurBehavior}
189
+ android_keyboardInputMode={android_keyboardInputMode}
190
+ enableBlurKeyboardOnGesture={enableBlurKeyboardOnGesture}
102
191
  >
103
- <BottomSheetView style={maxHeight ? { maxHeight } : undefined}>
104
- {wrapKeyboard(useScroll ? (
105
- <BottomSheetScrollView contentContainerStyle={[styles.content, style]}>
106
- {headerNode}
107
- {children}
108
- </BottomSheetScrollView>
109
- ) : (
110
- <BottomSheetView style={[styles.content, style]}>
111
- {headerNode}
112
- {children}
113
- </BottomSheetView>
114
- ))}
115
- </BottomSheetView>
192
+ {useScroll ? (
193
+ <BottomSheetScrollView
194
+ contentContainerStyle={[
195
+ styles.scrollContent,
196
+ style,
197
+ ]}
198
+ style={contentStyle}
199
+ showsVerticalScrollIndicator={true}
200
+ indicatorStyle="black"
201
+ persistentScrollbar={isAndroid}
202
+ >
203
+ {headerNode}
204
+ {children}
205
+ </BottomSheetScrollView>
206
+ ) : (
207
+ <BottomSheetView style={[styles.content, contentStyle, style]}>
208
+ {headerNode}
209
+ {children}
210
+ </BottomSheetView>
211
+ )}
116
212
  </BottomSheetModal>
117
213
  )
118
214
  }
@@ -127,21 +223,38 @@ const styles = StyleSheet.create({
127
223
  height: vs(4),
128
224
  borderRadius: ms(2),
129
225
  },
130
- content: {
131
- paddingHorizontal: s(24),
132
- paddingBottom: vs(32),
133
- },
134
226
  header: {
135
- gap: vs(8),
136
- marginBottom: vs(16),
227
+ paddingHorizontal: s(16),
228
+ paddingTop: vs(4),
229
+ paddingBottom: vs(12),
230
+ gap: vs(4),
231
+ },
232
+ headerRow: {
233
+ flexDirection: 'row',
234
+ alignItems: 'center',
235
+ justifyContent: 'space-between',
137
236
  },
138
237
  title: {
139
238
  fontFamily: 'Poppins-SemiBold',
140
239
  fontSize: ms(18),
240
+ flex: 1,
141
241
  },
142
- description: {
242
+ subtitle: {
143
243
  fontFamily: 'Poppins-Regular',
144
244
  fontSize: ms(14),
145
245
  lineHeight: mvs(20),
146
246
  },
247
+ closeButton: {
248
+ padding: s(4),
249
+ marginLeft: s(8),
250
+ },
251
+ content: {
252
+ paddingHorizontal: s(16),
253
+ paddingBottom: vs(32),
254
+ },
255
+ scrollContent: {
256
+ paddingHorizontal: s(16),
257
+ paddingBottom: vs(32),
258
+ paddingRight: s(16),
259
+ },
147
260
  })
@@ -1,2 +1,2 @@
1
- export { Sheet, BottomSheetModalProvider } from './Sheet'
1
+ export { Sheet, BottomSheetModalProvider, SheetTextInput } from './Sheet'
2
2
  export type { SheetProps } from './Sheet'
@@ -49,11 +49,11 @@ function TabTrigger({
49
49
  const scale = useRef(new Animated.Value(1)).current
50
50
 
51
51
  const handlePressIn = () => {
52
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
52
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, stiffness: 600, damping: 35, mass: 0.8 }).start()
53
53
  }
54
54
 
55
55
  const handlePressOut = () => {
56
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
56
+ Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, stiffness: 280, damping: 22, mass: 0.8 }).start()
57
57
  }
58
58
 
59
59
  const isUnderline = variant === 'underline'
@@ -108,8 +108,8 @@ export function Tabs({ tabs, variant = 'pill', value, onValueChange, children, s
108
108
  if (!layout) return
109
109
  if (animate) {
110
110
  Animated.parallel([
111
- Animated.spring(pillX, { toValue: layout.x, useNativeDriver: false, speed: 20, bounciness: 0 }),
112
- Animated.spring(pillWidth, { toValue: layout.width, useNativeDriver: false, speed: 20, bounciness: 0 }),
111
+ Animated.spring(pillX, { toValue: layout.x, useNativeDriver: false, stiffness: 380, damping: 38, mass: 1.0 }),
112
+ Animated.spring(pillWidth, { toValue: layout.width, useNativeDriver: false, stiffness: 380, damping: 38, mass: 1.0 }),
113
113
  ]).start()
114
114
  } else {
115
115
  pillX.setValue(layout.x)