@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,6 @@
1
1
  import React, { useState, useRef, useEffect } from 'react'
2
2
  import { View, TouchableOpacity, Text, Animated, StyleSheet, ViewStyle } from 'react-native'
3
+ import * as Haptics from 'expo-haptics'
3
4
  import { useTheme } from '../../theme'
4
5
 
5
6
  export interface TabItem {
@@ -9,6 +10,10 @@ export interface TabItem {
9
10
 
10
11
  export interface TabsProps {
11
12
  tabs: TabItem[]
13
+ /**
14
+ * Controlled active tab value. When omitted the component manages state internally
15
+ * (uncontrolled), defaulting to the first tab.
16
+ */
12
17
  value?: string
13
18
  onValueChange?: (value: string) => void
14
19
  children?: React.ReactNode
@@ -22,6 +27,53 @@ export interface TabsContentProps {
22
27
  style?: ViewStyle
23
28
  }
24
29
 
30
+ function TabTrigger({
31
+ tab,
32
+ isActive,
33
+ onPress,
34
+ onLayout,
35
+ }: {
36
+ tab: TabItem
37
+ isActive: boolean
38
+ onPress: () => void
39
+ onLayout: (e: any) => void
40
+ }) {
41
+ const { colors } = useTheme()
42
+ const scale = useRef(new Animated.Value(1)).current
43
+
44
+ const handlePressIn = () => {
45
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: true, speed: 40, bounciness: 0 }).start()
46
+ }
47
+
48
+ const handlePressOut = () => {
49
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
50
+ }
51
+
52
+ return (
53
+ <TouchableOpacity
54
+ style={styles.trigger}
55
+ onPress={onPress}
56
+ onPressIn={handlePressIn}
57
+ onPressOut={handlePressOut}
58
+ onLayout={onLayout}
59
+ activeOpacity={1}
60
+ touchSoundDisabled={true}
61
+ >
62
+ <Animated.View style={{ transform: [{ scale }] }}>
63
+ <Text
64
+ style={[
65
+ styles.triggerLabel,
66
+ { color: isActive ? colors.foreground : colors.mutedForeground },
67
+ isActive && styles.activeTriggerLabel,
68
+ ]}
69
+ >
70
+ {tab.label}
71
+ </Text>
72
+ </Animated.View>
73
+ </TouchableOpacity>
74
+ )
75
+ }
76
+
25
77
  export function Tabs({ tabs, value, onValueChange, children, style }: TabsProps) {
26
78
  const [internal, setInternal] = useState(tabs[0]?.value ?? '')
27
79
  const { colors } = useTheme()
@@ -37,8 +89,18 @@ export function Tabs({ tabs, value, onValueChange, children, style }: TabsProps)
37
89
  if (!layout) return
38
90
  if (animate) {
39
91
  Animated.parallel([
40
- Animated.spring(pillX, { toValue: layout.x, useNativeDriver: false, speed: 20, bounciness: 0 }),
41
- Animated.spring(pillWidth, { toValue: layout.width, useNativeDriver: false, speed: 20, bounciness: 0 }),
92
+ Animated.spring(pillX, {
93
+ toValue: layout.x,
94
+ useNativeDriver: false,
95
+ speed: 20,
96
+ bounciness: 0,
97
+ }),
98
+ Animated.spring(pillWidth, {
99
+ toValue: layout.width,
100
+ useNativeDriver: false,
101
+ speed: 20,
102
+ bounciness: 0,
103
+ }),
42
104
  ]).start()
43
105
  } else {
44
106
  pillX.setValue(layout.x)
@@ -53,6 +115,7 @@ export function Tabs({ tabs, value, onValueChange, children, style }: TabsProps)
53
115
  }, [active])
54
116
 
55
117
  const handlePress = (v: string) => {
118
+ Haptics.selectionAsync()
56
119
  if (!value) setInternal(v)
57
120
  onValueChange?.(v)
58
121
  }
@@ -79,35 +142,22 @@ export function Tabs({ tabs, value, onValueChange, children, style }: TabsProps)
79
142
  },
80
143
  ]}
81
144
  />
