@retray-dev/ui-kit 2.5.1 → 2.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.
- package/COMPONENTS.md +153 -6
- package/dist/index.d.mts +98 -8
- package/dist/index.d.ts +98 -8
- package/dist/index.js +591 -505
- package/dist/index.mjs +533 -436
- package/package.json +23 -21
- package/src/components/Accordion/Accordion.tsx +61 -57
- package/src/components/Alert/Alert.tsx +11 -10
- package/src/components/AlertBanner/AlertBanner.tsx +23 -10
- package/src/components/Avatar/Avatar.tsx +9 -8
- package/src/components/Badge/Badge.tsx +27 -12
- package/src/components/Button/Button.tsx +30 -12
- package/src/components/Card/Card.tsx +12 -11
- package/src/components/Checkbox/Checkbox.tsx +16 -13
- package/src/components/Chip/Chip.tsx +8 -7
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +12 -11
- package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +2 -1
- package/src/components/CurrencyInput/CurrencyInput.tsx +2 -1
- package/src/components/EmptyState/EmptyState.tsx +34 -21
- package/src/components/Input/Input.tsx +44 -22
- package/src/components/LabelValue/LabelValue.tsx +6 -5
- package/src/components/ListItem/ListItem.tsx +46 -22
- package/src/components/MonthPicker/MonthPicker.tsx +9 -8
- package/src/components/Progress/Progress.tsx +2 -1
- package/src/components/RadioGroup/RadioGroup.tsx +18 -15
- package/src/components/Select/Select.tsx +25 -24
- package/src/components/Sheet/Sheet.tsx +15 -14
- package/src/components/Slider/Slider.tsx +7 -6
- package/src/components/Switch/Switch.tsx +7 -6
- package/src/components/Tabs/Tabs.tsx +17 -14
- package/src/components/Text/Text.tsx +7 -6
- package/src/components/Textarea/Textarea.tsx +9 -8
- package/src/components/Toast/Toast.tsx +30 -19
- package/src/components/Toggle/Toggle.tsx +36 -10
- package/src/index.ts +4 -0
- package/src/utils/haptics.ts +32 -0
- package/src/utils/icons.ts +73 -0
- package/src/utils/scaling.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retray-dev/ui-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Personal UI Kit for React Native / Expo",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -37,18 +37,19 @@
|
|
|
37
37
|
],
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"
|
|
41
|
-
"
|
|
40
|
+
"@expo/vector-icons": ">=14.0.0",
|
|
41
|
+
"@gorhom/bottom-sheet": ">=5.0.0",
|
|
42
|
+
"@react-native-community/slider": ">=4.0.0",
|
|
43
|
+
"@react-native-picker/picker": ">=2.0.0",
|
|
42
44
|
"expo-haptics": ">=14.0.0",
|
|
43
45
|
"expo-linear-gradient": ">=13.0.0",
|
|
44
|
-
"
|
|
45
|
-
"react-native
|
|
46
|
+
"react": ">=17",
|
|
47
|
+
"react-native": ">=0.70",
|
|
46
48
|
"react-native-gesture-handler": ">=2.0.0",
|
|
47
|
-
"react-native-
|
|
49
|
+
"react-native-reanimated": ">=4.0.0",
|
|
48
50
|
"react-native-safe-area-context": ">=4.0.0",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"@expo/vector-icons": ">=14.0.0"
|
|
51
|
+
"react-native-size-matters": ">=0.4.0",
|
|
52
|
+
"react-native-worklets": ">=0.8.0"
|
|
52
53
|
},
|
|
53
54
|
"pnpm": {
|
|
54
55
|
"overrides": {
|
|
@@ -62,27 +63,28 @@
|
|
|
62
63
|
]
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
66
|
+
"@eslint/js": "^9.0.0",
|
|
67
|
+
"react-native-size-matters": "^0.4.2",
|
|
68
|
+
"@expo/vector-icons": "^15.1.1",
|
|
65
69
|
"@gorhom/bottom-sheet": "^5.0.0",
|
|
66
|
-
"@react-native-
|
|
67
|
-
"@react-native-
|
|
70
|
+
"@react-native-community/slider": "5.0.1",
|
|
71
|
+
"@react-native-picker/picker": "2.11.1",
|
|
68
72
|
"@types/react": "^19.1.0",
|
|
69
|
-
"
|
|
73
|
+
"eslint": "^9.0.0",
|
|
74
|
+
"eslint-config-prettier": "^10.0.0",
|
|
75
|
+
"eslint-plugin-react": "^7.37.0",
|
|
76
|
+
"eslint-plugin-react-hooks": "^5.0.0",
|
|
70
77
|
"expo-haptics": "~15.0.8",
|
|
71
78
|
"expo-linear-gradient": "~15.0.8",
|
|
79
|
+
"prettier": "^3.0.0",
|
|
72
80
|
"react": "19.1.0",
|
|
73
81
|
"react-native": "0.81.5",
|
|
74
82
|
"react-native-gesture-handler": "~2.28.0",
|
|
75
83
|
"react-native-reanimated": "~4.1.1",
|
|
76
|
-
"react-native-worklets": "~0.5.1",
|
|
77
84
|
"react-native-safe-area-context": "~5.6.2",
|
|
78
|
-
"
|
|
79
|
-
"@eslint/js": "^9.0.0",
|
|
80
|
-
"typescript-eslint": "^8.0.0",
|
|
81
|
-
"eslint-plugin-react": "^7.37.0",
|
|
82
|
-
"eslint-plugin-react-hooks": "^5.0.0",
|
|
83
|
-
"eslint-config-prettier": "^10.0.0",
|
|
84
|
-
"prettier": "^3.0.0",
|
|
85
|
+
"react-native-worklets": "~0.5.1",
|
|
85
86
|
"tsup": "^8.0.0",
|
|
86
|
-
"typescript": "^5.4.0"
|
|
87
|
+
"typescript": "^5.4.0",
|
|
88
|
+
"typescript-eslint": "^8.0.0"
|
|
87
89
|
}
|
|
88
90
|
}
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import React, { useState
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
5
|
-
|
|
6
|
-
Animated,
|
|
5
|
+
Pressable,
|
|
7
6
|
StyleSheet,
|
|
8
|
-
LayoutChangeEvent,
|
|
9
7
|
ViewStyle,
|
|
10
8
|
} from 'react-native'
|
|
11
|
-
import
|
|
9
|
+
import Animated, {
|
|
12
10
|
useSharedValue,
|
|
11
|
+
useDerivedValue,
|
|
13
12
|
useAnimatedStyle,
|
|
14
13
|
withTiming,
|
|
15
14
|
Easing,
|
|
16
15
|
} from 'react-native-reanimated'
|
|
17
16
|
import { Entypo } from '@expo/vector-icons'
|
|
18
|
-
import
|
|
17
|
+
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
19
18
|
import { useTheme } from '../../theme'
|
|
19
|
+
import { s, vs, ms } from '../../utils/scaling'
|
|
20
20
|
|
|
21
21
|
export interface AccordionItem {
|
|
22
22
|
value: string
|
|
@@ -46,71 +46,73 @@ function AccordionItemComponent({
|
|
|
46
46
|
onToggle: () => void
|
|
47
47
|
}) {
|
|
48
48
|
const { colors } = useTheme()
|
|
49
|
-
const animatedHeight = useSharedValue(0)
|
|
50
|
-
const animatedRotation = useSharedValue(0)
|
|
51
|
-
const contentHeight = useRef(0)
|
|
52
|
-
const scale = useRef(new Animated.Value(1)).current
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
animatedRotation.value = withTiming(open ? 1 : 0, { duration: 220, easing })
|
|
58
|
-
}
|
|
50
|
+
// Shared values — all animation lives on the UI thread
|
|
51
|
+
const isExpanded = useSharedValue(isOpen)
|
|
52
|
+
const height = useSharedValue(0)
|
|
59
53
|
|
|
54
|
+
// Keep isExpanded in sync with the parent-driven isOpen prop
|
|
60
55
|
React.useEffect(() => {
|
|
61
|
-
|
|
56
|
+
isExpanded.value = isOpen
|
|
62
57
|
}, [isOpen])
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
// Derived animated height — pattern from Reanimated docs:
|
|
60
|
+
// height * Number(isExpanded) gives 0 when closed and the measured height when open.
|
|
61
|
+
// withTiming wraps it so every change animates automatically.
|
|
62
|
+
const derivedHeight = useDerivedValue(() =>
|
|
63
|
+
withTiming(height.value * Number(isExpanded.value), {
|
|
64
|
+
duration: 220,
|
|
65
|
+
easing: isExpanded.value ? Easing.out(Easing.ease) : Easing.in(Easing.ease),
|
|
66
|
+
})
|
|
67
|
+
)
|
|
70
68
|
|
|
71
|
-
const
|
|
72
|
-
|
|
69
|
+
const derivedRotation = useDerivedValue(() =>
|
|
70
|
+
withTiming(isExpanded.value ? 1 : 0, {
|
|
71
|
+
duration: 220,
|
|
72
|
+
easing: isExpanded.value ? Easing.out(Easing.ease) : Easing.in(Easing.ease),
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const bodyStyle = useAnimatedStyle(() => ({
|
|
77
|
+
height: derivedHeight.value,
|
|
73
78
|
overflow: 'hidden',
|
|
74
79
|
}))
|
|
75
80
|
|
|
76
81
|
const rotationStyle = useAnimatedStyle(() => ({
|
|
77
|
-
transform: [{ rotate: `${
|
|
82
|
+
transform: [{ rotate: `${derivedRotation.value * 180}deg` }],
|
|
78
83
|
}))
|
|
79
84
|
|
|
80
|
-
const handlePressIn = () => {
|
|
81
|
-
Animated.spring(scale, { toValue: 0.95, useNativeDriver: true, speed: 40, bounciness: 0 }).start()
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const handlePressOut = () => {
|
|
85
|
-
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 40, bounciness: 4 }).start()
|
|
86
|
-
}
|
|
87
|
-
|
|
88
85
|
return (
|
|
89
86
|
<View style={[styles.item, { borderBottomColor: colors.border }]}>
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
<Pressable
|
|
88
|
+
style={({ pressed }) => [styles.trigger, { opacity: pressed ? 0.6 : 1 }]}
|
|
89
|
+
onPress={() => {
|
|
90
|
+
hapticSelection()
|
|
91
|
+
onToggle()
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<Text style={[styles.triggerText, { color: colors.foreground }]}>{item.trigger}</Text>
|
|
95
|
+
<Animated.View style={[styles.chevron, rotationStyle]}>
|
|
96
|
+
<Entypo name="chevron-down" size={20} color={colors.foreground} />
|
|
97
|
+
</Animated.View>
|
|
98
|
+
</Pressable>
|
|
99
|
+
|
|
100
|
+
{/*
|
|
101
|
+
The Animated.View height is driven by derivedHeight (0 when closed, full height when open).
|
|
102
|
+
The inner View uses position:'absolute' so onLayout always measures the natural content
|
|
103
|
+
height regardless of the animated wrapper's current height — this is the key pattern
|
|
104
|
+
from the Reanimated docs that prevents the jump.
|
|
105
|
+
*/}
|
|
106
|
+
<Animated.View style={bodyStyle}>
|
|
107
|
+
<View
|
|
108
|
+
style={styles.content}
|
|
109
|
+
onLayout={(e) => {
|
|
110
|
+
height.value = e.nativeEvent.layout.height
|
|
96
111
|
}}
|
|
97
|
-
onPressIn={handlePressIn}
|
|
98
|
-
onPressOut={handlePressOut}
|
|
99
|
-
activeOpacity={1}
|
|
100
|
-
touchSoundDisabled={true}
|
|
101
112
|
>
|
|
102
|
-
<Text style={[styles.triggerText, { color: colors.foreground }]}>{item.trigger}</Text>
|
|
103
|
-
<ReanimatedAnimated.View style={[styles.chevron, rotationStyle]}>
|
|
104
|
-
<Entypo name="chevron-down" size={20} color={colors.foreground} />
|
|
105
|
-
</ReanimatedAnimated.View>
|
|
106
|
-
</TouchableOpacity>
|
|
107
|
-
</Animated.View>
|
|
108
|
-
|
|
109
|
-
<ReanimatedAnimated.View style={heightStyle}>
|
|
110
|
-
<View style={styles.content} onLayout={onLayout}>
|
|
111
113
|
{item.content}
|
|
112
114
|
</View>
|
|
113
|
-
</
|
|
115
|
+
</Animated.View>
|
|
114
116
|
</View>
|
|
115
117
|
)
|
|
116
118
|
}
|
|
@@ -153,18 +155,20 @@ const styles = StyleSheet.create({
|
|
|
153
155
|
flexDirection: 'row',
|
|
154
156
|
justifyContent: 'space-between',
|
|
155
157
|
alignItems: 'center',
|
|
156
|
-
paddingVertical: 20,
|
|
158
|
+
paddingVertical: vs(20),
|
|
157
159
|
},
|
|
158
160
|
triggerText: {
|
|
159
|
-
fontSize: 17,
|
|
161
|
+
fontSize: ms(17),
|
|
160
162
|
fontWeight: '500',
|
|
161
163
|
flex: 1,
|
|
162
164
|
},
|
|
163
165
|
chevron: {
|
|
164
|
-
marginLeft: 8,
|
|
166
|
+
marginLeft: s(8),
|
|
165
167
|
},
|
|
168
|
+
// position:'absolute' is the key — the inner View escapes the animated wrapper's
|
|
169
|
+
// clipped height so onLayout always reports the true content height.
|
|
166
170
|
content: {
|
|
167
|
-
paddingBottom: 20,
|
|
171
|
+
paddingBottom: vs(20),
|
|
168
172
|
position: 'absolute',
|
|
169
173
|
width: '100%',
|
|
170
174
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
3
3
|
import { useTheme } from '../../theme'
|
|
4
|
+
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
4
5
|
|
|
5
6
|
export type AlertBannerVariant = 'default' | 'destructive' | 'success'
|
|
6
7
|
|
|
@@ -56,28 +57,28 @@ const styles = StyleSheet.create({
|
|
|
56
57
|
container: {
|
|
57
58
|
flexDirection: 'row',
|
|
58
59
|
borderWidth: 1,
|
|
59
|
-
borderRadius: 8,
|
|
60
|
-
padding: 16,
|
|
61
|
-
gap: 12,
|
|
60
|
+
borderRadius: ms(8),
|
|
61
|
+
padding: s(16),
|
|
62
|
+
gap: s(12),
|
|
62
63
|
},
|
|
63
64
|
icon: {
|
|
64
|
-
marginTop: 2,
|
|
65
|
+
marginTop: vs(2),
|
|
65
66
|
},
|
|
66
67
|
content: {
|
|
67
68
|
flex: 1,
|
|
68
|
-
gap: 4,
|
|
69
|
+
gap: vs(4),
|
|
69
70
|
},
|
|
70
71
|
title: {
|
|
71
|
-
fontSize: 14,
|
|
72
|
+
fontSize: ms(14),
|
|
72
73
|
fontWeight: '500',
|
|
73
|
-
lineHeight: 20,
|
|
74
|
+
lineHeight: mvs(20),
|
|
74
75
|
},
|
|
75
76
|
description: {
|
|
76
|
-
fontSize: 14,
|
|
77
|
-
lineHeight: 20,
|
|
77
|
+
fontSize: ms(14),
|
|
78
|
+
lineHeight: mvs(20),
|
|
78
79
|
},
|
|
79
80
|
defaultIcon: {
|
|
80
|
-
fontSize: 18,
|
|
81
|
+
fontSize: ms(18),
|
|
81
82
|
fontWeight: '700',
|
|
82
83
|
},
|
|
83
84
|
})
|
|
@@ -2,6 +2,8 @@ import React from 'react'
|
|
|
2
2
|
import { View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
3
3
|
import { FontAwesome5, MaterialIcons, Entypo } from '@expo/vector-icons'
|
|
4
4
|
import { useTheme } from '../../theme'
|
|
5
|
+
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
6
|
+
import { renderIcon } from '../../utils/icons'
|
|
5
7
|
|
|
6
8
|
export type AlertBannerVariant = 'default' | 'destructive' | 'success'
|
|
7
9
|
|
|
@@ -10,10 +12,17 @@ export interface AlertBannerProps {
|
|
|
10
12
|
description?: string
|
|
11
13
|
variant?: AlertBannerVariant
|
|
12
14
|
icon?: React.ReactNode
|
|
15
|
+
/**
|
|
16
|
+
* Icon name from `@expo/vector-icons`. See https://icons.expo.fyi.
|
|
17
|
+
* Takes precedence over `icon`. When neither is set, a default variant icon is shown.
|
|
18
|
+
*/
|
|
19
|
+
iconName?: string
|
|
20
|
+
/** Override the resolved icon color. Defaults to the variant title color. */
|
|
21
|
+
iconColor?: string
|
|
13
22
|
style?: ViewStyle
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
export function AlertBanner({ title, description, variant = 'default', icon, style }: AlertBannerProps) {
|
|
25
|
+
export function AlertBanner({ title, description, variant = 'default', icon, iconName, iconColor, style }: AlertBannerProps) {
|
|
17
26
|
const { colors } = useTheme()
|
|
18
27
|
|
|
19
28
|
const borderColor =
|
|
@@ -40,9 +49,13 @@ export function AlertBanner({ title, description, variant = 'default', icon, sty
|
|
|
40
49
|
<Entypo name="info-with-circle" size={18} color={titleColor} />
|
|
41
50
|
)
|
|
42
51
|
|
|
52
|
+
const effectiveIcon: React.ReactNode = iconName
|
|
53
|
+
? renderIcon(iconName, 18, iconColor ?? titleColor)
|
|
54
|
+
: icon ?? defaultIcon
|
|
55
|
+
|
|
43
56
|
return (
|
|
44
57
|
<View style={[styles.container, { backgroundColor: colors.card, borderColor }, style]}>
|
|
45
|
-
<View style={styles.icon}>{
|
|
58
|
+
<View style={styles.icon}>{effectiveIcon}</View>
|
|
46
59
|
<View style={styles.content}>
|
|
47
60
|
{title ? <Text style={[styles.title, { color: titleColor }]} allowFontScaling={true}>{title}</Text> : null}
|
|
48
61
|
{description ? (
|
|
@@ -57,9 +70,9 @@ const styles = StyleSheet.create({
|
|
|
57
70
|
container: {
|
|
58
71
|
flexDirection: 'row',
|
|
59
72
|
borderWidth: 1,
|
|
60
|
-
borderRadius: 12,
|
|
61
|
-
padding: 16,
|
|
62
|
-
gap: 12,
|
|
73
|
+
borderRadius: ms(12),
|
|
74
|
+
padding: s(16),
|
|
75
|
+
gap: s(12),
|
|
63
76
|
shadowColor: '#000',
|
|
64
77
|
shadowOffset: { width: 0, height: 4 },
|
|
65
78
|
shadowOpacity: 0.06,
|
|
@@ -71,15 +84,15 @@ const styles = StyleSheet.create({
|
|
|
71
84
|
},
|
|
72
85
|
content: {
|
|
73
86
|
flex: 1,
|
|
74
|
-
gap: 4,
|
|
87
|
+
gap: vs(4),
|
|
75
88
|
},
|
|
76
89
|
title: {
|
|
77
|
-
fontSize: 14,
|
|
90
|
+
fontSize: ms(14),
|
|
78
91
|
fontWeight: '500',
|
|
79
|
-
lineHeight: 20,
|
|
92
|
+
lineHeight: mvs(20),
|
|
80
93
|
},
|
|
81
94
|
description: {
|
|
82
|
-
fontSize: 14,
|
|
83
|
-
lineHeight: 20,
|
|
95
|
+
fontSize: ms(14),
|
|
96
|
+
lineHeight: mvs(20),
|
|
84
97
|
},
|
|
85
98
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
2
|
import { View, Text, Image, StyleSheet, ViewStyle } from 'react-native'
|
|
3
3
|
import { useTheme } from '../../theme'
|
|
4
|
+
import { s, ms } from '../../utils/scaling'
|
|
4
5
|
|
|
5
6
|
export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
6
7
|
|
|
@@ -14,17 +15,17 @@ export interface AvatarProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const sizeMap: Record<AvatarSize, number> = {
|
|
17
|
-
sm: 28,
|
|
18
|
-
md: 40,
|
|
19
|
-
lg: 56,
|
|
20
|
-
xl: 72,
|
|
18
|
+
sm: s(28),
|
|
19
|
+
md: s(40),
|
|
20
|
+
lg: s(56),
|
|
21
|
+
xl: s(72),
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const fontSizeMap: Record<AvatarSize, number> = {
|
|
24
|
-
sm: 12,
|
|
25
|
-
md: 16,
|
|
26
|
-
lg: 22,
|
|
27
|
-
xl: 28,
|
|
25
|
+
sm: ms(12),
|
|
26
|
+
md: ms(16),
|
|
27
|
+
lg: ms(22),
|
|
28
|
+
xl: ms(28),
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export function Avatar({ src, fallback, size = 'md', style }: AvatarProps) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { View, Text, StyleSheet, ViewStyle, TextStyle } from 'react-native'
|
|
3
3
|
import { useTheme } from '../../theme'
|
|
4
|
+
import { s, vs, ms } from '../../utils/scaling'
|
|
5
|
+
import { renderIcon } from '../../utils/icons'
|
|
4
6
|
|
|
5
7
|
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'
|
|
6
8
|
export type BadgeSize = 'sm' | 'md' | 'lg'
|
|
@@ -13,28 +15,37 @@ export interface BadgeProps {
|
|
|
13
15
|
size?: BadgeSize
|
|
14
16
|
/** Icon rendered before the label/children. */
|
|
15
17
|
icon?: React.ReactNode
|
|
18
|
+
/**
|
|
19
|
+
* Icon name from `@expo/vector-icons` rendered before the label.
|
|
20
|
+
* See https://icons.expo.fyi. Takes precedence over `icon`.
|
|
21
|
+
*/
|
|
22
|
+
iconName?: string
|
|
23
|
+
/** Override the resolved icon color. Defaults to the variant foreground color. */
|
|
24
|
+
iconColor?: string
|
|
16
25
|
style?: ViewStyle
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
const sizePadding: Record<BadgeSize, ViewStyle> = {
|
|
20
|
-
sm: { paddingHorizontal: 8, paddingVertical: 2 },
|
|
21
|
-
md: { paddingHorizontal: 10, paddingVertical: 4 },
|
|
22
|
-
lg: { paddingHorizontal: 12, paddingVertical: 6 },
|
|
29
|
+
sm: { paddingHorizontal: s(8), paddingVertical: vs(2) },
|
|
30
|
+
md: { paddingHorizontal: s(10), paddingVertical: vs(4) },
|
|
31
|
+
lg: { paddingHorizontal: s(12), paddingVertical: vs(6) },
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
const sizeFontSize: Record<BadgeSize, TextStyle> = {
|
|
26
|
-
sm: { fontSize: 11 },
|
|
27
|
-
md: { fontSize: 13 },
|
|
28
|
-
lg: { fontSize: 15 },
|
|
35
|
+
sm: { fontSize: ms(11) },
|
|
36
|
+
md: { fontSize: ms(13) },
|
|
37
|
+
lg: { fontSize: ms(15) },
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
const sizeIconGap: Record<BadgeSize, number> = {
|
|
32
|
-
sm: 4,
|
|
33
|
-
md: 6,
|
|
34
|
-
lg: 6,
|
|
41
|
+
sm: s(4),
|
|
42
|
+
md: s(6),
|
|
43
|
+
lg: s(6),
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
|
|
46
|
+
const sizeIconSize: Record<BadgeSize, number> = { sm: 10, md: 12, lg: 14 }
|
|
47
|
+
|
|
48
|
+
export function Badge({ label, children, variant = 'default', size = 'md', icon, iconName, iconColor, style }: BadgeProps) {
|
|
38
49
|
const { colors } = useTheme()
|
|
39
50
|
|
|
40
51
|
const containerStyle: ViewStyle = {
|
|
@@ -51,11 +62,15 @@ export function Badge({ label, children, variant = 'default', size = 'md', icon,
|
|
|
51
62
|
outline: colors.foreground,
|
|
52
63
|
}[variant]
|
|
53
64
|
|
|
65
|
+
const effectiveIcon: React.ReactNode = iconName
|
|
66
|
+
? renderIcon(iconName, sizeIconSize[size], iconColor ?? textColor)
|
|
67
|
+
: icon
|
|
68
|
+
|
|
54
69
|
const content = children ?? label
|
|
55
70
|
|
|
56
71
|
return (
|
|
57
72
|
<View style={[styles.container, containerStyle, sizePadding[size], { gap: sizeIconGap[size] }, style]}>
|
|
58
|
-
{
|
|
73
|
+
{effectiveIcon ? <View style={styles.iconContainer}>{effectiveIcon}</View> : null}
|
|
59
74
|
{typeof content === 'string' ? (
|
|
60
75
|
<Text style={[styles.label, { color: textColor }, sizeFontSize[size]]} allowFontScaling={true}>
|
|
61
76
|
{content}
|
|
@@ -69,7 +84,7 @@ export function Badge({ label, children, variant = 'default', size = 'md', icon,
|
|
|
69
84
|
|
|
70
85
|
const styles = StyleSheet.create({
|
|
71
86
|
container: {
|
|
72
|
-
borderRadius: 6,
|
|
87
|
+
borderRadius: ms(6),
|
|
73
88
|
alignSelf: 'flex-start',
|
|
74
89
|
flexDirection: 'row',
|
|
75
90
|
alignItems: 'center',
|
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
} from 'react-native'
|
|
13
13
|
|
|
14
14
|
const nativeDriver = Platform.OS !== 'web'
|
|
15
|
-
import
|
|
15
|
+
import { impactLight } from '../../utils/haptics'
|
|
16
16
|
import { useTheme } from '../../theme'
|
|
17
|
+
import { s, vs, ms } from '../../utils/scaling'
|
|
18
|
+
import { renderIcon } from '../../utils/icons'
|
|
17
19
|
|
|
18
20
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'
|
|
19
21
|
export type ButtonSize = 'sm' | 'md' | 'lg'
|
|
@@ -33,22 +35,32 @@ export interface ButtonProps extends TouchableOpacityProps {
|
|
|
33
35
|
fullWidth?: boolean
|
|
34
36
|
/** Icon rendered alongside the label. Can be a ReactNode or a render function `(props) => ReactNode`. */
|
|
35
37
|
icon?: React.ReactNode | ((props: { label: string; size: ButtonSize; variant: ButtonVariant }) => React.ReactNode)
|
|
38
|
+
/**
|
|
39
|
+
* Icon name from `@expo/vector-icons` (e.g. `"home"`, `"star"`, `"arrow-right"`).
|
|
40
|
+
* See https://icons.expo.fyi to browse available icons.
|
|
41
|
+
* Takes precedence over `icon` when both are supplied.
|
|
42
|
+
*/
|
|
43
|
+
iconName?: string
|
|
44
|
+
/** Override the resolved icon color. Defaults to the label foreground color for the active variant. */
|
|
45
|
+
iconColor?: string
|
|
36
46
|
/** Side the icon appears on. Defaults to `'left'`. */
|
|
37
47
|
iconPosition?: 'left' | 'right'
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
const containerSizeStyles: Record<ButtonSize, ViewStyle> = {
|
|
41
|
-
sm: { paddingHorizontal: 20, paddingVertical: 10 },
|
|
42
|
-
md: { paddingHorizontal: 24, paddingVertical: 14 },
|
|
43
|
-
lg: { paddingHorizontal: 32, paddingVertical: 18 },
|
|
51
|
+
sm: { paddingHorizontal: s(20), paddingVertical: vs(10) },
|
|
52
|
+
md: { paddingHorizontal: s(24), paddingVertical: vs(14) },
|
|
53
|
+
lg: { paddingHorizontal: s(32), paddingVertical: vs(18) },
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const labelSizeStyles: Record<ButtonSize, TextStyle> = {
|
|
47
|
-
sm: { fontSize: 15 },
|
|
48
|
-
md: { fontSize: 17 },
|
|
49
|
-
lg: { fontSize: 18 },
|
|
57
|
+
sm: { fontSize: ms(15) },
|
|
58
|
+
md: { fontSize: ms(17) },
|
|
59
|
+
lg: { fontSize: ms(18) },
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
const iconSizeMap: Record<ButtonSize, number> = { sm: 16, md: 18, lg: 20 }
|
|
63
|
+
|
|
52
64
|
export function Button({
|
|
53
65
|
label,
|
|
54
66
|
variant = 'primary',
|
|
@@ -56,6 +68,8 @@ export function Button({
|
|
|
56
68
|
loading = false,
|
|
57
69
|
fullWidth = false,
|
|
58
70
|
icon,
|
|
71
|
+
iconName,
|
|
72
|
+
iconColor,
|
|
59
73
|
iconPosition = 'left',
|
|
60
74
|
disabled,
|
|
61
75
|
style,
|
|
@@ -81,7 +95,7 @@ export function Button({
|
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
const handlePress: TouchableOpacityProps['onPress'] = (e) => {
|
|
84
|
-
|
|
98
|
+
impactLight()
|
|
85
99
|
onPress?.(e)
|
|
86
100
|
}
|
|
87
101
|
|
|
@@ -101,6 +115,10 @@ export function Button({
|
|
|
101
115
|
destructive: { color: colors.destructiveForeground },
|
|
102
116
|
}[variant]
|
|
103
117
|
|
|
118
|
+
const effectiveIcon: React.ReactNode = iconName
|
|
119
|
+
? renderIcon(iconName, iconSizeMap[size], iconColor ?? (labelVariantStyle.color as string))
|
|
120
|
+
: typeof icon === 'function' ? icon({ label, size, variant }) : icon
|
|
121
|
+
|
|
104
122
|
const spinnerColor =
|
|
105
123
|
variant === 'destructive' ? colors.destructiveForeground
|
|
106
124
|
: variant === 'primary' || variant === 'secondary' ? colors.primaryForeground
|
|
@@ -129,9 +147,9 @@ export function Button({
|
|
|
129
147
|
<ActivityIndicator size="small" color={spinnerColor} />
|
|
130
148
|
) : (
|
|
131
149
|
<>
|
|
132
|
-
{
|
|
133
|
-
<Text style={[styles.label, labelVariantStyle, labelSizeStyles[size],
|
|
134
|
-
{
|
|
150
|
+
{effectiveIcon && iconPosition === 'left' && <>{effectiveIcon}</>}
|
|
151
|
+
<Text style={[styles.label, labelVariantStyle, labelSizeStyles[size], effectiveIcon ? styles.labelWithIcon : undefined]}>{label}</Text>
|
|
152
|
+
{effectiveIcon && iconPosition === 'right' && <>{effectiveIcon}</>}
|
|
135
153
|
</>
|
|
136
154
|
)}
|
|
137
155
|
</TouchableOpacity>
|
|
@@ -156,6 +174,6 @@ const styles = StyleSheet.create({
|
|
|
156
174
|
fontWeight: '600',
|
|
157
175
|
},
|
|
158
176
|
labelWithIcon: {
|
|
159
|
-
marginHorizontal: 8,
|
|
177
|
+
marginHorizontal: s(8),
|
|
160
178
|
},
|
|
161
179
|
})
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useRef } from 'react'
|
|
2
2
|
import { View, Text, TouchableOpacity, Animated, StyleSheet, ViewStyle, TextStyle, Platform } from 'react-native'
|
|
3
|
-
import
|
|
3
|
+
import { impactLight } from '../../utils/haptics'
|
|
4
4
|
import { useTheme } from '../../theme'
|
|
5
|
+
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
5
6
|
|
|
6
7
|
const nativeDriver = Platform.OS !== 'web'
|
|
7
8
|
|
|
@@ -67,7 +68,7 @@ export function Card({ children, variant = 'elevated', onPress, style }: CardPro
|
|
|
67
68
|
|
|
68
69
|
const handlePress = () => {
|
|
69
70
|
if (!onPress) return
|
|
70
|
-
|
|
71
|
+
impactLight()
|
|
71
72
|
onPress()
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -146,28 +147,28 @@ export function CardFooter({ children, style }: CardFooterProps) {
|
|
|
146
147
|
|
|
147
148
|
const styles = StyleSheet.create({
|
|
148
149
|
card: {
|
|
149
|
-
borderRadius: 12,
|
|
150
|
+
borderRadius: ms(12),
|
|
150
151
|
borderWidth: 1,
|
|
151
152
|
},
|
|
152
153
|
header: {
|
|
153
|
-
padding: 24,
|
|
154
|
+
padding: s(24),
|
|
154
155
|
paddingBottom: 0,
|
|
155
|
-
gap: 8,
|
|
156
|
+
gap: vs(8),
|
|
156
157
|
},
|
|
157
158
|
title: {
|
|
158
|
-
fontSize: 20,
|
|
159
|
+
fontSize: ms(20),
|
|
159
160
|
fontWeight: '600',
|
|
160
|
-
lineHeight: 28,
|
|
161
|
+
lineHeight: mvs(28),
|
|
161
162
|
},
|
|
162
163
|
description: {
|
|
163
|
-
fontSize: 15,
|
|
164
|
-
lineHeight: 22,
|
|
164
|
+
fontSize: ms(15),
|
|
165
|
+
lineHeight: mvs(22),
|
|
165
166
|
},
|
|
166
167
|
content: {
|
|
167
|
-
padding: 24,
|
|
168
|
+
padding: s(24),
|
|
168
169
|
},
|
|
169
170
|
footer: {
|
|
170
|
-
padding: 24,
|
|
171
|
+
padding: s(24),
|
|
171
172
|
paddingTop: 0,
|
|
172
173
|
flexDirection: 'row',
|
|
173
174
|
alignItems: 'center',
|