@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.
Files changed (38) hide show
  1. package/COMPONENTS.md +153 -6
  2. package/dist/index.d.mts +98 -8
  3. package/dist/index.d.ts +98 -8
  4. package/dist/index.js +591 -505
  5. package/dist/index.mjs +533 -436
  6. package/package.json +23 -21
  7. package/src/components/Accordion/Accordion.tsx +61 -57
  8. package/src/components/Alert/Alert.tsx +11 -10
  9. package/src/components/AlertBanner/AlertBanner.tsx +23 -10
  10. package/src/components/Avatar/Avatar.tsx +9 -8
  11. package/src/components/Badge/Badge.tsx +27 -12
  12. package/src/components/Button/Button.tsx +30 -12
  13. package/src/components/Card/Card.tsx +12 -11
  14. package/src/components/Checkbox/Checkbox.tsx +16 -13
  15. package/src/components/Chip/Chip.tsx +8 -7
  16. package/src/components/ConfirmDialog/ConfirmDialog.tsx +12 -11
  17. package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +2 -1
  18. package/src/components/CurrencyInput/CurrencyInput.tsx +2 -1
  19. package/src/components/EmptyState/EmptyState.tsx +34 -21
  20. package/src/components/Input/Input.tsx +44 -22
  21. package/src/components/LabelValue/LabelValue.tsx +6 -5
  22. package/src/components/ListItem/ListItem.tsx +46 -22
  23. package/src/components/MonthPicker/MonthPicker.tsx +9 -8
  24. package/src/components/Progress/Progress.tsx +2 -1
  25. package/src/components/RadioGroup/RadioGroup.tsx +18 -15
  26. package/src/components/Select/Select.tsx +25 -24
  27. package/src/components/Sheet/Sheet.tsx +15 -14
  28. package/src/components/Slider/Slider.tsx +7 -6
  29. package/src/components/Switch/Switch.tsx +7 -6
  30. package/src/components/Tabs/Tabs.tsx +17 -14
  31. package/src/components/Text/Text.tsx +7 -6
  32. package/src/components/Textarea/Textarea.tsx +9 -8
  33. package/src/components/Toast/Toast.tsx +30 -19
  34. package/src/components/Toggle/Toggle.tsx +36 -10
  35. package/src/index.ts +4 -0
  36. package/src/utils/haptics.ts +32 -0
  37. package/src/utils/icons.ts +73 -0
  38. 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.5.1",
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
- "react": ">=17",
41
- "react-native": ">=0.70",
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
- "@gorhom/bottom-sheet": ">=5.0.0",
45
- "react-native-reanimated": ">=4.0.0",
46
+ "react": ">=17",
47
+ "react-native": ">=0.70",
46
48
  "react-native-gesture-handler": ">=2.0.0",
47
- "react-native-worklets": ">=0.8.0",
49
+ "react-native-reanimated": ">=4.0.0",
48
50
  "react-native-safe-area-context": ">=4.0.0",
49
- "@react-native-picker/picker": ">=2.0.0",
50
- "@react-native-community/slider": ">=4.0.0",
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-picker/picker": "2.11.4",
67
- "@react-native-community/slider": "^4.5.5",
70
+ "@react-native-community/slider": "5.0.1",
71
+ "@react-native-picker/picker": "2.11.1",
68
72
  "@types/react": "^19.1.0",
69
- "@expo/vector-icons": "^15.1.1",
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
- "eslint": "^9.0.0",
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, useRef } from 'react'
1
+ import React, { useState } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
- TouchableOpacity,
6
- Animated,
5
+ Pressable,
7
6
  StyleSheet,
8
- LayoutChangeEvent,
9
7
  ViewStyle,
10
8
  } from 'react-native'
11
- import ReanimatedAnimated, {
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 * as Haptics from 'expo-haptics'
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
- const toggle = (open: boolean) => {
55
- const easing = open ? Easing.out(Easing.ease) : Easing.in(Easing.ease)
56
- animatedHeight.value = withTiming(open ? contentHeight.current : 0, { duration: 220, easing })
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
- toggle(isOpen)
56
+ isExpanded.value = isOpen
62
57
  }, [isOpen])
63
58
 
64
- const onLayout = (e: LayoutChangeEvent) => {
65
- if (contentHeight.current === 0) {
66
- contentHeight.current = e.nativeEvent.layout.height
67
- if (isOpen) animatedHeight.value = contentHeight.current
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 heightStyle = useAnimatedStyle(() => ({
72
- height: animatedHeight.value,
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: `${animatedRotation.value * 180}deg` }],
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
- <Animated.View style={{ transform: [{ scale }] }}>
91
- <TouchableOpacity
92
- style={styles.trigger}
93
- onPress={() => {
94
- Haptics.selectionAsync()
95
- onToggle()
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
- </ReanimatedAnimated.View>
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}>{icon ?? defaultIcon}</View>
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
- export function Badge({ label, children, variant = 'default', size = 'md', icon, style }: BadgeProps) {
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
- {icon ? <View style={styles.iconContainer}>{icon}</View> : null}
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 * as Haptics from 'expo-haptics'
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
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
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
- {icon && iconPosition === 'left' && <>{typeof icon === 'function' ? icon({ label, size, variant }) : icon}</>}
133
- <Text style={[styles.label, labelVariantStyle, labelSizeStyles[size], icon ? styles.labelWithIcon : undefined]}>{label}</Text>
134
- {icon && iconPosition === 'right' && <>{typeof icon === 'function' ? icon({ label, size, variant }) : icon}</>}
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 * as Haptics from 'expo-haptics'
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
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
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',