@retray-dev/ui-kit 1.0.0 → 1.6.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.
@@ -1,5 +1,5 @@
1
- import React from 'react'
2
- import { TouchableOpacity, View, Text, StyleSheet, ViewStyle } from 'react-native'
1
+ import React, { useRef } from 'react'
2
+ import { TouchableOpacity, Animated, View, Text, StyleSheet, ViewStyle } from 'react-native'
3
3
  import * as Haptics from 'expo-haptics'
4
4
  import { useTheme } from '../../theme'
5
5
 
@@ -17,6 +17,67 @@ export interface RadioGroupProps {
17
17
  style?: ViewStyle
18
18
  }
19
19
 
20
+ function RadioItem({
21
+ option,
22
+ selected,
23
+ onSelect,
24
+ }: {
25
+ option: RadioOption
26
+ selected: boolean
27
+ onSelect: () => void
28
+ }) {
29
+ const { colors } = useTheme()
30
+ const scale = useRef(new Animated.Value(1)).current
31
+
32
+ const handlePressIn = () => {
33
+ if (option.disabled) return
34
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: true, speed: 40, bounciness: 0 }).start()
35
+ }
36
+
37
+ const handlePressOut = () => {
38
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
39
+ }
40
+
41
+ return (
42
+ <Animated.View style={{ transform: [{ scale }] }}>
43
+ <TouchableOpacity
44
+ style={styles.row}
45
+ onPress={() => {
46
+ if (!option.disabled) {
47
+ Haptics.selectionAsync()
48
+ onSelect()
49
+ }
50
+ }}
51
+ onPressIn={handlePressIn}
52
+ onPressOut={handlePressOut}
53
+ activeOpacity={1}
54
+ touchSoundDisabled={true}
55
+ disabled={option.disabled}
56
+ >
57
+ <View
58
+ style={[
59
+ styles.radio,
60
+ {
61
+ borderColor: selected ? colors.primary : colors.border,
62
+ opacity: option.disabled ? 0.45 : 1,
63
+ },
64
+ ]}
65
+ >
66
+ {selected ? <View style={[styles.dot, { backgroundColor: colors.primary }]} /> : null}
67
+ </View>
68
+ <Text
69
+ style={[
70
+ styles.label,
71
+ { color: option.disabled ? colors.mutedForeground : colors.foreground },
72
+ ]}
73
+ >
74
+ {option.label}
75
+ </Text>
76
+ </TouchableOpacity>
77
+ </Animated.View>
78
+ )
79
+ }
80
+
20
81
  export function RadioGroup({
21
82
  options,
22
83
  value,
@@ -24,50 +85,16 @@ export function RadioGroup({
24
85
  orientation = 'vertical',
25
86
  style,
26
87
  }: RadioGroupProps) {
27
- const { colors } = useTheme()
28
-
29
88
  return (
30
- <View
31
- style={[
32
- styles.container,
33
- orientation === 'horizontal' && styles.horizontal,
34
- style,
35
- ]}
36
- >
37
- {options.map((option) => {
38
- const selected = option.value === value
39
- return (
40
- <TouchableOpacity
41
- key={option.value}
42
- style={styles.row}
43
- onPress={() => { if (!option.disabled) { Haptics.selectionAsync(); onValueChange?.(option.value) } }}
44
- activeOpacity={0.7}
45
- disabled={option.disabled}
46
- >
47
- <View
48
- style={[
49
- styles.radio,
50
- {
51
- borderColor: selected ? colors.primary : colors.border,
52
- opacity: option.disabled ? 0.45 : 1,
53
- },
54
- ]}
55
- >
56
- {selected ? (
57
- <View style={[styles.dot, { backgroundColor: colors.primary }]} />
58
- ) : null}
59
- </View>
60
- <Text
61
- style={[
62
- styles.label,
63
- { color: option.disabled ? colors.mutedForeground : colors.foreground },
64
- ]}
65
- >
66
- {option.label}
67
- </Text>
68
- </TouchableOpacity>
69
- )
70
- })}
89
+ <View style={[styles.container, orientation === 'horizontal' && styles.horizontal, style]}>
90
+ {options.map((option) => (
91
+ <RadioItem
92
+ key={option.value}
93
+ option={option}
94
+ selected={option.value === value}
95
+ onSelect={() => onValueChange?.(option.value)}
96
+ />
97
+ ))}
71
98
  </View>
72
99
  )
73
100
  }
@@ -1,13 +1,11 @@
1
- import React, { useState } from 'react'
1
+ import React, { useRef, useCallback } from 'react'
2
+ import { View, Text, TouchableOpacity, Animated, StyleSheet, ViewStyle } from 'react-native'
2
3
  import {
3
- Modal,
4
- View,
5
- Text,
6
- TouchableOpacity,
7
- FlatList,
8
- StyleSheet,
9
- ViewStyle,
10
- } from 'react-native'
4
+ BottomSheetModal,
5
+ BottomSheetView,
6
+ BottomSheetBackdrop,
7
+ type BottomSheetBackdropProps,
8
+ } from '@gorhom/bottom-sheet'
11
9
  import * as Haptics from 'expo-haptics'
12
10
  import { useTheme } from '../../theme'
13
11
 
@@ -21,8 +19,10 @@ export interface SelectProps {
21
19
  options: SelectOption[]
22
20
  value?: string
23
21
  onValueChange?: (value: string) => void
22
+ /** Text shown when no option is selected. Defaults to `'Select an option'`. */
24
23
  placeholder?: string
25
24
  label?: string
25
+ /** Red helper text; also changes trigger border to `destructive` color. */
26
26
  error?: string
27
27
  disabled?: boolean
28
28
  style?: ViewStyle
@@ -39,27 +39,57 @@ export function Select({
39
39
  style,
40
40
  }: SelectProps) {
41
41
  const { colors } = useTheme()
42
- const [open, setOpen] = useState(false)
42
+ const bottomSheetRef = useRef<BottomSheetModal>(null)
43
+ const scale = useRef(new Animated.Value(1)).current
43
44
 
44
45
  const selected = options.find((o) => o.value === value)
45
46
 
47
+ const handlePressIn = () => {
48
+ if (disabled) return
49
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: true, speed: 40, bounciness: 0 }).start()
50
+ }
51
+
52
+ const handlePressOut = () => {
53
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
54
+ }
55
+
56
+ const handleOpen = () => {
57
+ if (!disabled) {
58
+ Haptics.selectionAsync()
59
+ bottomSheetRef.current?.present()
60
+ }
61
+ }
62
+
63
+ const renderBackdrop = useCallback(
64
+ (props: BottomSheetBackdropProps) => (
65
+ <BottomSheetBackdrop
66
+ {...props}
67
+ disappearsOnIndex={-1}
68
+ appearsOnIndex={0}
69
+ pressBehavior="close"
70
+ />
71
+ ),
72
+ []
73
+ )
74
+
46
75
  return (
47
76
  <View style={[styles.container, style]}>
48
- {label ? (
49
- <Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
50
- ) : null}
77
+ {label ? <Text style={[styles.label, { color: colors.foreground }]}>{label}</Text> : null}
51
78
 
79
+ <Animated.View style={{ transform: [{ scale }], opacity: disabled ? 0.45 : 1 }}>
52
80
  <TouchableOpacity
53
81
  style={[
54
82
  styles.trigger,
55
83
  {
56
84
  borderColor: error ? colors.destructive : colors.border,
57
85
  backgroundColor: colors.background,
58
- opacity: disabled ? 0.45 : 1,
59
86
  },
60
87
  ]}
61
- onPress={() => { if (!disabled) { Haptics.selectionAsync(); setOpen(true) } }}
62
- activeOpacity={0.7}
88
+ onPress={handleOpen}
89
+ onPressIn={handlePressIn}
90
+ onPressOut={handlePressOut}
91
+ activeOpacity={1}
92
+ touchSoundDisabled={true}
63
93
  >
64
94
  <Text
65
95
  style={[
@@ -72,54 +102,61 @@ export function Select({
72
102
  </Text>
73
103
  <Text style={[styles.chevron, { color: colors.mutedForeground }]}>▾</Text>
74
104
  </TouchableOpacity>
105
+ </Animated.View>
75
106
 
76
107
  {error ? (
77
108
  <Text style={[styles.helperText, { color: colors.destructive }]}>{error}</Text>
78
109
  ) : null}
79
110
 
80
- <Modal transparent visible={open} onRequestClose={() => setOpen(false)} animationType="fade">
81
- <TouchableOpacity style={styles.overlay} onPress={() => setOpen(false)} activeOpacity={1}>
82
- <View style={[styles.list, { backgroundColor: colors.card, borderColor: colors.border }]}>
83
- <FlatList
84
- data={options}
85
- keyExtractor={(item) => item.value}
86
- renderItem={({ item }) => {
87
- const isSelected = item.value === value
88
- return (
89
- <TouchableOpacity
90
- style={[
91
- styles.option,
92
- isSelected && { backgroundColor: colors.accent },
93
- item.disabled && styles.disabledOption,
94
- ]}
95
- onPress={() => {
96
- if (!item.disabled) {
97
- Haptics.selectionAsync()
98
- onValueChange?.(item.value)
99
- setOpen(false)
100
- }
101
- }}
102
- activeOpacity={0.7}
103
- >
104
- <Text
105
- style={[
106
- styles.optionText,
107
- { color: item.disabled ? colors.mutedForeground : colors.foreground },
108
- isSelected && { fontWeight: '500' },
109
- ]}
110
- >
111
- {item.label}
112
- </Text>
113
- {isSelected ? (
114
- <Text style={[styles.checkmark, { color: colors.primary }]}>✓</Text>
115
- ) : null}
116
- </TouchableOpacity>
117
- )
118
- }}
119
- />
120
- </View>
121
- </TouchableOpacity>
122
- </Modal>
111
+ <BottomSheetModal
112
+ ref={bottomSheetRef}
113
+ enableDynamicSizing
114
+ enablePanDownToClose
115
+ backdropComponent={renderBackdrop}
116
+ backgroundStyle={[styles.sheetBackground, { backgroundColor: colors.card }]}
117
+ handleIndicatorStyle={[styles.sheetHandle, { backgroundColor: colors.border }]}
118
+ >
119
+ <BottomSheetView style={styles.sheetContent}>
120
+ {label ? (
121
+ <Text style={[styles.sheetTitle, { color: colors.foreground }]}>{label}</Text>
122
+ ) : null}
123
+ {options.map((item) => {
124
+ const isSelected = item.value === value
125
+ return (
126
+ <TouchableOpacity
127
+ key={item.value}
128
+ style={[
129
+ styles.option,
130
+ isSelected && { backgroundColor: colors.accent },
131
+ item.disabled && styles.disabledOption,
132
+ ]}
133
+ onPress={() => {
134
+ if (!item.disabled) {
135
+ Haptics.selectionAsync()
136
+ onValueChange?.(item.value)
137
+ bottomSheetRef.current?.dismiss()
138
+ }
139
+ }}
140
+ activeOpacity={0.7}
141
+ touchSoundDisabled={true}
142
+ >
143
+ <Text
144
+ style={[
145
+ styles.optionText,
146
+ { color: item.disabled ? colors.mutedForeground : colors.foreground },
147
+ isSelected && { fontWeight: '500' },
148
+ ]}
149
+ >
150
+ {item.label}
151
+ </Text>
152
+ {isSelected ? (
153
+ <Text style={[styles.checkmark, { color: colors.primary }]}>✓</Text>
154
+ ) : null}
155
+ </TouchableOpacity>
156
+ )
157
+ })}
158
+ </BottomSheetView>
159
+ </BottomSheetModal>
123
160
  </View>
124
161
  )
125
162
  }
@@ -153,27 +190,36 @@ const styles = StyleSheet.create({
153
190
  helperText: {
154
191
  fontSize: 12,
155
192
  },
156
- overlay: {
157
- flex: 1,
158
- backgroundColor: 'rgba(0,0,0,0.3)',
159
- justifyContent: 'center',
160
- padding: 24,
193
+ sheetBackground: {
194
+ borderTopLeftRadius: 16,
195
+ borderTopRightRadius: 16,
161
196
  },
162
- list: {
163
- borderRadius: 12,
164
- borderWidth: 1,
165
- maxHeight: 300,
166
- overflow: 'hidden',
197
+ sheetHandle: {
198
+ width: 36,
199
+ height: 4,
200
+ borderRadius: 2,
201
+ },
202
+ sheetContent: {
203
+ paddingHorizontal: 16,
204
+ paddingBottom: 32,
205
+ },
206
+ sheetTitle: {
207
+ fontSize: 16,
208
+ fontWeight: '600',
209
+ paddingVertical: 12,
210
+ paddingHorizontal: 4,
167
211
  },
168
212
  option: {
169
213
  flexDirection: 'row',
170
214
  alignItems: 'center',
171
215
  justifyContent: 'space-between',
172
216
  paddingHorizontal: 12,
173
- paddingVertical: 10,
217
+ paddingVertical: 14,
218
+ borderRadius: 8,
174
219
  },
175
220
  optionText: {
176
221
  fontSize: 15,
222
+ flex: 1,
177
223
  },
178
224
  disabledOption: {
179
225
  opacity: 0.45,
@@ -181,5 +227,6 @@ const styles = StyleSheet.create({
181
227
  checkmark: {
182
228
  fontSize: 14,
183
229
  fontWeight: '600',
230
+ marginLeft: 8,
184
231
  },
185
232
  })
@@ -18,7 +18,12 @@ export interface SheetProps {
18
18
  title?: string
19
19
  description?: string
20
20
  children?: React.ReactNode
21
+ /**
22
+ * Heights the sheet can snap to. Accepts percentage strings (`'50%'`) or
23
+ * absolute point values (`300`). Defaults to `['50%']`.
24
+ */
21
25
  snapPoints?: (string | number)[]
26
+ /** Style for the inner `BottomSheetView` content container. */
22
27
  style?: ViewStyle
23
28
  }
24
29
 
@@ -63,13 +68,15 @@ export function Sheet({
63
68
  enablePanDownToClose
64
69
  >
65
70
  <BottomSheetView style={[styles.content, style]}>
66
- {(title || description) ? (
71
+ {title || description ? (
67
72
  <View style={styles.header}>
68
73
  {title ? (
69
74
  <Text style={[styles.title, { color: colors.cardForeground }]}>{title}</Text>
70
75
  ) : null}
71
76
  {description ? (
72
- <Text style={[styles.description, { color: colors.mutedForeground }]}>{description}</Text>
77
+ <Text style={[styles.description, { color: colors.mutedForeground }]}>
78
+ {description}
79
+ </Text>
73
80
  ) : null}
74
81
  </View>
75
82
  ) : null}
@@ -1,5 +1,6 @@
1
- import React, { useEffect, useRef } from 'react'
2
- import { Animated, StyleSheet, ViewStyle } from 'react-native'
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import { Animated, StyleSheet, View, ViewStyle } from 'react-native'
3
+ import { LinearGradient } from 'expo-linear-gradient'
3
4
  import { useTheme } from '../../theme'
4
5
 
5
6
  export interface SkeletonProps {
@@ -10,31 +11,53 @@ export interface SkeletonProps {
10
11
  }
11
12
 
12
13
  export function Skeleton({ width = '100%', height = 16, borderRadius = 6, style }: SkeletonProps) {
13
- const { colors } = useTheme()
14
- const opacity = useRef(new Animated.Value(1)).current
14
+ const { colors, colorScheme } = useTheme()
15
+ const shimmerAnim = useRef(new Animated.Value(0)).current
16
+ const [containerWidth, setContainerWidth] = useState(300)
17
+
18
+ const shimmerHighlight =
19
+ colorScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'
15
20
 
16
21
  useEffect(() => {
17
22
  const animation = Animated.loop(
18
- Animated.sequence([
19
- Animated.timing(opacity, { toValue: 0.4, duration: 800, useNativeDriver: true }),
20
- Animated.timing(opacity, { toValue: 1, duration: 800, useNativeDriver: true }),
21
- ])
23
+ Animated.timing(shimmerAnim, {
24
+ toValue: 1,
25
+ duration: 1200,
26
+ useNativeDriver: true,
27
+ })
22
28
  )
23
29
  animation.start()
24
30
  return () => animation.stop()
25
- }, [opacity])
31
+ }, [shimmerAnim])
32
+
33
+ const translateX = shimmerAnim.interpolate({
34
+ inputRange: [0, 1],
35
+ outputRange: [-containerWidth, containerWidth],
36
+ })
26
37
 
27
38
  return (
28
- <Animated.View
39
+ <View
29
40
  style={[
30
41
  styles.base,
31
- { width: width as any, height, borderRadius, backgroundColor: colors.muted, opacity },
42
+ { width: width as any, height, borderRadius, backgroundColor: colors.muted },
32
43
  style,
33
44
  ]}
34
- />
45
+ onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
46
+ >
47
+ <Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateX }] }]}>
48
+ <LinearGradient
49
+ colors={['transparent', shimmerHighlight, 'transparent']}
50
+ start={{ x: 0, y: 0 }}
51
+ end={{ x: 1, y: 0 }}
52
+ style={StyleSheet.absoluteFill}
53
+ />
54
+ </Animated.View>
55
+ </View>
35
56
  )
36
57
  }
37
58
 
38
59
  const styles = StyleSheet.create({
39
- base: {},
60
+ base: {
61
+ overflow: 'hidden',
62
+ },
40
63
  })
@@ -4,11 +4,15 @@ import * as Haptics from 'expo-haptics'
4
4
  import { useTheme } from '../../theme'
5
5
 
6
6
  export interface SliderProps {
7
+ /** Current value. Controlled when provided; falls back to internal state otherwise. */
7
8
  value?: number
8
9
  minimumValue?: number
9
10
  maximumValue?: number
11
+ /** Snap interval. `0` (default) means continuous (no snapping). */
10
12
  step?: number
13
+ /** Called on every move while dragging. */
11
14
  onValueChange?: (value: number) => void
15
+ /** Called once when the user releases the thumb. */
12
16
  onSlidingComplete?: (value: number) => void
13
17
  disabled?: boolean
14
18
  style?: ViewStyle
@@ -86,10 +90,7 @@ export function Slider({
86
90
  >
87
91
  <View style={[styles.track, { backgroundColor: colors.muted }]}>
88
92
  <View
89
- style={[
90
- styles.range,
91
- { width: `${percent}%` as any, backgroundColor: colors.primary },
92
- ]}
93
+ style={[styles.range, { width: `${percent}%` as any, backgroundColor: colors.primary }]}
93
94
  />
94
95
  </View>
95
96
  <View
@@ -17,11 +17,5 @@ const sizeMap: Record<SpinnerSize, 'small' | 'large'> = {
17
17
 
18
18
  export function Spinner({ size = 'md', color, ...props }: SpinnerProps) {
19
19
  const { colors } = useTheme()
20
- return (
21
- <ActivityIndicator
22
- size={sizeMap[size]}
23
- color={color ?? colors.primary}
24
- {...props}
25
- />
26
- )
20
+ return <ActivityIndicator size={sizeMap[size]} color={color ?? colors.primary} {...props} />
27
21
  }
@@ -43,9 +43,13 @@ export function Switch({ checked = false, onCheckedChange, disabled, style }: Sw
43
43
 
44
44
  return (
45
45
  <TouchableOpacity
46
- onPress={() => { Haptics.selectionAsync(); onCheckedChange?.(!checked) }}
46
+ onPress={() => {
47
+ Haptics.selectionAsync()
48
+ onCheckedChange?.(!checked)
49
+ }}
47
50
  disabled={disabled}
48
51
  activeOpacity={0.8}
52
+ touchSoundDisabled={true}
49
53
  style={[styles.wrapper, { opacity: disabled ? 0.45 : 1 }, style]}
50
54
  >
51
55
  <Animated.View style={[styles.track, { backgroundColor: trackColor }]}>