@retray-dev/ui-kit 5.2.0 → 6.0.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 (42) hide show
  1. package/COMPONENTS.md +500 -140
  2. package/EXAMPLES.md +666 -0
  3. package/README.md +3 -3
  4. package/dist/index.d.mts +253 -49
  5. package/dist/index.d.ts +253 -49
  6. package/dist/index.js +955 -610
  7. package/dist/index.mjs +886 -552
  8. package/package.json +9 -3
  9. package/src/components/Accordion/Accordion.tsx +31 -4
  10. package/src/components/AlertBanner/AlertBanner.tsx +16 -33
  11. package/src/components/Avatar/Avatar.tsx +21 -7
  12. package/src/components/Button/Button.tsx +34 -13
  13. package/src/components/ButtonGroup/ButtonGroup.tsx +60 -0
  14. package/src/components/ButtonGroup/index.ts +1 -0
  15. package/src/components/Card/Card.tsx +12 -9
  16. package/src/components/Chip/Chip.tsx +8 -1
  17. package/src/components/ConfirmDialog/ConfirmDialog.tsx +4 -4
  18. package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +38 -5
  19. package/src/components/DetailRow/DetailRow.tsx +140 -0
  20. package/src/components/DetailRow/index.ts +1 -0
  21. package/src/components/EmptyState/EmptyState.tsx +21 -6
  22. package/src/components/Input/Input.tsx +21 -10
  23. package/src/components/LabelValue/LabelValue.tsx +25 -4
  24. package/src/components/ListItem/ListItem.tsx +14 -8
  25. package/src/components/MediaCard/MediaCard.tsx +1 -0
  26. package/src/components/MenuItem/MenuItem.tsx +206 -0
  27. package/src/components/MenuItem/index.ts +2 -0
  28. package/src/components/MonthPicker/MonthPicker.tsx +18 -6
  29. package/src/components/Select/Select.tsx +1 -1
  30. package/src/components/Separator/Separator.tsx +2 -0
  31. package/src/components/Sheet/Sheet.tsx +165 -36
  32. package/src/components/Sheet/index.ts +1 -1
  33. package/src/components/Tabs/Tabs.tsx +4 -4
  34. package/src/components/Textarea/Textarea.tsx +66 -29
  35. package/src/components/Toast/Toast.tsx +41 -267
  36. package/src/components/Toast/index.ts +1 -2
  37. package/src/components/Toggle/Toggle.tsx +2 -2
  38. package/src/index.ts +6 -0
  39. package/src/theme/colors.ts +3 -0
  40. package/src/theme/types.ts +11 -0
  41. package/src/tokens.ts +4 -4
  42. package/src/utils/typography.ts +24 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retray-dev/ui-kit",
3
- "version": "5.2.0",
3
+ "version": "6.0.0",
4
4
  "description": "Personal UI Kit for React Native / Expo",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "src",
19
- "COMPONENTS.md"
19
+ "COMPONENTS.md",
20
+ "EXAMPLES.md"
20
21
  ],
21
22
  "scripts": {
22
23
  "build": "tsup",
@@ -51,7 +52,9 @@
51
52
  "react-native-reanimated": ">=4.0.0",
52
53
  "react-native-safe-area-context": ">=4.0.0",
53
54
  "react-native-size-matters": ">=0.4.0",
54
- "react-native-worklets": ">=0.5.0"
55
+ "react-native-worklets": ">=0.5.0",
56
+ "react-native-svg": ">=15.0.0",
57
+ "react-native-screens": ">=3.0.0"
55
58
  },
