@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retray-dev/ui-kit",
3
- "version": "5.2.0",
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
- <Text style={[styles.triggerText, { color: colors.foreground }]} allowFontScaling={true}>{item.trigger}</Text>
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
- size?: AvatarSize
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
- export function Avatar({ src, fallback, size = 'md', status, style }: AvatarProps) {
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: fontSizeMap[size] }]}
88
+ style={[styles.fallback, { color: colors.foregroundMuted, fontSize }]}
75
89
  allowFontScaling={true}
76
90
  >
77
- {fallback?.slice(0, 2).toUpperCase() ?? '?'}
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], iconColor ?? (labelVariantStyle.color as string))
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
- style,
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 style={[styles.amount, { color: textColor ?? colors.foreground }]} allowFontScaling={true}>
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
- <Text style={[styles.label, { color: colors.foregroundMuted }]} allowFontScaling={true}>
18
- {label}
19
- </Text>
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
- 'July', 'August', 'September', 'October', 'November', 'December',
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
- {MONTH_NAMES[value.month - 1]} {value.year}
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
  )