@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.
- package/COMPONENTS.md +1806 -663
- package/README.md +14 -10
- package/dist/index.d.mts +274 -85
- package/dist/index.d.ts +274 -85
- package/dist/index.js +1048 -321
- package/dist/index.mjs +1046 -324
- package/package.json +3 -2
- package/src/components/Accordion/Accordion.tsx +1 -1
- package/src/components/AlertBanner/AlertBanner.tsx +50 -45
- package/src/components/Avatar/Avatar.tsx +61 -17
- package/src/components/Badge/Badge.tsx +17 -15
- package/src/components/Button/Button.tsx +31 -42
- package/src/components/Card/Card.tsx +4 -4
- package/src/components/CategoryStrip/CategoryStrip.tsx +185 -0
- package/src/components/CategoryStrip/index.ts +2 -0
- package/src/components/Checkbox/Checkbox.tsx +44 -16
- package/src/components/Chip/Chip.tsx +1 -1
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +9 -9
- package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +1 -0
- package/src/components/CurrencyInput/CurrencyInput.tsx +6 -4
- package/src/components/EmptyState/EmptyState.tsx +9 -9
- package/src/components/IconButton/IconButton.tsx +74 -34
- package/src/components/Input/Input.tsx +15 -13
- package/src/components/LabelValue/LabelValue.tsx +1 -1
- package/src/components/ListItem/ListItem.tsx +5 -5
- package/src/components/MediaCard/MediaCard.tsx +249 -0
- package/src/components/MediaCard/index.ts +2 -0
- package/src/components/Pressable/Pressable.tsx +100 -0
- package/src/components/Pressable/index.ts +1 -0
- package/src/components/Progress/Progress.tsx +14 -7
- package/src/components/RadioGroup/RadioGroup.tsx +1 -1
- package/src/components/Select/Select.tsx +5 -5
- package/src/components/Sheet/Sheet.tsx +35 -15
- package/src/components/Skeleton/Skeleton.tsx +34 -7
- package/src/components/Slider/Slider.tsx +2 -2
- package/src/components/Spinner/Spinner.tsx +1 -1
- package/src/components/Switch/Switch.tsx +31 -4
- package/src/components/Tabs/Tabs.tsx +63 -45
- package/src/components/Text/Text.tsx +59 -10
- package/src/components/Textarea/Textarea.tsx +4 -3
- package/src/components/Toast/Toast.tsx +77 -36
- package/src/components/Toggle/Toggle.tsx +3 -3
- package/src/index.ts +8 -2
- package/src/theme/ThemeProvider.tsx +11 -10
- package/src/theme/colorUtils.ts +80 -0
- package/src/theme/colors.ts +76 -35
- package/src/theme/index.ts +2 -2
- package/src/theme/types.ts +27 -13
- package/src/tokens.ts +150 -13
- 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
|
+
})
|
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|
|
@@ -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'
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
84
|
-
height: s(
|
|
85
|
-
borderRadius: ms(
|
|
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(
|
|
91
|
-
height: s(
|
|
92
|
-
borderRadius: ms(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
85
|
-
secondary:
|
|
86
|
-
outline:
|
|
87
|
-
|
|
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:
|
|
93
|
-
secondary:
|
|
94
|
-
outline:
|
|
95
|
-
|
|
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'
|
|
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:
|
|
157
|
+
borderRadius: 9999,
|
|
141
158
|
alignItems: 'center',
|
|
142
159
|
justifyContent: 'center',
|
|
143
160
|
},
|
|
144
161
|
disabled: {
|
|
145
|
-
opacity: 0.
|
|
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
|
})
|