@retray-dev/ui-kit 4.0.0 → 5.2.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 (50) hide show
  1. package/COMPONENTS.md +1806 -663
  2. package/README.md +14 -10
  3. package/dist/index.d.mts +274 -85
  4. package/dist/index.d.ts +274 -85
  5. package/dist/index.js +1048 -321
  6. package/dist/index.mjs +1046 -324
  7. package/package.json +3 -2
  8. package/src/components/Accordion/Accordion.tsx +1 -1
  9. package/src/components/AlertBanner/AlertBanner.tsx +50 -45
  10. package/src/components/Avatar/Avatar.tsx +61 -17
  11. package/src/components/Badge/Badge.tsx +17 -15
  12. package/src/components/Button/Button.tsx +31 -42
  13. package/src/components/Card/Card.tsx +4 -4
  14. package/src/components/CategoryStrip/CategoryStrip.tsx +185 -0
  15. package/src/components/CategoryStrip/index.ts +2 -0
  16. package/src/components/Checkbox/Checkbox.tsx +44 -16
  17. package/src/components/Chip/Chip.tsx +1 -1
  18. package/src/components/ConfirmDialog/ConfirmDialog.tsx +9 -9
  19. package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +1 -0
  20. package/src/components/CurrencyInput/CurrencyInput.tsx +6 -4
  21. package/src/components/EmptyState/EmptyState.tsx +9 -9
  22. package/src/components/IconButton/IconButton.tsx +74 -34
  23. package/src/components/Input/Input.tsx +15 -13
  24. package/src/components/LabelValue/LabelValue.tsx +1 -1
  25. package/src/components/ListItem/ListItem.tsx +5 -5
  26. package/src/components/MediaCard/MediaCard.tsx +249 -0
  27. package/src/components/MediaCard/index.ts +2 -0
  28. package/src/components/Pressable/Pressable.tsx +100 -0
  29. package/src/components/Pressable/index.ts +1 -0
  30. package/src/components/Progress/Progress.tsx +14 -7
  31. package/src/components/RadioGroup/RadioGroup.tsx +1 -1
  32. package/src/components/Select/Select.tsx +5 -5
  33. package/src/components/Sheet/Sheet.tsx +35 -15
  34. package/src/components/Skeleton/Skeleton.tsx +34 -7
  35. package/src/components/Slider/Slider.tsx +2 -2
  36. package/src/components/Spinner/Spinner.tsx +1 -1
  37. package/src/components/Switch/Switch.tsx +31 -4
  38. package/src/components/Tabs/Tabs.tsx +63 -45
  39. package/src/components/Text/Text.tsx +59 -10
  40. package/src/components/Textarea/Textarea.tsx +4 -3
  41. package/src/components/Toast/Toast.tsx +77 -36
  42. package/src/components/Toggle/Toggle.tsx +3 -3
  43. package/src/index.ts +8 -2
  44. package/src/theme/ThemeProvider.tsx +11 -10
  45. package/src/theme/colorUtils.ts +80 -0
  46. package/src/theme/colors.ts +76 -35
  47. package/src/theme/index.ts +2 -2
  48. package/src/theme/types.ts +27 -13
  49. package/src/tokens.ts +150 -13
  50. package/src/utils/hover.ts +25 -0