56
59
  "pnpm": {
57
60
  "overrides": {
@@ -86,6 +89,9 @@
86
89
  "react-native-reanimated": "~4.1.1",
87
90
  "react-native-safe-area-context": "~5.6.2",
88
91
  "react-native-worklets": "~0.5.1",
92
+ "sonner-native": "0.23.1",
93
+ "react-native-svg": "15.12.1",
94
+ "react-native-screens": "4.16.0",
89
95
  "tsup": "^8.0.0",
90
96
  "typescript": "^5.4.0",
91
97
  "typescript-eslint": "^8.0.0"
@@ -12,16 +12,27 @@ import Animated, {
12
12
  useAnimatedStyle,
13
13
  withTiming,
14
14
  Easing,
15
+ type EasingFunction,
15
16
  } from 'react-native-reanimated'
17
+
18
+ const easingExpand: EasingFunction = Easing.bezier(0.23, 1, 0.32, 1) as unknown as EasingFunction
19
+ const easingCollapse: EasingFunction = Easing.in(Easing.ease)
16
20
  import { Entypo } from '@expo/vector-icons'
17
21
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
18
22
  import { useTheme } from '../../theme'
19
23
  import { s, vs, ms } from '../../utils/scaling'
24
+ import { renderIcon } from '../../utils/icons'
20
25
 
21
26
  export interface AccordionItem {
22
27
  value: string
23
28
  trigger: string
24
29
  content: React.ReactNode
30
+ /** Icon name from @expo/vector-icons rendered left of trigger. */
31
+ iconName?: string
32
+ /** Custom icon node rendered left of trigger. */
33
+ icon?: React.ReactNode
34
+ /** Override icon color. Defaults to foregroundMuted. */
35
+ iconColor?: string
25
36
  }
26
37
 
27
38
  export interface AccordionProps {
@@ -47,6 +58,10 @@ function AccordionItemComponent({
47
58
  }) {
48
59
  const { colors } = useTheme()
49
60
 
61
+ const resolvedIcon = item.iconName
62
+ ? renderIcon(item.iconName, ms(16), item.iconColor ?? colors.foregroundMuted)
63
+ : item.icon
64
+
50
65
  // Shared values — all animation lives on the UI thread
51
66
  const isExpanded = useSharedValue(isOpen)
52
67
  const height = useSharedValue(0)
@@ -62,14 +77,14 @@ function AccordionItemComponent({
62
77
  const derivedHeight = useDerivedValue(() =>
63
78
  withTiming(height.value * Number(isExpanded.value), {
64
79
  duration: 220,
65
- easing: isExpanded.value ? Easing.out(Easing.ease) : Easing.in(Easing.ease),
80
+ easing: isExpanded.value ? easingExpand : easingCollapse,
66
81
  })
67
82
  )
68
83
 
69
84
  const derivedRotation = useDerivedValue(() =>
70
85
  withTiming(isExpanded.value ? 1 : 0, {
71
86
  duration: 220,
72
- easing: isExpanded.value ? Easing.out(Easing.ease) : Easing.in(Easing.ease),
87
+ easing: isExpanded.value ? easingExpand : easingCollapse,
73
88
  })
74
89
  )
75
90
 
@@ -91,7 +106,10 @@ function AccordionItemComponent({
91
106
  onToggle()
92
107
  }}
93
108
  >
94
- <Text style={[styles.triggerText, { color: colors.foreground }]} allowFontScaling={true}>{item.trigger}</Text>
109
+ <View style={styles.triggerContent}>
110
+ {resolvedIcon ? <View style={styles.icon}>{resolvedIcon}</View> : null}
111
+ <Text style={[styles.triggerText, { color: colors.foreground }]} allowFontScaling={true}>{item.trigger}</Text>
112
+ </View>
95
113
  <Animated.View style={[styles.chevron, rotationStyle]}>
96
114
  <Entypo name="chevron-down" size={18} color={colors.foregroundMuted} />
97
115
  </Animated.View>
@@ -163,10 +181,19 @@ const styles = StyleSheet.create({
163
181
  paddingHorizontal: s(14),
164
182
  paddingVertical: vs(12),
165
183
  },
184
+ triggerContent: {
185
+ flexDirection: 'row',
186
+ alignItems: 'center',
187
+ gap: s(8),
188
+ flex: 1,
189
+ },
190
+ icon: {
191
+ alignItems: 'center',
192
+ justifyContent: 'center',
193
+ },
166
194
  triggerText: {
167
195
  fontFamily: 'Poppins-Medium',
168
196
  fontSize: ms(14),
169
- flex: 1,
170
197
  },
171
198
  chevron: {
172
199
  marginLeft: s(8),
@@ -4,6 +4,7 @@ import { FontAwesome5, MaterialIcons, Entypo } from '@expo/vector-icons'
4
4
  import { useTheme } from '../../theme'
5
5
  import { s, vs, ms } from '../../utils/scaling'
6
6
  import { renderIcon } from '../../utils/icons'
7
+ import { RADIUS } from '../../tokens'
7
8
 
8
9
  export type AlertBannerVariant = 'default' | 'destructive' | 'success' | 'warning'
9
10
 
@@ -20,52 +21,36 @@ export interface AlertBannerProps {
20
21
  export function AlertBanner({ title, description, variant = 'default', icon, iconName, iconColor, style }: AlertBannerProps) {
21
22
  const { colors } = useTheme()
22
23
 
23
- const bgColor =
24
- variant === 'destructive' ? colors.destructiveTint
25
- : variant === 'success' ? colors.successTint
26
- : variant === 'warning' ? colors.warningTint
27
- : colors.card
28
-
29
- const borderColor =
30
- variant === 'destructive' ? colors.destructiveBorder
31
- : variant === 'success' ? colors.successBorder
32
- : variant === 'warning' ? colors.warningBorder
33
- : colors.border
34
-
35
24
  const accentColor =
36
25
  variant === 'destructive' ? colors.destructive
37
26
  : variant === 'success' ? colors.success
38
27
  : variant === 'warning' ? colors.warning
39
28
  : colors.primary
40
29
 
41
- const titleColor =
42
- variant === 'default' ? colors.foreground : accentColor
43
-
44
- const descColor =
45
- variant === 'default' ? colors.foregroundMuted : accentColor
46
-
47
30
  const defaultIcon =
48
31
  variant === 'success' ? (
49
- <FontAwesome5 name="check-circle" size={16} color={accentColor} />
32
+ <FontAwesome5 name="check-circle" size={ms(16)} color={accentColor} />
50
33
  ) : variant === 'destructive' ? (
51
- <MaterialIcons name="error-outline" size={17} color={accentColor} />
34
+ <MaterialIcons name="error-outline" size={ms(17)} color={accentColor} />
52
35
  ) : variant === 'warning' ? (
53
- <MaterialIcons name="warning-amber" size={17} color={accentColor} />
36
+ <MaterialIcons name="warning-amber" size={ms(17)} color={accentColor} />
54
37
  ) : (
55
- <Entypo name="info-with-circle" size={16} color={accentColor} />
38
+ <Entypo name="info-with-circle" size={ms(16)} color={accentColor} />
56
39
  )
57
40
 
58
41
  const effectiveIcon: React.ReactNode = iconName
59
- ? renderIcon(iconName, 16, iconColor ?? accentColor)
42
+ ? renderIcon(iconName, ms(16), iconColor ?? accentColor)
60
43
  : icon ?? defaultIcon
61
44
 
62
45
  return (
63
- <View style={[styles.container, { backgroundColor: bgColor, borderColor }, style]}>
46
+ <View style={[styles.container, { backgroundColor: colors.card }, style]}>
47
+ {/* Icon */}
64
48
  <View style={styles.iconSlot}>{effectiveIcon}</View>
49
+ {/* Text */}
65
50
  <View style={styles.content}>
66
- <Text style={[styles.title, { color: titleColor }]} allowFontScaling={true}>{title}</Text>
51
+ <Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>{title}</Text>
67
52
  {description ? (
68
- <Text style={[styles.description, { color: descColor }]} allowFontScaling={true}>{description}</Text>
53
+ <Text style={[styles.description, { color: colors.foregroundMuted }]} allowFontScaling={true}>{description}</Text>
69
54
  ) : null}
70
55
  </View>
71
56
  </View>
@@ -76,11 +61,10 @@ const styles = StyleSheet.create({
76
61
  container: {
77
62
  flexDirection: 'row',
78
63
  alignItems: 'flex-start',
79
- borderWidth: 0.5,
80
- borderRadius: 10,
81
- paddingHorizontal: s(12),
82
- paddingVertical: vs(10),
83
- gap: s(10),
64
+ borderRadius: RADIUS.lg,
65
+ gap: s(8),
66
+ paddingVertical: vs(8),
67
+ paddingHorizontal: s(10),
84
68
  },
85
69
  iconSlot: {
86
70
  marginTop: vs(1),
@@ -92,12 +76,11 @@ const styles = StyleSheet.create({
92
76
  title: {
93
77
  fontFamily: 'Poppins-Medium',
94
78
  fontSize: ms(13),
95
- lineHeight: ms(18),
79
+ lineHeight: ms(19),
96
80
  },
97
81
  description: {
98
82
  fontFamily: 'Poppins-Regular',
99
83
  fontSize: ms(12),
100
84
  lineHeight: ms(17),
101
- opacity: 0.85,
102
85
  },
103
86
  })
@@ -8,9 +8,12 @@ export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
8
8
  export type AvatarStatus = 'online' | 'offline' | 'busy' | 'away'
9
9
 
10
10
  export interface AvatarProps {
11
- src?: string
11
+ src?: string | null
12
+ /** Manual initials (max 2 chars). */
12
13
  fallback?: string
13
- size?: AvatarSize
14
+ /** Full name — extracts up to 2 initials (e.g. "Julian Cruz" → "JC"). */
15
+ fallbackText?: string
16
+ size?: AvatarSize | number
14
17
  /** Optional status indicator dot — bottom-right corner. */
15
18
  status?: AvatarStatus
16
19
  style?: ViewStyle
@@ -37,13 +40,24 @@ const statusSizeMap: Record<AvatarSize, number> = {
37
40
  xl: 16,
38
41
  }
39
42
 
40
- export function Avatar({ src, fallback, size = 'md', status, style }: AvatarProps) {
43
+ function getInitials(fallback?: string, fallbackText?: string): string {
44
+ if (fallback) return fallback.slice(0, 2).toUpperCase()
45
+ if (fallbackText) {
46
+ const words = fallbackText.trim().split(/\s+/)
47
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
48
+ return (words[0][0] + words[words.length - 1][0]).toUpperCase()
49
+ }
50
+ return '?'
51
+ }
52
+
53
+ export function Avatar({ src, fallback, fallbackText, size = 'md', status, style }: AvatarProps) {
41
54
  const { colors } = useTheme()
42
55
  const [imageError, setImageError] = useState(false)
43
- const dimension = sizeMap[size]
56
+ const dimension = typeof size === 'number' ? size : sizeMap[size as AvatarSize]
57
+ const fontSize = typeof size === 'number' ? size * 0.38 : fontSizeMap[size as AvatarSize]
44
58
  const showFallback = !src || imageError
45
59
 
46
- const statusSize = statusSizeMap[size]
60
+ const statusSize = typeof size === 'number' ? size * 0.25 : statusSizeMap[size as AvatarSize]
47
61
 
48
62
  const statusColor: Record<AvatarStatus, string> = {
49
63
  online: '#22c55e',
@@ -71,10 +85,10 @@ export function Avatar({ src, fallback, size = 'md', status, style }: AvatarProp
71
85
  />
72
86
  ) : (
73
87
  <Text
74
- style={[styles.fallback, { color: colors.foregroundMuted, fontSize: fontSizeMap[size] }]}
88
+ style={[styles.fallback, { color: colors.foregroundMuted, fontSize }]}
75
89
  allowFontScaling={true}
76
90
  >
77
- {fallback?.slice(0, 2).toUpperCase() ?? '?'}
91
+ {getInitials(fallback, fallbackText)}
78
92
  </Text>
79
93
  )}
80
94
  </View>
@@ -12,9 +12,9 @@ import {
12
12
  } from 'react-native'
13
13
 
14
14
  const nativeDriver = Platform.OS !== 'web'
15
- import { impactLight } from '../../utils/haptics'
15
+ import { impactMedium } from '../../utils/haptics'
16
16
  import { useTheme } from '../../theme'
17
- import { s, vs, ms } from '../../utils/scaling'
17
+ import { s, vs, ms, mvs } from '../../utils/scaling'
18
18
  import { renderIcon } from '../../utils/icons'
19
19
  import { RADIUS, TYPOGRAPHY } from '../../tokens'
20
20
 
@@ -31,7 +31,7 @@ export interface ButtonProps extends TouchableOpacityProps {
31
31
  size?: ButtonSize
32
32
  loading?: boolean
33
33
  fullWidth?: boolean
34
- icon?: React.ReactNode | ((props: { label: string; size: ButtonSize; variant: ButtonVariant }) => React.ReactNode)
34
+ icon?: React.ReactNode | ((props: { label: string; size: ButtonSize; variant: ButtonVariant; color: string }) => React.ReactNode)
35
35
  iconName?: string
36
36
  iconColor?: string
37
37
  iconPosition?: 'left' | 'right'
@@ -47,7 +47,7 @@ const containerSizeStyles: Record<ButtonSize, ViewStyle> = {
47
47
  const labelSizeStyles: Record<ButtonSize, TextStyle> = {
48
48
  sm: { ...TYPOGRAPHY['button-sm'], fontSize: ms(TYPOGRAPHY['button-sm'].fontSize) },
49
49
  md: { ...TYPOGRAPHY['button-lg'], fontSize: ms(TYPOGRAPHY['button-lg'].fontSize) },
50
- lg: { ...TYPOGRAPHY['button-lg'], fontSize: ms(TYPOGRAPHY['button-lg'].fontSize + 1) },
50
+ lg: { ...TYPOGRAPHY['button-lg'], fontSize: ms(TYPOGRAPHY['button-lg'].fontSize + 1), lineHeight: mvs(24) },
51
51
  }
52
52
 
53
53
  const iconSizeMap: Record<ButtonSize, number> = { sm: 16, md: 18, lg: 20 }
@@ -73,15 +73,15 @@ export function Button({
73
73
 
74
74
  const handlePressIn = () => {
75
75
  if (isDisabled) return
76
- Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
76
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, stiffness: 600, damping: 35, mass: 0.8 }).start()
77
77
  }
78
78
 
79
79
  const handlePressOut = () => {
80
- Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
80
+ Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, stiffness: 280, damping: 22, mass: 0.8 }).start()
81
81
  }
82
82
 
83
83
  const handlePress: TouchableOpacityProps['onPress'] = (e) => {
84
- impactLight()
84
+ impactMedium()
85
85
  onPress?.(e)
86
86
  }
87
87
 
@@ -99,17 +99,24 @@ export function Button({
99
99
  destructive: { color: colors.destructiveForeground },
100
100
  }[variant]
101
101
 
102
+ const textColor = iconColor ?? (labelVariantStyle.color as string)
103
+
102
104
  const effectiveIcon: React.ReactNode = iconName
103
- ? renderIcon(iconName, iconSizeMap[size], iconColor ?? (labelVariantStyle.color as string))
104
- : typeof icon === 'function' ? icon({ label, size, variant }) : icon
105
+ ? renderIcon(iconName, iconSizeMap[size], textColor)
106
+ : typeof icon === 'function' ? icon({ label, size, variant, color: textColor }) : icon
105
107
 
106
108
  const spinnerColor =
107
109
  variant === 'destructive' ? colors.destructiveForeground
108
110
  : variant === 'primary' ? colors.primaryForeground
109
111
  : colors.foreground
110
112
 
113
+ // Extract flex from style for wrapper — ButtonGroup sets flex: 1
114
+ const styleArray = Array.isArray(style) ? style : style ? [style] : []
115
+ const flatStyle = StyleSheet.flatten(styleArray)
116
+ const { flex, ...restStyle } = flatStyle || {}
117
+
111
118
  return (
112
- <Animated.View style={[fullWidth && styles.fullWidth, { transform: [{ scale }] }]}>
119
+ <Animated.View style={[fullWidth && styles.fullWidth, flex !== undefined && { flex }, { transform: [{ scale }] }]}>
113
120
  <TouchableOpacity
114
121
  style={[
115
122
  styles.base,
@@ -117,7 +124,7 @@ export function Button({
117
124
  containerSizeStyles[size],
118
125
  fullWidth && styles.fullWidth,
119
126
  isDisabled && styles.disabled,
120
- style,
127
+ restStyle,
121
128
  ]}
122
129
  disabled={isDisabled}
123
130
  activeOpacity={1}
@@ -128,13 +135,23 @@ export function Button({
128
135
  {...props}
129
136
  >
130
137
  {loading ? (
131
- <ActivityIndicator size="small" color={spinnerColor} />
138
+ <>
139
+ <ActivityIndicator size="small" color={spinnerColor} style={{ marginRight: s(6) }} />
140
+ <Text
141
+ style={[styles.label, labelVariantStyle, labelSizeStyles[size], styles.labelLoading]}
142
+ allowFontScaling={true}
143
+ numberOfLines={1}
144
+ >
145
+ {label}
146
+ </Text>
147
+ </>
132
148
  ) : (
133
149
  <>
134
150
  {effectiveIcon && iconPosition === 'left' && <>{effectiveIcon}</>}
135
151
  <Text
136
152
  style={[styles.label, labelVariantStyle, labelSizeStyles[size], effectiveIcon ? styles.labelWithIcon : undefined]}
137
153
  allowFontScaling={true}
154
+ numberOfLines={1}
138
155
  >
139
156
  {label}
140
157
  </Text>
@@ -148,7 +165,7 @@ export function Button({
148
165
 
149
166
  const styles = StyleSheet.create({
150
167
  base: {
151
- borderRadius: RADIUS.xl, // 32pxpill-shaped primary CTA (Airbnb spec)
168
+ borderRadius: RADIUS.md, // 14pxAirbnb-aligned rounded rect (not pill)
152
169
  alignItems: 'center',
153
170
  justifyContent: 'center',
154
171
  flexDirection: 'row',
@@ -161,8 +178,12 @@ const styles = StyleSheet.create({
161
178
  },
162
179
  label: {
163
180
  fontFamily: 'Poppins-Medium',
181
+ flexShrink: 1,
164
182
  },
165
183
  labelWithIcon: {
166
184
  marginHorizontal: s(6),
167
185
  },
186
+ labelLoading: {
187
+ opacity: 0.6,
188
+ },
168
189
  })
@@ -0,0 +1,60 @@
1
+ import React from 'react'
2
+ import { View, ViewStyle, StyleSheet } from 'react-native'
3
+ import { s } from '../../utils/scaling'
4
+
5
+ export interface ButtonGroupProps {
6
+ children: React.ReactNode
7
+ /** Spacing between buttons. Defaults to 12px. */
8
+ gap?: number
9
+ /** Stack buttons vertically instead of horizontally. */
10
+ vertical?: boolean
11
+ style?: ViewStyle
12
+ }
13
+
14
+ /**
15
+ * Container that auto-distributes space equally among Button children.
16
+ * Each child gets `flex: 1` — perfect for side-by-side CTAs.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <ButtonGroup>
21
+ * <Button label="Cancel" variant="secondary" onPress={...} />
22
+ * <Button label="Confirm" onPress={...} />
23
+ * </ButtonGroup>
24
+ * ```
25
+ */
26
+ export function ButtonGroup({ children, gap = 12, vertical = false, style }: ButtonGroupProps) {
27
+ return (
28
+ <View
29
+ style={[
30
+ styles.container,
31
+ vertical ? styles.vertical : styles.horizontal,
32
+ { gap: s(gap) },
33
+ style,
34
+ ]}
35
+ >
36
+ {React.Children.map(children, (child) =>
37
+ React.isValidElement(child)
38
+ ? React.cloneElement(child as React.ReactElement<any>, {
39
+ style: [
40
+ (child as React.ReactElement<any>).props.style,
41
+ { flex: 1 },
42
+ ],
43
+ })
44
+ : child,
45
+ )}
46
+ </View>
47
+ )
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ container: {
52
+ width: '100%',
53
+ },
54
+ horizontal: {
55
+ flexDirection: 'row',
56
+ },
57
+ vertical: {
58
+ flexDirection: 'column',
59
+ },
60
+ })
@@ -0,0 +1 @@
1
+ export * from './ButtonGroup'
@@ -3,6 +3,7 @@ import { View, Text, TouchableOpacity, Animated, StyleSheet, ViewStyle, TextStyl
3
3
  import { impactLight } from '../../utils/haptics'
4
4
  import { useTheme } from '../../theme'
5
5
  import { s, vs, ms, mvs } from '../../utils/scaling'
6
+ import { RADIUS } from '../../tokens'
6
7
 
7
8
  const nativeDriver = Platform.OS !== 'web'
8
9
 
@@ -51,8 +52,9 @@ export function Card({ children, variant = 'elevated', onPress, style }: CardPro
51
52
  Animated.spring(scale, {
52
53
  toValue: 0.98,
53
54
  useNativeDriver: nativeDriver,
54
- speed: 40,
55
- bounciness: 0,
55
+ stiffness: 400,
56
+ damping: 30,
57
+ mass: 1.0,
56
58
  }).start()
57
59
  }
58
60
 
@@ -61,8 +63,9 @@ export function Card({ children, variant = 'elevated', onPress, style }: CardPro
61
63
  Animated.spring(scale, {
62
64
  toValue: 1,
63
65
  useNativeDriver: nativeDriver,
64
- speed: 40,
65
- bounciness: 4,
66
+ stiffness: 250,
67
+ damping: 24,
68
+ mass: 1.0,
66
69
  }).start()
67
70
  }
68
71
 
@@ -77,10 +80,10 @@ export function Card({ children, variant = 'elevated', onPress, style }: CardPro
77
80
  backgroundColor: colors.card,
78
81
  borderColor: colors.border,
79
82
  shadowColor: '#000',
80
- shadowOffset: { width: 0, height: 4 },
81
- shadowOpacity: 0.06,
82
- shadowRadius: 12,
83
- elevation: 3,
83
+ shadowOffset: { width: 0, height: 6 },
84
+ shadowOpacity: 0.10,
85
+ shadowRadius: 16,
86
+ elevation: 4,
84
87
  },
85
88
  outlined: {
86
89
  backgroundColor: colors.card,
@@ -147,7 +150,7 @@ export function CardFooter({ children, style }: CardFooterProps) {
147
150
 
148
151
  const styles = StyleSheet.create({
149
152
  card: {
150
- borderRadius: 14, // RADIUS.md — Airbnb property card spec
153
+ borderRadius: RADIUS.md, // 14px — Airbnb property card spec
151
154
  borderWidth: 1,
152
155
  },
153
156
  header: {
@@ -30,6 +30,11 @@ export interface ChipProps {
30
30
  export interface ChipOption {
31
31
  label: string
32
32
  value: string | number
33
+ /** Icon name resolved via renderIcon (Feather, AntDesign, etc.). */
34
+ iconName?: string
35
+ /** Icon tint color override. */
36
+ iconColor?: string
37
+ disabled?: boolean
33
38
  }
34
39
 
35
40
  export interface ChipGroupProps {
@@ -154,7 +159,9 @@ export function ChipGroup({ options, value, onValueChange, multiSelect = false,
154
159
  key={opt.value}
155
160
  label={opt.label}
156
161
  selected={isSelected(opt.value)}
157
- onPress={() => handlePress(opt.value)}
162
+ onPress={opt.disabled ? undefined : () => handlePress(opt.value)}
163
+ iconName={opt.iconName}
164
+ style={opt.disabled ? { opacity: 0.4 } : undefined}
158
165
  />
159
166
  ))}
160
167
  </View>
@@ -7,7 +7,7 @@ import {
7
7
  type BottomSheetBackdropProps,
8
8
  } from '@gorhom/bottom-sheet'
9
9
  import { Feather } from '@expo/vector-icons'
10
- import { impactLight } from '../../utils/haptics'
10
+ import { impactMedium, notificationSuccess, selectionAsync as hapticSelection } from '../../utils/haptics'
11
11
  import { useTheme } from '../../theme'
12
12
  import { Button } from '../Button'
13
13
  import { s, vs, ms, mvs } from '../../utils/scaling'
@@ -38,7 +38,7 @@ export function ConfirmDialog({
38
38
 
39
39
  useEffect(() => {
40
40
  if (visible) {
41
- impactLight()
41
+ impactMedium()
42
42
  ref.current?.present()
43
43
  } else {
44
44
  ref.current?.dismiss()
@@ -78,7 +78,7 @@ export function ConfirmDialog({
78
78
  label={confirmLabel}
79
79
  variant={confirmVariant}
80
80
  fullWidth
81
- onPress={onConfirm}
81
+ onPress={() => { notificationSuccess(); onConfirm() }}
82
82
  icon={
83
83
  <Feather
84
84
  name={confirmVariant === 'destructive' ? 'trash-2' : 'check'}
@@ -95,7 +95,7 @@ export function ConfirmDialog({
95
95
  label={cancelLabel}
96
96
  variant="secondary"
97
97
  fullWidth
98
- onPress={onCancel}
98
+ onPress={() => { hapticSelection(); onCancel() }}
99
99
  icon={<Feather name="x" size={15} color={colors.foreground} />}
100
100
  />
101
101
  </View>
@@ -3,6 +3,22 @@ import { View, Text, StyleSheet, ViewStyle } from 'react-native'
3
3
  import { useTheme } from '../../theme'
4
4
  import { ms } from '../../utils/scaling'
5
5
 
6
+ export type CurrencyDisplayVariant = 'hero' | 'large' | 'medium' | 'small'
7
+
8
+ const variantFontSize: Record<CurrencyDisplayVariant, number> = {
9
+ hero: ms(48),
10
+ large: ms(32),
11
+ medium: ms(18),
12
+ small: ms(14),
13
+ }
14
+
15
+ const variantLetterSpacing: Record<CurrencyDisplayVariant, number> = {
16
+ hero: -2,
17
+ large: -1,
18
+ medium: -0.5,
19
+ small: 0,
20
+ }
21
+
6
22
  export interface CurrencyDisplayProps {
7
23
  value: number | string
8
24
  /** Symbol prepended to the formatted value. Defaults to `'$'`. */
@@ -11,6 +27,12 @@ export interface CurrencyDisplayProps {
11
27
  showDecimals?: boolean
12
28
  /** Override the color of the formatted text. Defaults to the `foreground` theme token. */
13
29
  textColor?: string
30
+ /** Predefined size variant. Overrides the default 56px size. */
31
+ variant?: CurrencyDisplayVariant
32
+ /** Enable adjustsFontSizeToFit so long values shrink to fit in one line. */
33
+ autoScale?: boolean
34
+ /** Maximum font size when autoScale is true (defaults to variant size or 56px). */
35
+ maxFontSize?: number
14
36
  style?: ViewStyle
15
37
  }
16
38
 
@@ -27,13 +49,22 @@ function formatValue(value: number | string, prefix: string, showDecimals: boole
27
49
  return `${sign}${prefix}${intPart}`
28
50
  }
29
51
 
30
- export function CurrencyDisplay({ value, prefix = '$', showDecimals = false, textColor, style }: CurrencyDisplayProps) {
52
+ export function CurrencyDisplay({ value, prefix = '$', showDecimals = false, textColor, variant, autoScale, maxFontSize, style }: CurrencyDisplayProps) {
31
53
  const { colors } = useTheme()
32
54
  const formatted = formatValue(value, prefix, showDecimals)
55
+ const baseFontSize = variant ? variantFontSize[variant] : ms(56)
56
+ const fontSize = maxFontSize ?? baseFontSize
57
+ const letterSpacing = variant ? variantLetterSpacing[variant] : -2
33
58
 
34
59
  return (
35
60
  <View style={[styles.container, style]}>
36
- <Text style={[styles.amount, { color: textColor ?? colors.foreground }]} allowFontScaling={true}>
61
+ <Text
62
+ style={[styles.amount, { color: textColor ?? colors.foreground, fontSize, letterSpacing }]}
63
+ allowFontScaling={true}
64
+ numberOfLines={autoScale ? 1 : undefined}
65
+ adjustsFontSizeToFit={autoScale}
66
+ minimumFontScale={autoScale ? 0.5 : undefined}
67
+ >
37
68
  {formatted}
38
69
  </Text>
39
70
  </View>
@@ -41,10 +72,12 @@ export function CurrencyDisplay({ value, prefix = '$', showDecimals = false, tex
41
72
  }
42
73
 
43
74
  const styles = StyleSheet.create({
44
- container: {},
75
+ container: {
76
+ alignSelf: 'flex-start',
77
+ },
45
78
  amount: {
46
79
  fontFamily: 'Poppins-Bold',
47
- fontSize: ms(56),
48
- letterSpacing: -2,
80
+ includeFontPadding: false,
81
+ textAlignVertical: 'top',
49
82
  },
50
83
  })