@retray-dev/ui-kit 5.2.0 → 5.4.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 +242 -17
- package/EXAMPLES.md +666 -0
- package/README.md +3 -3
- package/dist/index.d.mts +112 -11
- package/dist/index.d.ts +112 -11
- package/dist/index.js +579 -358
- package/dist/index.mjs +516 -298
- package/package.json +3 -2
- package/src/components/Accordion/Accordion.tsx +25 -2
- package/src/components/Avatar/Avatar.tsx +21 -7
- package/src/components/Button/Button.tsx +16 -7
- package/src/components/ButtonGroup/ButtonGroup.tsx +60 -0
- package/src/components/ButtonGroup/index.ts +1 -0
- package/src/components/Chip/Chip.tsx +8 -1
- package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +25 -3
- package/src/components/DetailRow/DetailRow.tsx +140 -0
- package/src/components/DetailRow/index.ts +1 -0
- package/src/components/LabelValue/LabelValue.tsx +25 -4
- package/src/components/MonthPicker/MonthPicker.tsx +18 -6
- package/src/components/Sheet/Sheet.tsx +19 -3
- package/src/components/Textarea/Textarea.tsx +66 -29
- package/src/index.ts +5 -0
- package/src/tokens.ts +1 -1
- package/src/utils/typography.ts +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retray-dev/ui-kit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.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",
|
|
@@ -17,11 +17,18 @@ import { Entypo } from '@expo/vector-icons'
|
|
|
17
17
|
import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
18
18
|
import { useTheme } from '../../theme'
|
|
19
19
|
import { s, vs, ms } from '../../utils/scaling'
|
|
20
|
+
import { renderIcon } from '../../utils/icons'
|
|
20
21
|
|
|
21
22
|
export interface AccordionItem {
|
|
22
23
|
value: string
|
|
23
24
|
trigger: string
|
|
24
25
|
content: React.ReactNode
|
|
26
|
+
/** Icon name from @expo/vector-icons rendered left of trigger. */
|
|
27
|
+
iconName?: string
|
|
28
|
+
/** Custom icon node rendered left of trigger. */
|
|
29
|
+
icon?: React.ReactNode
|
|
30
|
+
/** Override icon color. Defaults to foregroundMuted. */
|
|
31
|
+
iconColor?: string
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface AccordionProps {
|
|
@@ -47,6 +54,10 @@ function AccordionItemComponent({
|
|
|
47
54
|
}) {
|
|
48
55
|
const { colors } = useTheme()
|
|
49
56
|
|
|
57
|
+
const resolvedIcon = item.iconName
|
|
58
|
+
? renderIcon(item.iconName, ms(16), item.iconColor ?? colors.foregroundMuted)
|
|
59
|
+
: item.icon
|
|
60
|
+
|
|
50
61
|
// Shared values — all animation lives on the UI thread
|
|
51
62
|
const isExpanded = useSharedValue(isOpen)
|
|
52
63
|
const height = useSharedValue(0)
|
|
@@ -91,7 +102,10 @@ function AccordionItemComponent({
|
|
|
91
102
|
onToggle()
|
|
92
103
|
}}
|
|
93
104
|
>
|
|
94
|
-
<
|
|
105
|
+
<View style={styles.triggerContent}>
|
|
106
|
+
{resolvedIcon ? <View style={styles.icon}>{resolvedIcon}</View> : null}
|
|
107
|
+
<Text style={[styles.triggerText, { color: colors.foreground }]} allowFontScaling={true}>{item.trigger}</Text>
|
|
108
|
+
</View>
|
|
95
109
|
<Animated.View style={[styles.chevron, rotationStyle]}>
|
|
96
110
|
<Entypo name="chevron-down" size={18} color={colors.foregroundMuted} />
|
|
97
111
|
</Animated.View>
|
|
@@ -163,10 +177,19 @@ const styles = StyleSheet.create({
|
|
|
163
177
|
paddingHorizontal: s(14),
|
|
164
178
|
paddingVertical: vs(12),
|
|
165
179
|
},
|
|
180
|
+
triggerContent: {
|
|
181
|
+
flexDirection: 'row',
|
|
182
|
+
alignItems: 'center',
|
|
183
|
+
gap: s(8),
|
|
184
|
+
flex: 1,
|
|
185
|
+
},
|
|
186
|
+
icon: {
|
|
187
|
+
alignItems: 'center',
|
|
188
|
+
justifyContent: 'center',
|
|
189
|
+
},
|
|
166
190
|
triggerText: {
|
|
167
191
|
fontFamily: 'Poppins-Medium',
|
|
168
192
|
fontSize: ms(14),
|
|
169
|
-
flex: 1,
|
|
170
193
|
},
|
|
171
194
|
chevron: {
|
|
172
195
|
marginLeft: s(8),
|
|
@@ -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>
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
const nativeDriver = Platform.OS !== 'web'
|
|
15
15
|
import { impactLight } 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 }
|
|
@@ -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}
|
|
@@ -135,6 +142,7 @@ export function Button({
|
|
|
135
142
|
<Text
|
|
136
143
|
style={[styles.label, labelVariantStyle, labelSizeStyles[size], effectiveIcon ? styles.labelWithIcon : undefined]}
|
|
137
144
|
allowFontScaling={true}
|
|
145
|
+
numberOfLines={1}
|
|
138
146
|
>
|
|
139
147
|
{label}
|
|
140
148
|
</Text>
|
|
@@ -161,6 +169,7 @@ const styles = StyleSheet.create({
|
|
|
161
169
|
},
|
|
162
170
|
label: {
|
|
163
171
|
fontFamily: 'Poppins-Medium',
|
|
172
|
+
flexShrink: 1,
|
|
164
173
|
},
|
|
165
174
|
labelWithIcon: {
|
|
166
175
|
marginHorizontal: s(6),
|
|
@@ -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'
|
|
@@ -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>
|
|
@@ -3,6 +3,15 @@ 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
|
+
|
|
6
15
|
export interface CurrencyDisplayProps {
|
|
7
16
|
value: number | string
|
|
8
17
|
/** Symbol prepended to the formatted value. Defaults to `'$'`. */
|
|
@@ -11,6 +20,12 @@ export interface CurrencyDisplayProps {
|
|
|
11
20
|
showDecimals?: boolean
|
|
12
21
|
/** Override the color of the formatted text. Defaults to the `foreground` theme token. */
|
|
13
22
|
textColor?: string
|
|
23
|
+
/** Predefined size variant. Overrides the default 56px size. */
|
|
24
|
+
variant?: CurrencyDisplayVariant
|
|
25
|
+
/** Enable adjustsFontSizeToFit so long values shrink to fit in one line. */
|
|
26
|
+
autoScale?: boolean
|
|
27
|
+
/** Maximum font size when autoScale is true (defaults to variant size or 56px). */
|
|
28
|
+
maxFontSize?: number
|
|
14
29
|
style?: ViewStyle
|
|
15
30
|
}
|
|
16
31
|
|
|
@@ -27,13 +42,21 @@ function formatValue(value: number | string, prefix: string, showDecimals: boole
|
|
|
27
42
|
return `${sign}${prefix}${intPart}`
|
|
28
43
|
}
|
|
29
44
|
|
|
30
|
-
export function CurrencyDisplay({ value, prefix = '$', showDecimals = false, textColor, style }: CurrencyDisplayProps) {
|
|
45
|
+
export function CurrencyDisplay({ value, prefix = '$', showDecimals = false, textColor, variant, autoScale, maxFontSize, style }: CurrencyDisplayProps) {
|
|
31
46
|
const { colors } = useTheme()
|
|
32
47
|
const formatted = formatValue(value, prefix, showDecimals)
|
|
48
|
+
const baseFontSize = variant ? variantFontSize[variant] : ms(56)
|
|
49
|
+
const fontSize = maxFontSize ?? baseFontSize
|
|
33
50
|
|
|
34
51
|
return (
|
|
35
52
|
<View style={[styles.container, style]}>
|
|
36
|
-
<Text
|
|
53
|
+
<Text
|
|
54
|
+
style={[styles.amount, { color: textColor ?? colors.foreground, fontSize }]}
|
|
55
|
+
allowFontScaling={true}
|
|
56
|
+
numberOfLines={autoScale ? 1 : undefined}
|
|
57
|
+
adjustsFontSizeToFit={autoScale}
|
|
58
|
+
minimumFontScale={autoScale ? 0.5 : undefined}
|
|
59
|
+
>
|
|
37
60
|
{formatted}
|
|
38
61
|
</Text>
|
|
39
62
|
</View>
|
|
@@ -44,7 +67,6 @@ const styles = StyleSheet.create({
|
|
|
44
67
|
container: {},
|
|
45
68
|
amount: {
|
|
46
69
|
fontFamily: 'Poppins-Bold',
|
|
47
|
-
fontSize: ms(56),
|
|
48
70
|
letterSpacing: -2,
|
|
49
71
|
},
|
|
50
72
|
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, StyleSheet, ViewStyle, TextStyle } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
4
|
+
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
5
|
+
import { renderIcon } from '../../utils/icons'
|
|
6
|
+
|
|
7
|
+
export type DetailRowSeparator = 'dotted' | 'solid' | 'dashed' | 'none'
|
|
8
|
+
export type DetailRowLabelWeight = 'normal' | 'medium' | 'semibold' | 'bold'
|
|
9
|
+
|
|
10
|
+
const weightMap: Record<DetailRowLabelWeight, string> = {
|
|
11
|
+
normal: 'Poppins-Regular',
|
|
12
|
+
medium: 'Poppins-Medium',
|
|
13
|
+
semibold: 'Poppins-SemiBold',
|
|
14
|
+
bold: 'Poppins-Bold',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DetailRowProps {
|
|
18
|
+
label: React.ReactNode
|
|
19
|
+
value: string
|
|
20
|
+
/** Dotted/dashed/solid line between label and value. Defaults to 'dotted'. */
|
|
21
|
+
separator?: DetailRowSeparator
|
|
22
|
+
labelWeight?: DetailRowLabelWeight
|
|
23
|
+
/** Semantic color key or hex string for value text. */
|
|
24
|
+
valueColor?: string
|
|
25
|
+
/** Node rendered left of the label (e.g. Avatar, Icon). */
|
|
26
|
+
leftIcon?: React.ReactNode
|
|
27
|
+
/** Icon name from @expo/vector-icons rendered left of label. Takes precedence over leftIcon. */
|
|
28
|
+
leftIconName?: string
|
|
29
|
+
/** Override left icon color. Defaults to foregroundMuted. */
|
|
30
|
+
leftIconColor?: string
|
|
31
|
+
/** Icon name from @expo/vector-icons rendered right of value. */
|
|
32
|
+
rightIconName?: string
|
|
33
|
+
/** Override right icon color. Defaults to foregroundMuted. */
|
|
34
|
+
rightIconColor?: string
|
|
35
|
+
style?: ViewStyle
|
|
36
|
+
labelStyle?: TextStyle
|
|
37
|
+
valueStyle?: TextStyle
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function DetailRow({
|
|
41
|
+
label,
|
|
42
|
+
value,
|
|
43
|
+
separator = 'dotted',
|
|
44
|
+
labelWeight = 'normal',
|
|
45
|
+
valueColor,
|
|
46
|
+
leftIcon,
|
|
47
|
+
leftIconName,
|
|
48
|
+
leftIconColor,
|
|
49
|
+
rightIconName,
|
|
50
|
+
rightIconColor,
|
|
51
|
+
style,
|
|
52
|
+
labelStyle,
|
|
53
|
+
valueStyle,
|
|
54
|
+
}: DetailRowProps) {
|
|
55
|
+
const { colors } = useTheme()
|
|
56
|
+
|
|
57
|
+
const resolvedLeftIcon = leftIconName
|
|
58
|
+
? renderIcon(leftIconName, ms(14), leftIconColor ?? colors.foregroundMuted)
|
|
59
|
+
: leftIcon
|
|
60
|
+
|
|
61
|
+
const resolvedRightIcon = rightIconName
|
|
62
|
+
? renderIcon(rightIconName, ms(14), rightIconColor ?? colors.foregroundMuted)
|
|
63
|
+
: null
|
|
64
|
+
|
|
65
|
+
const separatorStyle: ViewStyle | null =
|
|
66
|
+
separator === 'none'
|
|
67
|
+
? null
|
|
68
|
+
: {
|
|
69
|
+
flex: 1,
|
|
70
|
+
height: 1,
|
|
71
|
+
borderBottomWidth: 1,
|
|
72
|
+
borderStyle: separator,
|
|
73
|
+
borderColor: 'rgba(128,128,128,0.3)',
|
|
74
|
+
marginHorizontal: s(4),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<View style={[styles.row, style]}>
|
|
79
|
+
<View style={styles.labelSide}>
|
|
80
|
+
{resolvedLeftIcon ? <View style={styles.icon}>{resolvedLeftIcon}</View> : null}
|
|
81
|
+
{typeof label === 'string' ? (
|
|
82
|
+
<Text
|
|
83
|
+
style={[styles.labelText, { color: colors.foregroundMuted, fontFamily: weightMap[labelWeight] }, labelStyle]}
|
|
84
|
+
allowFontScaling={true}
|
|
85
|
+
>
|
|
86
|
+
{label}
|
|
87
|
+
</Text>
|
|
88
|
+
) : (
|
|
89
|
+
label
|
|
90
|
+
)}
|
|
91
|
+
</View>
|
|
92
|
+
{separatorStyle ? <View style={separatorStyle} /> : <View style={styles.spacer} />}
|
|
93
|
+
<View style={styles.valueSide}>
|
|
94
|
+
<Text
|
|
95
|
+
style={[styles.valueText, { color: valueColor ?? colors.foreground }, valueStyle]}
|
|
96
|
+
allowFontScaling={true}
|
|
97
|
+
>
|
|
98
|
+
{value}
|
|
99
|
+
</Text>
|
|
100
|
+
{resolvedRightIcon ? <View style={styles.icon}>{resolvedRightIcon}</View> : null}
|
|
101
|
+
</View>
|
|
102
|
+
</View>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const styles = StyleSheet.create({
|
|
107
|
+
row: {
|
|
108
|
+
flexDirection: 'row',
|
|
109
|
+
alignItems: 'center',
|
|
110
|
+
gap: s(4),
|
|
111
|
+
},
|
|
112
|
+
labelSide: {
|
|
113
|
+
flexDirection: 'row',
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
gap: s(4),
|
|
116
|
+
flexShrink: 0,
|
|
117
|
+
},
|
|
118
|
+
icon: {
|
|
119
|
+
alignItems: 'center',
|
|
120
|
+
justifyContent: 'center',
|
|
121
|
+
},
|
|
122
|
+
spacer: {
|
|
123
|
+
flex: 1,
|
|
124
|
+
},
|
|
125
|
+
labelText: {
|
|
126
|
+
fontSize: ms(13),
|
|
127
|
+
lineHeight: mvs(18),
|
|
128
|
+
},
|
|
129
|
+
valueSide: {
|
|
130
|
+
flexDirection: 'row',
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
gap: s(4),
|
|
133
|
+
flexShrink: 0,
|
|
134
|
+
},
|
|
135
|
+
valueText: {
|
|
136
|
+
fontFamily: 'Poppins-SemiBold',
|
|
137
|
+
fontSize: ms(13),
|
|
138
|
+
lineHeight: mvs(18),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './DetailRow'
|
|
@@ -2,21 +2,33 @@ import React from 'react'
|
|
|
2
2
|
import { View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
3
3
|
import { useTheme } from '../../theme'
|
|
4
4
|
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
5
|
+
import { renderIcon } from '../../utils/icons'
|
|
5
6
|
|
|
6
7
|
export interface LabelValueProps {
|
|
7
8
|
label: string
|
|
8
9
|
value: string | React.ReactNode
|
|
10
|
+
/** Icon name from @expo/vector-icons rendered left of label. */
|
|
11
|
+
iconName?: string
|
|
12
|
+
/** Override icon color. Defaults to foregroundMuted. */
|
|
13
|
+
iconColor?: string
|
|
9
14
|
style?: ViewStyle
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
export function LabelValue({ label, value, style }: LabelValueProps) {
|
|
17
|
+
export function LabelValue({ label, value, iconName, iconColor, style }: LabelValueProps) {
|
|
13
18
|
const { colors } = useTheme()
|
|
14
19
|
|
|
20
|
+
const resolvedIcon = iconName
|
|
21
|
+
? renderIcon(iconName, ms(14), iconColor ?? colors.foregroundMuted)
|
|
22
|
+
: null
|
|
23
|
+
|
|
15
24
|
return (
|
|
16
25
|
<View style={[styles.container, style]}>
|
|
17
|
-
<
|
|
18
|
-
{
|
|
19
|
-
|
|
26
|
+
<View style={styles.labelSide}>
|
|
27
|
+
{resolvedIcon ? <View style={styles.icon}>{resolvedIcon}</View> : null}
|
|
28
|
+
<Text style={[styles.label, { color: colors.foregroundMuted }]} allowFontScaling={true}>
|
|
29
|
+
{label}
|
|
30
|
+
</Text>
|
|
31
|
+
</View>
|
|
20
32
|
{typeof value === 'string' ? (
|
|
21
33
|
<Text style={[styles.value, { color: colors.foreground }]} allowFontScaling={true}>
|
|
22
34
|
{value}
|
|
@@ -35,6 +47,15 @@ const styles = StyleSheet.create({
|
|
|
35
47
|
alignItems: 'center',
|
|
36
48
|
gap: s(12),
|
|
37
49
|
},
|
|
50
|
+
labelSide: {
|
|
51
|
+
flexDirection: 'row',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
gap: s(4),
|
|
54
|
+
},
|
|
55
|
+
icon: {
|
|
56
|
+
alignItems: 'center',
|
|
57
|
+
justifyContent: 'center',
|
|
58
|
+
},
|
|
38
59
|
label: {
|
|
39
60
|
fontFamily: 'Poppins-Regular',
|
|
40
61
|
fontSize: ms(13),
|
|
@@ -5,10 +5,12 @@ import { selectionAsync as hapticSelection } from '../../utils/haptics'
|
|
|
5
5
|
import { useTheme } from '../../theme'
|
|
6
6
|
import { s, vs, ms, mvs } from '../../utils/scaling'
|
|
7
7
|
|
|
8
|
-
const MONTH_NAMES =
|
|
9
|
-
'January', 'February', 'March', 'April', 'May', 'June',
|
|
10
|
-
'
|
|
11
|
-
]
|
|
8
|
+
const MONTH_NAMES: Record<string, string[]> = {
|
|
9
|
+
en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
|
10
|
+
es: ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'],
|
|
11
|
+
pt: ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'],
|
|
12
|
+
fr: ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'],
|
|
13
|
+
}
|
|
12
14
|
|
|
13
15
|
export interface MonthPickerValue {
|
|
14
16
|
/** Month number 1–12 */
|
|
@@ -19,12 +21,22 @@ export interface MonthPickerValue {
|
|
|
19
21
|
export interface MonthPickerProps {
|
|
20
22
|
value: MonthPickerValue
|
|
21
23
|
onChange: (value: MonthPickerValue) => void
|
|
24
|
+
/** BCP 47 locale tag. Built-in: 'en' | 'es' | 'pt' | 'fr'. For other locales supply formatLabel. */
|
|
25
|
+
locale?: string
|
|
26
|
+
/** Custom label formatter. Takes precedence over locale. */
|
|
27
|
+
formatLabel?: (value: MonthPickerValue) => string
|
|
22
28
|
style?: ViewStyle
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
export function MonthPicker({ value, onChange, style }: MonthPickerProps) {
|
|
31
|
+
export function MonthPicker({ value, onChange, locale = 'en', formatLabel, style }: MonthPickerProps) {
|
|
26
32
|
const { colors } = useTheme()
|
|
27
33
|
|
|
34
|
+
const getLabel = (): string => {
|
|
35
|
+
if (formatLabel) return formatLabel(value)
|
|
36
|
+
const names = MONTH_NAMES[locale] ?? MONTH_NAMES.en
|
|
37
|
+
return `${names[value.month - 1]} ${value.year}`
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
const handlePrev = () => {
|
|
29
41
|
hapticSelection()
|
|
30
42
|
if (value.month === 1) {
|
|
@@ -54,7 +66,7 @@ export function MonthPicker({ value, onChange, style }: MonthPickerProps) {
|
|
|
54
66
|
<Entypo name="chevron-left" size={22} color={colors.foreground} />
|
|
55
67
|
</TouchableOpacity>
|
|
56
68
|
<Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>
|
|
57
|
-
{
|
|
69
|
+
{getLabel()}
|
|
58
70
|
</Text>
|
|
59
71
|
<TouchableOpacity
|
|
60
72
|
style={styles.arrow}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useRef } from 'react'
|
|
2
|
-
import { View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
2
|
+
import { View, Text, StyleSheet, ViewStyle, KeyboardAvoidingView, Platform } from 'react-native'
|
|
3
3
|
import {
|
|
4
4
|
BottomSheetModal,
|
|
5
5
|
BottomSheetView,
|
|
@@ -26,6 +26,10 @@ export interface SheetProps {
|
|
|
26
26
|
scrollable?: boolean
|
|
27
27
|
/** Cap sheet height (dp). Children scroll when content exceeds this value. */
|
|
28
28
|
maxHeight?: number
|
|
29
|
+
/** Wrap content in KeyboardAvoidingView. Defaults to platform-appropriate behavior when set. */
|
|
30
|
+
keyboardBehavior?: 'padding' | 'height' | 'position' | 'none'
|
|
31
|
+
/** Extra vertical offset for the keyboard avoiding view. */
|
|
32
|
+
keyboardOffset?: number
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export function Sheet({
|
|
@@ -37,6 +41,8 @@ export function Sheet({
|
|
|
37
41
|
style,
|
|
38
42
|
scrollable,
|
|
39
43
|
maxHeight,
|
|
44
|
+
keyboardBehavior,
|
|
45
|
+
keyboardOffset = 0,
|
|
40
46
|
}: SheetProps) {
|
|
41
47
|
const { colors } = useTheme()
|
|
42
48
|
const ref = useRef<BottomSheetModal>(null)
|
|
@@ -74,6 +80,16 @@ export function Sheet({
|
|
|
74
80
|
|
|
75
81
|
const useScroll = scrollable || !!maxHeight
|
|
76
82
|
|
|
83
|
+
const wrapKeyboard = (node: React.ReactNode) => {
|
|
84
|
+
if (!keyboardBehavior || keyboardBehavior === 'none') return node
|
|
85
|
+
const behavior = keyboardBehavior ?? Platform.select({ ios: 'padding', android: 'height' }) as 'padding' | 'height'
|
|
86
|
+
return (
|
|
87
|
+
<KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset}>
|
|
88
|
+
{node}
|
|
89
|
+
</KeyboardAvoidingView>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
return (
|
|
78
94
|
<BottomSheetModal
|
|
79
95
|
ref={ref}
|
|
@@ -85,7 +101,7 @@ export function Sheet({
|
|
|
85
101
|
enablePanDownToClose
|
|
86
102
|
>
|
|
87
103
|
<BottomSheetView style={maxHeight ? { maxHeight } : undefined}>
|
|
88
|
-
{useScroll ? (
|
|
104
|
+
{wrapKeyboard(useScroll ? (
|
|
89
105
|
<BottomSheetScrollView contentContainerStyle={[styles.content, style]}>
|
|
90
106
|
{headerNode}
|
|
91
107
|
{children}
|
|
@@ -95,7 +111,7 @@ export function Sheet({
|
|
|
95
111
|
{headerNode}
|
|
96
112
|
{children}
|
|
97
113
|
</BottomSheetView>
|
|
98
|
-
)}
|
|
114
|
+
))}
|
|
99
115
|
</BottomSheetView>
|
|
100
116
|
</BottomSheetModal>
|
|
101
117
|
)
|