@@ -0,0 +1,185 @@
1
+ import React, { useRef } from 'react'
2
+ import {
3
+ ScrollView,
4
+ TouchableOpacity,
5
+ Animated,
6
+ Text,
7
+ View,
8
+ StyleSheet,
9
+ ViewStyle,
10
+ Platform,
11
+ } from 'react-native'
12
+ import { selectionAsync as hapticSelection } from '../../utils/haptics'
13
+ import { useTheme } from '../../theme'
14
+ import { s, vs, ms } from '../../utils/scaling'
15
+ import { renderIcon } from '../../utils/icons'
16
+ import { RADIUS } from '../../tokens'
17
+
18
+ const nativeDriver = Platform.OS !== 'web'
19
+
20
+ export interface CategoryItem {
21
+ label: string
22
+ value: string
23
+ /** Icon rendered to the left of the label. ReactNode or icon name string. */
24
+ icon?: React.ReactNode | string
25
+ /** Badge count over the icon/label. */
26
+ badge?: number
27
+ }
28
+
29
+ export interface CategoryStripProps {
30
+ categories: CategoryItem[]
31
+ value?: string | string[]
32
+ /** Called with new value(s) on selection change. */
33
+ onValueChange?: (value: string | string[]) => void
34
+ /** Allow multiple simultaneous selections. Defaults to false. */
35
+ multiSelect?: boolean
36
+ style?: ViewStyle
37
+ /** Style applied to each pill item. */
38
+ itemStyle?: ViewStyle
39
+ }
40
+
41
+ function CategoryChip({
42
+ item,
43
+ selected,
44
+ onPress,
45
+ }: {
46
+ item: CategoryItem
47
+ selected: boolean
48
+ onPress: () => void
49
+ }) {
50
+ const { colors } = useTheme()
51
+ const scale = useRef(new Animated.Value(1)).current
52
+
53
+ const handlePressIn = () => {
54
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
55
+ }
56
+
57
+ const handlePressOut = () => {
58
+ Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
59
+ }
60
+
61
+ const bgColor = selected ? colors.primary : colors.surface
62
+ const textColor = selected ? colors.primaryForeground : colors.foregroundSubtle
63
+ const borderColor = selected ? colors.primary : colors.border
64
+
65
+ const resolvedIcon =
66
+ typeof item.icon === 'string'
67
+ ? renderIcon(item.icon, 16, textColor)
68
+ : item.icon ?? null
69
+
70
+ return (
71
+ <Animated.View style={{ transform: [{ scale }] }}>
72
+ <TouchableOpacity
73
+ style={[
74
+ styles.chip,
75
+ {
76
+ backgroundColor: bgColor,
77
+ borderColor,
78
+ },
79
+ ]}
80
+ onPress={onPress}
81
+ onPressIn={handlePressIn}
82
+ onPressOut={handlePressOut}
83
+ activeOpacity={1}
84
+ touchSoundDisabled={true}
85
+ >
86
+ {resolvedIcon && <View style={styles.chipIcon}>{resolvedIcon}</View>}
87
+ <Text style={[styles.chipLabel, { color: textColor }]} allowFontScaling={true}>
88
+ {item.label}
89
+ </Text>
90
+ {item.badge !== undefined && item.badge > 0 && (
91
+ <View style={[styles.chipBadge, { backgroundColor: colors.primary }]}>
92
+ <Text style={[styles.chipBadgeText, { color: colors.primaryForeground }]}>
93
+ {Math.min(item.badge, 99)}
94
+ </Text>
95
+ </View>
96
+ )}
97
+ </TouchableOpacity>
98
+ </Animated.View>
99
+ )
100
+ }
101
+
102
+ export function CategoryStrip({
103
+ categories,
104
+ value,
105
+ onValueChange,
106
+ multiSelect = false,
107
+ style,
108
+ itemStyle,
109
+ }: CategoryStripProps) {
110
+ const selected = Array.isArray(value) ? value : value ? [value] : []
111
+
112
+ const handlePress = (v: string) => {
113
+ hapticSelection()
114
+ if (multiSelect) {
115
+ const current = Array.isArray(value) ? value : value ? [value] : []
116
+ const next = current.includes(v)
117
+ ? current.filter((x) => x !== v)
118
+ : [...current, v]
119
+ onValueChange?.(next)
120
+ } else {
121
+ onValueChange?.(v === value ? '' : v)
122
+ }
123
+ }
124
+
125
+ return (
126
+ <ScrollView
127
+ horizontal
128
+ showsHorizontalScrollIndicator={false}
129
+ contentContainerStyle={[styles.container, style]}
130
+ style={styles.scroll}
131
+ >
132
+ {categories.map((cat) => (
133
+ <View key={cat.value} style={itemStyle}>
134
+ <CategoryChip
135
+ item={cat}
136
+ selected={selected.includes(cat.value)}
137
+ onPress={() => handlePress(cat.value)}
138
+ />
139
+ </View>
140
+ ))}
141
+ </ScrollView>
142
+ )
143
+ }
144
+
145
+ const styles = StyleSheet.create({
146
+ scroll: {
147
+ flexGrow: 0,
148
+ },
149
+ container: {
150
+ flexDirection: 'row',
151
+ gap: s(8),
152
+ paddingHorizontal: s(4),
153
+ paddingVertical: vs(4),
154
+ },
155
+ chip: {
156
+ flexDirection: 'row',
157
+ alignItems: 'center',
158
+ borderRadius: RADIUS.full,
159
+ borderWidth: 1,
160
+ paddingHorizontal: s(14),
161
+ paddingVertical: vs(8),
162
+ gap: s(6),
163
+ },
164
+ chipIcon: {
165
+ alignItems: 'center',
166
+ justifyContent: 'center',
167
+ },
168
+ chipLabel: {
169
+ fontFamily: 'Poppins-Medium',
170
+ fontSize: ms(13),
171
+ },
172
+ chipBadge: {
173
+ minWidth: 16,
174
+ height: 16,
175
+ borderRadius: 9999,
176
+ paddingHorizontal: 3,
177
+ alignItems: 'center',
178
+ justifyContent: 'center',
179
+ },
180
+ chipBadgeText: {
181
+ fontFamily: 'Poppins-Bold',
182
+ fontSize: ms(9),
183
+ lineHeight: 14,
184
+ },
185
+ })
@@ -0,0 +1,2 @@
1
+ export { CategoryStrip } from './CategoryStrip'
2
+ export type { CategoryStripProps, CategoryItem } from './CategoryStrip'
@@ -1,4 +1,4 @@
1
- import React, { useRef } from 'react'
1
+ import React, { useRef, useEffect } from 'react'
2
2
  import { TouchableOpacity, Animated, View, Text, StyleSheet, ViewStyle, Platform } from 'react-native'