82
- {tabs.map((tab) => {
83
- const isActive = tab.value === active
84
- return (
85
- <TouchableOpacity
86
- key={tab.value}
87
- style={styles.trigger}
88
- onPress={() => handlePress(tab.value)}
89
- activeOpacity={0.7}
90
- onLayout={(e) => {
91
- const { x, width } = e.nativeEvent.layout
92
- tabLayouts.current[tab.value] = { x, width }
93
- if (tab.value === active) {
94
- animatePill(tab.value, false)
95
- initialised.current = true
96
- }
97
- }}
98
- >
99
- <Text
100
- style={[
101
- styles.triggerLabel,
102
- { color: isActive ? colors.foreground : colors.mutedForeground },
103
- isActive && styles.activeTriggerLabel,
104
- ]}
105
- >
106
- {tab.label}
107
- </Text>
108
- </TouchableOpacity>
109
- )
110
- })}
145
+ {tabs.map((tab) => (
146
+ <TabTrigger
147
+ key={tab.value}
148
+ tab={tab}
149
+ isActive={tab.value === active}
150
+ onPress={() => handlePress(tab.value)}
151
+ onLayout={(e) => {
152
+ const { x, width } = e.nativeEvent.layout
153
+ tabLayouts.current[tab.value] = { x, width }
154
+ if (tab.value === active) {
155
+ animatePill(tab.value, false)
156
+ initialised.current = true
157
+ }
158
+ }}
159
+ />
160
+ ))}
111
161
  </View>
112
162
  {children}
113
163
  </View>
