@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.
- package/COMPONENTS.md +500 -140
- package/EXAMPLES.md +666 -0
- package/README.md +3 -3
- package/dist/index.d.mts +253 -49
- package/dist/index.d.ts +253 -49
- package/dist/index.js +955 -610
- package/dist/index.mjs +886 -552
- package/package.json +9 -3
- package/src/components/Accordion/Accordion.tsx +31 -4
- package/src/components/AlertBanner/AlertBanner.tsx +16 -33
- package/src/components/Avatar/Avatar.tsx +21 -7
- package/src/components/Button/Button.tsx +34 -13
- package/src/components/ButtonGroup/ButtonGroup.tsx +60 -0
- package/src/components/ButtonGroup/index.ts +1 -0
- package/src/components/Card/Card.tsx +12 -9
- package/src/components/Chip/Chip.tsx +8 -1
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +4 -4
- package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +38 -5
- package/src/components/DetailRow/DetailRow.tsx +140 -0
- package/src/components/DetailRow/index.ts +1 -0
- package/src/components/EmptyState/EmptyState.tsx +21 -6
- package/src/components/Input/Input.tsx +21 -10
- package/src/components/LabelValue/LabelValue.tsx +25 -4
- package/src/components/ListItem/ListItem.tsx +14 -8
- package/src/components/MediaCard/MediaCard.tsx +1 -0
- package/src/components/MenuItem/MenuItem.tsx +206 -0
- package/src/components/MenuItem/index.ts +2 -0
- package/src/components/MonthPicker/MonthPicker.tsx +18 -6
- package/src/components/Select/Select.tsx +1 -1
- package/src/components/Separator/Separator.tsx +2 -0
- package/src/components/Sheet/Sheet.tsx +165 -36
- package/src/components/Sheet/index.ts +1 -1
- package/src/components/Tabs/Tabs.tsx +4 -4
- package/src/components/Textarea/Textarea.tsx +66 -29
- package/src/components/Toast/Toast.tsx +41 -267
- package/src/components/Toast/index.ts +1 -2
- package/src/components/Toggle/Toggle.tsx +2 -2
- package/src/index.ts +6 -0
- package/src/theme/colors.ts +3 -0
- package/src/theme/types.ts +11 -0
- package/src/tokens.ts +4 -4
- 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": "
|
|
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 ?
|
|
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 ?
|
|
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
|
-
<
|
|
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:
|
|
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:
|
|
51
|
+
<Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>{title}</Text>
|
|
67
52
|
{description ? (
|
|
68
|
-
<Text style={[styles.description, { color:
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
88
|
+
style={[styles.fallback, { color: colors.foregroundMuted, fontSize }]}
|
|
75
89
|
allowFontScaling={true}
|
|
76
90
|
>
|
|
77
|
-
{fallback
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
168
|
+
borderRadius: RADIUS.md, // 14px — Airbnb-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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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:
|
|
81
|
-
shadowOpacity: 0.
|
|
82
|
-
shadowRadius:
|
|
83
|
-
elevation:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
80
|
+
includeFontPadding: false,
|
|
81
|
+
textAlignVertical: 'top',
|
|
49
82
|
},
|
|
50
83
|
})
|