3
3
  import { selectionAsync as hapticSelection } from '../../utils/haptics'
4
4
 
@@ -23,6 +23,33 @@ export function Checkbox({
23
23
  }: CheckboxProps) {
24
24
  const { colors } = useTheme()
25
25
  const scale = useRef(new Animated.Value(1)).current
26
+ const bgOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
27
+ const checkOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
28
+
29
+ useEffect(() => {
30
+ Animated.parallel([
31
+ Animated.timing(bgOpacity, {
32
+ toValue: checked ? 1 : 0,
33
+ duration: 150,
34
+ useNativeDriver: false,
35
+ }),
36
+ Animated.timing(checkOpacity, {
37
+ toValue: checked ? 1 : 0,
38
+ duration: 120,
39
+ useNativeDriver: false,
40
+ }),
41
+ ]).start()
42
+ }, [checked, bgOpacity, checkOpacity])
43
+
44
+ const borderColor = bgOpacity.interpolate({
45
+ inputRange: [0, 1],
46
+ outputRange: [colors.border, colors.primary],
47
+ })
48
+
49
+ const backgroundColor = bgOpacity.interpolate({
50
+ inputRange: [0, 1],
51
+ outputRange: ['transparent', colors.primary],
52
+ })
26
53
 
27
54
  const handlePressIn = () => {
28
55
  if (disabled) return
@@ -46,24 +73,25 @@ export function Checkbox({
46
73
  activeOpacity={1}
47
74
  touchSoundDisabled={true}
48
75
  >
49
- <Animated.View
50
- style={[
51
- styles.box,
52
- {
53
- borderColor: checked ? colors.primary : colors.border,
54
- backgroundColor: checked ? colors.primary : 'transparent',
55
- opacity: disabled ? 0.45 : 1,
56
- transform: [{ scale }],
57
- },
58
- ]}
59
- >
60
- {checked ? (
61
- <View style={[styles.checkmark, { borderColor: colors.primaryForeground }]} />
62
- ) : null}
76
+ <Animated.View style={{ transform: [{ scale }] }}>
77
+ <Animated.View
78
+ style={[
79
+ styles.box,
80
+ {
81
+ borderColor,
82
+ backgroundColor,
83
+ opacity: disabled ? 0.45 : 1,
84
+ },
85
+ ]}
86
+ >
87
+ <Animated.View style={{ opacity: checkOpacity }}>
88
+ <View style={[styles.checkmark, { borderColor: colors.primaryForeground }]} />
89
+ </Animated.View>
90
+ </Animated.View>
63
91
  </Animated.View>
64
92
  {label ? (
65
93
  <Text
66
- style={[styles.label, { color: disabled ? colors.mutedForeground : colors.foreground }]}
94
+ style={[styles.label, { color: disabled ? colors.foregroundMuted : colors.foreground }]}
67
95
  allowFontScaling={true}
68
96
  >
69
97
  {label}
@@ -80,7 +80,7 @@ export function Chip({ label, selected = false, onPress, icon, iconName, style }
80
80
 
81
81
  const backgroundColor = pressAnim.interpolate({
82
82
  inputRange: [0, 1],
83
- outputRange: [colors.secondary, colors.primary],
83
+ outputRange: [colors.surface, colors.primary],
84
84
  })
85
85
 
86
86
  const textColor = pressAnim.interpolate({
@@ -65,22 +65,15 @@ export function ConfirmDialog({
65
65
  enablePanDownToClose
66
66
  >
67
67
  <BottomSheetView style={styles.content}>
68
- <Text style={[styles.title, { color: colors.cardForeground }]} allowFontScaling={true}>
68
+ <Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>
69
69
  {title}
70
70
  </Text>
71
71
  {description ? (
72
- <Text style={[styles.description, { color: colors.mutedForeground }]} allowFontScaling={true}>
72
+ <Text style={[styles.description, { color: colors.foregroundMuted }]} allowFontScaling={true}>
73
73
  {description}
74
74
  </Text>
75
75
  ) : null}
76
76
  <View style={styles.actions}>
77
- <Button
78
- label={cancelLabel}
79
- variant="outline"
80
- fullWidth
81
- onPress={onCancel}
82
- icon={<Feather name="x" size={15} color={colors.foreground} />}
83
- />
84
77
  <Button
85
78
  label={confirmLabel}
86
79
  variant={confirmVariant}
@@ -98,6 +91,13 @@ export function ConfirmDialog({
98
91
  />
99
92
  }
100
93
  />
94
+ <Button
95
+ label={cancelLabel}
96
+ variant="secondary"
97
+ fullWidth
98
+ onPress={onCancel}
99
+ icon={<Feather name="x" size={15} color={colors.foreground} />}
100
+ />
101
101
  </View>
102
102
  </BottomSheetView>
103
103
  </BottomSheetModal>
@@ -45,5 +45,6 @@ const styles = StyleSheet.create({
45
45
  amount: {
46
46
  fontFamily: 'Poppins-Bold',
47
47
  fontSize: ms(56),
48
+ letterSpacing: -2,
48
49
  },
49
50
  })
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { ViewStyle, TextStyle } from 'react-native'
3
3
  import { Input } from '../Input'
4
- import { ms } from '../../utils/scaling'
4
+ import { ms, vs } from '../../utils/scaling'
5
5
  import { renderIcon } from '../../utils/icons'
6
6
  import { useTheme } from '../../theme'
7
7
 
@@ -58,9 +58,11 @@ export function CurrencyInput({
58
58
  onChangeValue?.(isNaN(raw) ? 0 : raw)
59
59
  }
60
60
 
61
- const inputStyle: TextStyle = size === 'large' ? { fontFamily: 'Poppins-Regular', fontSize: ms(36) } : { fontFamily: 'Poppins-Regular' }
61
+ const inputStyle: TextStyle = size === 'large'
62
+ ? { fontFamily: 'Poppins-Regular', fontSize: ms(36) }
63
+ : { fontFamily: 'Poppins-Regular' }
62
64
 
63
- const dollarIcon = renderIcon('dollar-sign', size === 'large' ? 24 : 16, colors.mutedForeground)
65
+ const dollarIcon = renderIcon('dollar-sign', size === 'large' ? 24 : 16, colors.foregroundMuted)
64
66
 
65
67
  // Remove prefix from display value if present
66
68
  const displayValue = value && prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value
@@ -77,7 +79,7 @@ export function CurrencyInput({
77
79
  editable={editable}
78
80
  prefix={dollarIcon}
79
81
  containerStyle={containerStyle}
80
- inputWrapperStyle={size === 'large' ? { paddingVertical: 10 } : undefined}
82
+ inputWrapperStyle={size === 'large' ? { paddingVertical: vs(16), minHeight: 72 } : undefined}
81
83
  style={[inputStyle, style]}
82
84
  />
83
85
  )
@@ -26,7 +26,7 @@ export function EmptyState({ icon, iconName, iconColor, title, description, acti
26
26
  const isCompact = size === 'compact'
27
27
 
28
28
  const effectiveIcon: React.ReactNode = iconName
29
- ? renderIcon(iconName, isCompact ? 32 : 48, iconColor ?? colors.mutedForeground)
29
+ ? renderIcon(iconName, isCompact ? 32 : 48, iconColor ?? colors.foregroundMuted)
30
30
  : icon
31
31
 
32
32
  return (
@@ -43,7 +43,7 @@ export function EmptyState({ icon, iconName, iconColor, title, description, acti
43
43
  style={[
44
44
  styles.iconWrapper,
45
45
  isCompact && styles.iconWrapperCompact,
46
- { backgroundColor: colors.muted },
46
+ { backgroundColor: colors.surface },
47
47
  ]}
48
48
  >
49
49
  {effectiveIcon}
@@ -57,7 +57,7 @@ export function EmptyState({ icon, iconName, iconColor, title, description, acti
57
57
  {title}
58
58
  </Text>
59
59
  {description && !isCompact ? (
60
- <Text style={[styles.description, { color: colors.mutedForeground }]} allowFontScaling={true}>{description}</Text>
60
+ <Text style={[styles.description, { color: colors.foregroundMuted }]} allowFontScaling={true}>{description}</Text>
61
61
  ) : null}
62
62
  </View>
63
63
  {action && !isCompact ? <View style={styles.action}>{action}</View> : null}
@@ -80,16 +80,16 @@ const styles = StyleSheet.create({
80
80
  gap: vs(10),
81
81
  },
82
82
  iconWrapper: {
83
- width: s(48),
84
- height: s(48),
85
- borderRadius: ms(12),
83
+ width: s(80),
84
+ height: s(80),
85
+ borderRadius: ms(20),
86
86
  alignItems: 'center',
87
87
  justifyContent: 'center',
88
88
  },
89
89
  iconWrapperCompact: {
90
- width: s(36),
91
- height: s(36),
92
- borderRadius: ms(8),
90
+ width: s(56),
91
+ height: s(56),
92
+ borderRadius: ms(14),
93
93
  },
94
94
  textWrapper: {
95
95
  alignItems: 'center',
@@ -4,6 +4,8 @@ import {
4
4
  Animated,
5
5
  ActivityIndicator,
6
6
  StyleSheet,
7
+ View,
8
+ Text,
7
9
  TouchableOpacityProps,
8
10
  ViewStyle,
9
11
  Platform,
@@ -12,30 +14,33 @@ import {
12
14
  const nativeDriver = Platform.OS !== 'web'
13
15
  import { impactLight } from '../../utils/haptics'
14
16
  import { useTheme } from '../../theme'
15
- import { s } from '../../utils/scaling'
17
+ import { s, ms } from '../../utils/scaling'
16
18
  import { renderIcon } from '../../utils/icons'
17
19
 
18
- export type IconButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'
20
+ // primary: filled primary
21
+ // secondary: filled surface — icon on neutral bg (Airbnb icon-button-circle)
22
+ // outline: transparent + border
23
+ // text: fully transparent
24
+ // destructive: filled destructive
25
+ export type IconButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'destructive'
19
26
  export type IconButtonSize = 'sm' | 'md' | 'lg'
20
27
 
21
28
  export interface IconButtonProps extends TouchableOpacityProps {
22
- /**
23
- * Icon name from `@expo/vector-icons` (e.g. `"home"`, `"star"`, `"plus"`).
24
- * See https://icons.expo.fyi. Takes precedence over `icon` when both supplied.
25
- */
26
29
  iconName?: string
27
- /** ReactNode icon — used when `iconName` is not provided. */
28
30
  icon?: React.ReactNode
29
- /** Override the resolved icon color. Defaults to the foreground color for the active variant. */
30
31
  iconColor?: string
31
32
  variant?: IconButtonVariant
32
33
  size?: IconButtonSize
33
- /** Replaces icon with a spinner and forces `disabled`. */
34
34
  loading?: boolean
35
+ /**
36
+ * Badge overlay. `true` shows a dot. A number shows a count (capped at 99).
37
+ * The dot/count appears top-right of the button.
38
+ */
39
+ badge?: boolean | number
35
40
  }
36
41
 
37
42
  const sizeMap: Record<IconButtonSize, { container: number; icon: number }> = {
38
- sm: { container: s(40), icon: 18 },
43
+ sm: { container: s(32), icon: 16 },
39
44
  md: { container: s(44), icon: 20 },
40
45
  lg: { container: s(52), icon: 24 },
41
46
  }
@@ -47,6 +52,7 @@ export function IconButton({
47
52
  variant = 'primary',
48
53
  size = 'md',
49
54
  loading = false,
55
+ badge,
50
56
  disabled,
51
57
  style,
52
58
  onPress,
@@ -58,21 +64,11 @@ export function IconButton({
58
64
 
59
65
  const handlePressIn = () => {
60
66
  if (isDisabled) return
61
- Animated.spring(scale, {
62
- toValue: 0.95,
63
- useNativeDriver: nativeDriver,
64
- speed: 40,
65
- bounciness: 0,
66
- }).start()
67
+ Animated.spring(scale, { toValue: 0.95, useNativeDriver: nativeDriver, speed: 40, bounciness: 0 }).start()
67
68
  }
68
69
 
69
70
  const handlePressOut = () => {
70
- Animated.spring(scale, {
71
- toValue: 1,
72
- useNativeDriver: nativeDriver,
73
- speed: 40,
74
- bounciness: 4,
75
- }).start()
71
+ Animated.spring(scale, { toValue: 1, useNativeDriver: nativeDriver, speed: 40, bounciness: 4 }).start()
76
72
  }
77
73
 
78
74
  const handlePress: TouchableOpacityProps['onPress'] = (e) => {
@@ -81,24 +77,24 @@ export function IconButton({
81
77
  }
82
78
 
83
79
  const containerVariantStyle: ViewStyle = {
84
- primary: { backgroundColor: colors.primary },
85
- secondary: { backgroundColor: colors.secondary },
86
- outline: { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: colors.border },
87
- ghost: { backgroundColor: 'transparent' },
80
+ primary: { backgroundColor: colors.primary },
81
+ secondary: { backgroundColor: colors.surface },
82
+ outline: { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: colors.border },
83
+ text: { backgroundColor: 'transparent' },
88
84
  destructive: { backgroundColor: colors.destructive },
89
85
  }[variant]
90
86
 
91
87
  const defaultIconColor: string = {
92
- primary: colors.primaryForeground,
93
- secondary: colors.secondaryForeground,
94
- outline: colors.foreground,
95
- ghost: colors.foreground,
88
+ primary: colors.primaryForeground,
89
+ secondary: colors.foreground,
90
+ outline: colors.foreground,
91
+ text: colors.foreground,
96
92
  destructive: colors.destructiveForeground,
97
93
  }[variant]
98
94
 
99
95
  const spinnerColor =
100
96
  variant === 'destructive' ? colors.destructiveForeground
101
- : variant === 'primary' || variant === 'secondary' ? colors.primaryForeground
97
+ : variant === 'primary' ? colors.primaryForeground
102
98
  : colors.foreground
103
99
 
104
100
  const { container: containerSize, icon: iconSize } = sizeMap[size]
@@ -107,8 +103,13 @@ export function IconButton({
107
103
  ? renderIcon(iconName, iconSize, iconColor ?? defaultIconColor)
108
104
  : icon
109
105
 
106
+ // Badge rendering
107
+ const showBadge = badge !== undefined && badge !== false && badge !== 0
108
+ const badgeCount = typeof badge === 'number' ? Math.min(badge, 99) : null
109
+ const showCount = typeof badge === 'number' && badge > 0
110
+
110
111
  return (
111
- <Animated.View style={{ transform: [{ scale }] }}>
112
+ <Animated.View style={[styles.wrapper, { transform: [{ scale }] }]}>
112
113
  <TouchableOpacity
113
114
  style={[
114
115
  styles.base,
@@ -131,17 +132,56 @@ export function IconButton({
131
132
  resolvedIcon
132
133
  )}
133
134
  </TouchableOpacity>
135
+ {showBadge && (
136
+ <View style={[
137
+ styles.badge,
138
+ { backgroundColor: colors.primary },
139
+ showCount ? styles.badgeCount : styles.badgeDot,
140
+ ]}>
141
+ {showCount && (
142
+ <Text style={[styles.badgeText, { color: colors.primaryForeground }]}>
143
+ {badgeCount}
144
+ </Text>
145
+ )}
146
+ </View>
147
+ )}
134
148
  </Animated.View>
135
149
  )
136
150
  }
137
151
 
138
152
  const styles = StyleSheet.create({
153
+ wrapper: {
154
+ alignSelf: 'flex-start',
155
+ },
139
156
  base: {
140
- borderRadius: 999,
157
+ borderRadius: 9999,
141
158
  alignItems: 'center',
142
159
  justifyContent: 'center',
143
160
  },
144
161
  disabled: {
145
- opacity: 0.5,
162
+ opacity: 0.45,
163
+ },
164
+ badge: {
165
+ position: 'absolute',
166
+ top: -2,
167
+ right: -2,
168
+ alignItems: 'center',
169
+ justifyContent: 'center',
170
+ },
171
+ badgeDot: {
172
+ width: 8,
173
+ height: 8,
174
+ borderRadius: 9999,
175
+ },
176
+ badgeCount: {
177
+ minWidth: 16,
178
+ height: 16,
179
+ borderRadius: 9999,
180
+ paddingHorizontal: 3,
181
+ },
182
+ badgeText: {
183
+ fontFamily: 'Poppins-Bold',
184
+ fontSize: ms(9),
185
+ lineHeight: 14,
146
186
  },
147
187
  })