@retray-dev/ui-kit 4.0.0 → 5.1.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 +1791 -663
- package/README.md +4 -3
- package/dist/index.d.mts +268 -83
- package/dist/index.d.ts +268 -83
- package/dist/index.js +1032 -309
- package/dist/index.mjs +1029 -311
- 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 +3 -3
- 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 +2 -2
- 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,18 +65,18 @@ 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
77
|
<Button
|
|
78
78
|
label={cancelLabel}
|
|
79
|
-
variant="
|
|
79
|
+
variant="secondary"
|
|
80
80
|
fullWidth
|
|
81
81
|
onPress={onCancel}
|
|
82
82
|
icon={<Feather name="x" size={15} color={colors.foreground} />}
|
|
@@ -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
|
})
|
|
@@ -55,16 +55,16 @@ export function Input({ label, error, hint, prefix, suffix, prefixStyle, suffixS
|
|
|
55
55
|
const effectiveSecure = isPassword ? !showPassword : secureTextEntry
|
|
56
56
|
|
|
57
57
|
const effectivePrefix: React.ReactNode = prefixIcon
|
|
58
|
-
? renderIcon(prefixIcon, 20, prefixIconColor ?? colors.
|
|
58
|
+
? renderIcon(prefixIcon, 20, prefixIconColor ?? colors.foregroundMuted)
|
|
59
59
|
: prefix
|
|
60
60
|
|
|
61
61
|
// If type is password and no suffix override is provided, add the toggle button
|
|
62
62
|
const effectiveSuffix: React.ReactNode = isPassword && !suffix && !suffixIcon ? (
|
|
63
63
|
<TouchableOpacity onPress={() => setShowPassword(!showPassword)} style={styles.passwordToggle} activeOpacity={0.6}>
|
|
64
|
-
<AntDesign name={showPassword ? 'eye' : 'eye-invisible'} size={20} color={colors.
|
|
64
|
+
<AntDesign name={showPassword ? 'eye' : 'eye-invisible'} size={20} color={colors.foregroundMuted} />
|
|
65
65
|
</TouchableOpacity>
|
|
66
66
|
) : suffixIcon
|
|
67
|
-
? renderIcon(suffixIcon, 20, suffixIconColor ?? colors.
|
|
67
|
+
? renderIcon(suffixIcon, 20, suffixIconColor ?? colors.foregroundMuted)
|
|
68
68
|
: suffix
|
|
69
69
|
|
|
70
70
|
return (
|
|
@@ -77,7 +77,7 @@ export function Input({ label, error, hint, prefix, suffix, prefixStyle, suffixS
|
|
|
77
77
|
borderColor: error
|
|
78
78
|
? colors.destructive
|
|
79
79
|
: focused
|
|
80
|
-
?
|
|
80
|
+
? colors.primary
|
|
81
81
|
: colors.border,
|
|
82
82
|
backgroundColor: colors.background,
|
|
83
83
|
},
|
|
@@ -86,7 +86,7 @@ export function Input({ label, error, hint, prefix, suffix, prefixStyle, suffixS
|
|
|
86
86
|
>
|
|
87
87
|
{effectivePrefix ? (
|
|
88
88
|
typeof effectivePrefix === 'string' ? (
|
|
89
|
-
<Text style={[styles.prefixText, { color: colors.
|
|
89
|
+
<Text style={[styles.prefixText, { color: colors.foregroundMuted }, prefixStyle]} allowFontScaling={true}>
|
|
90
90
|
{effectivePrefix}
|
|
91
91
|
</Text>
|
|
92
92
|
) : (
|
|
@@ -110,14 +110,14 @@ export function Input({ label, error, hint, prefix, suffix, prefixStyle, suffixS
|
|
|
110
110
|
setFocused(false)
|
|
111
111
|
onBlur?.(e)
|
|
112
112
|
}}
|
|
113
|
-
placeholderTextColor={colors.
|
|
113
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
114
114
|
allowFontScaling={true}
|
|
115
115
|
secureTextEntry={effectiveSecure}
|
|
116
116
|
{...props}
|
|
117
117
|
/>
|
|
118
118
|
{effectiveSuffix ? (
|
|
119
119
|
typeof effectiveSuffix === 'string' ? (
|
|
120
|
-
<Text style={[styles.suffixText, { color: colors.
|
|
120
|
+
<Text style={[styles.suffixText, { color: colors.foregroundMuted }, suffixStyle]} allowFontScaling={true}>
|
|
121
121
|
{effectiveSuffix}
|
|
122
122
|
</Text>
|
|
123
123
|
) : (
|
|
@@ -129,7 +129,7 @@ export function Input({ label, error, hint, prefix, suffix, prefixStyle, suffixS
|
|
|
129
129
|
<Text style={[styles.helperText, { color: colors.destructive }]} allowFontScaling={true}>{error}</Text>
|
|
130
130
|
) : null}
|
|
131
131
|
{!error && hint ? (
|
|
132
|
-
<Text style={[styles.helperText, { color: colors.
|
|
132
|
+
<Text style={[styles.helperText, { color: colors.foregroundMuted }]} allowFontScaling={true}>{hint}</Text>
|
|
133
133
|
) : null}
|
|
134
134
|
</View>
|
|
135
135
|
)
|
|
@@ -141,21 +141,23 @@ const styles = StyleSheet.create({
|
|
|
141
141
|
},
|
|
142
142
|
label: {
|
|
143
143
|
fontFamily: 'Poppins-Medium',
|
|
144
|
-
fontSize: ms(
|
|
144
|
+
fontSize: ms(14), // caption size for input labels
|
|
145
145
|
},
|
|
146
146
|
inputWrapper: {
|
|
147
147
|
flexDirection: 'row',
|
|
148
148
|
alignItems: 'center',
|
|
149
|
-
borderWidth:
|
|
150
|
-
borderRadius:
|
|
149
|
+
borderWidth: 2,
|
|
150
|
+
borderRadius: 8,
|
|
151
151
|
paddingHorizontal: s(14),
|
|
152
152
|
paddingVertical: vs(11),
|
|
153
|
+
minHeight: 48,
|
|
153
154
|
},
|
|
154
155
|
input: {
|
|
155
156
|
fontFamily: 'Poppins-Regular',
|
|
156
157
|
flex: 1,
|
|
157
|
-
fontSize: ms(
|
|
158
|
-
paddingVertical:
|
|
158
|
+
fontSize: ms(16),
|
|
159
|
+
paddingVertical: vs(2),
|
|
160
|
+
includeFontPadding: false,
|
|
159
161
|
},
|
|
160
162
|
prefixContainer: {
|
|
161
163
|
marginRight: s(8),
|
|
@@ -14,7 +14,7 @@ export function LabelValue({ label, value, style }: LabelValueProps) {
|
|
|
14
14
|
|
|
15
15
|
return (
|
|
16
16
|
<View style={[styles.container, style]}>
|
|
17
|
-
<Text style={[styles.label, { color: colors.
|
|
17
|
+
<Text style={[styles.label, { color: colors.foregroundMuted }]} allowFontScaling={true}>
|
|
18
18
|
{label}
|
|
19
19
|
</Text>
|
|
20
20
|
{typeof value === 'string' ? (
|