@@ -133,6 +183,7 @@ const styles = StyleSheet.create({
133
183
  paddingHorizontal: 12,
134
184
  borderRadius: 6,
135
185
  alignItems: 'center',
186
+ justifyContent: 'center',
136
187
  zIndex: 1,
137
188
  },
138
189
  triggerLabel: {
@@ -1,23 +1,36 @@
1
1
  import React, { useState } from 'react'
2
- import { TextInput, View, Text, StyleSheet, TextInputProps } from 'react-native'
2
+ import { TextInput, View, Text, StyleSheet, TextInputProps, ViewStyle } from 'react-native'
3
3
  import { useTheme } from '../../theme'
4
4
 
5
5
  export interface TextareaProps extends TextInputProps {
6
6
  label?: string
7
+ /** Red helper text below the textarea; also changes border to `destructive` color. Takes priority over `hint`. */
7
8
  error?: string
9
+ /** Helper text shown below the textarea when there is no error. */
8
10
  hint?: string
11
+ /** Number of visible text rows. Defaults to `4`. Controls `numberOfLines` and `minHeight`. */
9
12
  rows?: number
13
+ /** Style for the outer container `View`. Use `style` (from `TextInputProps`) to style the `TextInput` itself. */
14
+ containerStyle?: ViewStyle
10
15
  }
11
16
 
12
- export function Textarea({ label, error, hint, rows = 4, style, onFocus, onBlur, ...props }: TextareaProps) {
17
+ export function Textarea({
18
+ label,
19
+ error,
20
+ hint,
21
+ rows = 4,
22
+ containerStyle,
23
+ style,
24
+ onFocus,
25
+ onBlur,
26
+ ...props
27
+ }: TextareaProps) {
13
28
  const { colors } = useTheme()
14
29
  const [focused, setFocused] = useState(false)
15
30
 
16
31
  return (
17
- <View style={styles.container}>
18
- {label ? (
19
- <Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
20
- ) : null}
32
+ <View style={[styles.container, containerStyle]}>
33
+ {label ? <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>{label}</Text> : null}
21
34
  <TextInput
22
35
  multiline
23
36
  numberOfLines={rows}
@@ -32,17 +45,23 @@ export function Textarea({ label, error, hint, rows = 4, style, onFocus, onBlur,
32
45
  },
33
46
  style,
34
47
  ]}
35
- onFocus={(e) => { setFocused(true); onFocus?.(e) }}
36
- onBlur={(e) => { setFocused(false); onBlur?.(e) }}
48
+ onFocus={(e) => {
49
+ setFocused(true)
50
+ onFocus?.(e)
51
+ }}
52
+ onBlur={(e) => {
53
+ setFocused(false)
54
+ onBlur?.(e)
55
+ }}
37
56
  placeholderTextColor={colors.mutedForeground}
38
57
  allowFontScaling={true}
39
58
  {...props}
40
59
  />
41
60
  {error ? (
42
- <Text style={[styles.helperText, { color: colors.destructive }]}>{error}</Text>
61
+ <Text style={[styles.helperText, { color: colors.destructive }]} allowFontScaling={true}>{error}</Text>
43
62
  ) : null}
44
63
  {!error && hint ? (
45
- <Text style={[styles.helperText, { color: colors.mutedForeground }]}>{hint}</Text>
64
+ <Text style={[styles.helperText, { color: colors.mutedForeground }]} allowFontScaling={true}>{hint}</Text>
46
65
  ) : null}
47
66
  </View>
48
67
  )
@@ -1,5 +1,14 @@
1
- import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
2
- import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native'
1
+ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
2
+ import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
3
+ import Animated, {
4
+ useSharedValue,
5
+ useAnimatedStyle,
6
+ withSpring,
7
+ withTiming,
8
+ Easing,
9
+ } from 'react-native-reanimated'
10
+ import { scheduleOnRN } from 'react-native-worklets'
11
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler'
3
12
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
4
13
  import * as Haptics from 'expo-haptics'
5
14
  import { useTheme } from '../../theme'
@@ -11,6 +20,7 @@ export interface ToastItem {
11
20
  title?: string
12
21
  description?: string
13
22
  variant?: ToastVariant
23
+ /** Auto-dismiss delay in milliseconds. Defaults to `3000`. */
14
24
  duration?: number
15
25
  }
16
26
 
@@ -28,57 +38,83 @@ export function useToast() {
28
38
  return useContext(ToastContext)
29
39
  }
30
40
 
41
+ const SWIPE_THRESHOLD = 80
42
+ const VELOCITY_THRESHOLD = 800
43
+
31
44
  function ToastNotification({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
32
45
  const { colors } = useTheme()
33
- const translateY = useRef(new Animated.Value(-80)).current
34
- const opacity = useRef(new Animated.Value(0)).current
46
+ const translateY = useSharedValue(-80)
47
+ const translateX = useSharedValue(0)
48
+ const opacity = useSharedValue(0)
35
49
 
36
50
  useEffect(() => {
37
- Animated.parallel([
38
- Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 2 }),
39
- Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
40
- ]).start()
51
+ translateY.value = withTiming(0, { duration: 120, easing: Easing.out(Easing.exp) })
52
+ opacity.value = withTiming(1, { duration: 100 })
41
53
 
42
54
  const timer = setTimeout(() => {
43
- Animated.parallel([
44
- Animated.timing(translateY, { toValue: -80, duration: 200, useNativeDriver: true }),
45
- Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
46
- ]).start(onDismiss)
55
+ translateY.value = withTiming(-80, { duration: 200 })
56
+ opacity.value = withTiming(0, { duration: 200 }, (done) => {
57
+ if (done) scheduleOnRN(onDismiss)
58
+ })
47
59
  }, item.duration ?? 3000)
48
60
 
49
61
  return () => clearTimeout(timer)
50
62
  }, [])
51
63
 
64
+ const panGesture = Gesture.Pan()
65
+ .onUpdate((e) => {
66
+ translateX.value = e.translationX
67
+ })
68
+ .onEnd((e) => {
69
+ const shouldDismiss =
70
+ Math.abs(translateX.value) > SWIPE_THRESHOLD ||
71
+ Math.abs(e.velocityX) > VELOCITY_THRESHOLD
72
+ if (shouldDismiss) {
73
+ const direction = translateX.value > 0 ? 1 : -1
74
+ translateX.value = withTiming(direction * 500, { duration: 200 }, (done) => {
75
+ if (done) scheduleOnRN(onDismiss)
76
+ })
77
+ opacity.value = withTiming(0, { duration: 150 })
78
+ } else {
79
+ translateX.value = withSpring(0, { damping: 20, stiffness: 300 })
80
+ }
81
+ })
82
+
83
+ const animatedStyle = useAnimatedStyle(() => ({
84
+ opacity: opacity.value,
85
+ transform: [{ translateY: translateY.value }, { translateX: translateX.value }],
86
+ }))
87
+
52
88
  const bgColor = {
53
89
  default: colors.foreground,
54
90
  destructive: colors.destructive,
55
- success: '#16a34a',
91
+ success: colors.success,
56
92
  }[item.variant ?? 'default']
57
93
 
58
94
  const textColor = {
59
95
  default: colors.background,
60
96
  destructive: colors.destructiveForeground,
61
- success: '#ffffff',
97
+ success: colors.successForeground,
62
98
  }[item.variant ?? 'default']
63
99
 
64
100
  return (
65
- <Animated.View
66
- style={[styles.toast, { backgroundColor: bgColor, opacity, transform: [{ translateY }] }]}
67
- >
68
- <View style={styles.toastContent}>
69
- {item.title ? (
70
- <Text style={[styles.toastTitle, { color: textColor }]}>{item.title}</Text>
71
- ) : null}
72
- {item.description ? (
73
- <Text style={[styles.toastDescription, { color: textColor, opacity: 0.85 }]}>
74
- {item.description}
75
- </Text>
76
- ) : null}
77
- </View>
78
- <TouchableOpacity onPress={onDismiss} style={styles.dismissButton}>
79
- <Text style={[styles.dismissIcon, { color: textColor }]}>✕</Text>
80
- </TouchableOpacity>
81
- </Animated.View>
101
+ <GestureDetector gesture={panGesture}>
102
+ <Animated.View style={[styles.toast, { backgroundColor: bgColor }, animatedStyle]}>
103
+ <View style={styles.toastContent}>
104
+ {item.title ? (
105
+ <Text style={[styles.toastTitle, { color: textColor }]}>{item.title}</Text>
106
+ ) : null}
107
+ {item.description ? (
108
+ <Text style={[styles.toastDescription, { color: textColor, opacity: 0.85 }]}>
109
+ {item.description}
110
+ </Text>
111
+ ) : null}
112
+ </View>
113
+ <TouchableOpacity onPress={onDismiss} style={styles.dismissButton} touchSoundDisabled={true}>
114
+ <Text style={[styles.dismissIcon, { color: textColor }]}>✕</Text>
115
+ </TouchableOpacity>
116
+ </Animated.View>
117
+ </GestureDetector>
82
118
  )
83
119
  }
84
120
 
@@ -155,8 +191,8 @@ const styles = StyleSheet.create({
155
191
  fontSize: 13,
156
192
  },
157
193
  dismissButton: {
158
- padding: 4,
159
- marginLeft: 8,
194
+ padding: 12,
195
+ marginLeft: 4,
160
196
  },
161
197
  dismissIcon: {
162
198
  fontSize: 12,
@@ -1,5 +1,5 @@
1
- import React from 'react'
2
- import { TouchableOpacity, Text, StyleSheet, TouchableOpacityProps, ViewStyle } from 'react-native'
1
+ import React, { useRef } from 'react'
2
+ import { TouchableOpacity, Animated, Text, StyleSheet, TouchableOpacityProps, ViewStyle } from 'react-native'
3
3
  import * as Haptics from 'expo-haptics'
4
4
  import { useTheme } from '../../theme'
5
5
 
@@ -33,32 +33,44 @@ export function Toggle({
33
33
  ...props
34
34
  }: ToggleProps) {
35
35
  const { colors } = useTheme()
36
+ const scale = useRef(new Animated.Value(1)).current
37
+
38
+ const handlePressIn = () => {
39
+ if (disabled) return
40
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: true, speed: 40, bounciness: 0 }).start()
41
+ }
42
+
43
+ const handlePressOut = () => {
44
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
45
+ }
36
46
 
37
47
  const containerStyle: ViewStyle = pressed
38
48
  ? { backgroundColor: colors.accent }
39
49
  : variant === 'outline'
40
- ? { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border }
41
- : { backgroundColor: 'transparent' }
50
+ ? { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border }
51
+ : { backgroundColor: 'transparent' }
42
52
 
43
53
  const textColor = pressed ? colors.accentForeground : colors.foreground
44
54
 
45
55
  return (
46
- <TouchableOpacity
47
- style={[
48
- styles.base,
49
- containerStyle,
50
- sizeStyles[size],
51
- disabled && styles.disabled,
52
- style,
53
- ]}
54
- onPress={() => { Haptics.selectionAsync(); onPressedChange?.(!pressed) }}
55
- disabled={disabled}
56
- activeOpacity={0.7}
57
- {...props}
58
- >
59
- {icon}
60
- {label ? <Text style={[styles.label, { color: textColor }]}>{label}</Text> : null}
61
- </TouchableOpacity>
56
+ <Animated.View style={{ transform: [{ scale }] }}>
57
+ <TouchableOpacity
58
+ style={[styles.base, containerStyle, sizeStyles[size], disabled && styles.disabled, style]}
59
+ onPress={() => {
60
+ Haptics.selectionAsync()
61
+ onPressedChange?.(!pressed)
62
+ }}
63
+ onPressIn={handlePressIn}
64
+ onPressOut={handlePressOut}
65
+ disabled={disabled}
66
+ activeOpacity={1}
67
+ touchSoundDisabled={true}
68
+ {...props}
69
+ >
70
+ {icon}
71
+ {label ? <Text style={[styles.label, { color: textColor }]}>{label}</Text> : null}
72
+ </TouchableOpacity>
73
+ </Animated.View>
62
74
  )
63
75
  }
64
76
 
package/src/index.ts CHANGED
@@ -27,3 +27,4 @@ export * from './components/Slider'
27
27
  export * from './components/Sheet'
28
28
  export * from './components/Select'
29
29
  export * from './components/Toast'
30
+ export * from './components/CurrencyInput'
@@ -18,6 +18,8 @@ export const defaultLight: ThemeColors = {
18
18
  border: '#e5e5e5',
19
19
  input: '#e5e5e5',
20
20
  ring: '#a3a3a3',
21
+ success: '#16a34a',
22
+ successForeground: '#ffffff',
21
23
  }
22
24
 
23
25
  export const defaultDark: ThemeColors = {
@@ -38,4 +40,6 @@ export const defaultDark: ThemeColors = {
38
40
  border: '#2a2a2a',
39
41
  input: '#2a2a2a',
40
42
  ring: '#d4d4d4',
43
+ success: '#22c55e',
44
+ successForeground: '#ffffff',
41
45
  }
@@ -16,6 +16,8 @@ export type ThemeColors = {
16
16
  border: string
17
17
  input: string
18
18
  ring: string
19
+ success: string
20
+ successForeground: string
19
21
  }
20
22
 
21
23
  export type Theme = {