@retray-dev/ui-kit 10.1.0 → 12.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.
Files changed (192) hide show
  1. package/COMPONENTS.md +419 -38
  2. package/README.md +14 -5
  3. package/dist/Accordion.js +1 -1
  4. package/dist/Accordion.mjs +3 -3
  5. package/dist/AlertBanner.js +1 -1
  6. package/dist/AlertBanner.mjs +3 -3
  7. package/dist/AppHeader.js +1 -1
  8. package/dist/AppHeader.mjs +4 -4
  9. package/dist/Avatar.mjs +2 -2
  10. package/dist/Badge.js +1 -1
  11. package/dist/Badge.mjs +3 -3
  12. package/dist/Button.js +1 -1
  13. package/dist/Button.mjs +3 -3
  14. package/dist/Card.mjs +2 -2
  15. package/dist/CategoryStrip.js +1 -1
  16. package/dist/CategoryStrip.mjs +3 -3
  17. package/dist/Checkbox.mjs +2 -2
  18. package/dist/Chip.js +1 -1
  19. package/dist/Chip.mjs +3 -3
  20. package/dist/ConfirmDialog.d.mts +1 -6
  21. package/dist/ConfirmDialog.d.ts +1 -6
  22. package/dist/ConfirmDialog.js +30 -24
  23. package/dist/ConfirmDialog.mjs +4 -4
  24. package/dist/CurrencyDisplay.mjs +2 -2
  25. package/dist/CurrencyInput.d.mts +3 -8
  26. package/dist/CurrencyInput.d.ts +3 -8
  27. package/dist/CurrencyInput.js +4 -2
  28. package/dist/CurrencyInput.mjs +4 -4
  29. package/dist/DetailRow.d.mts +1 -1
  30. package/dist/DetailRow.d.ts +1 -1
  31. package/dist/DetailRow.js +1 -1
  32. package/dist/DetailRow.mjs +3 -3
  33. package/dist/EmptyState.js +1 -1
  34. package/dist/EmptyState.mjs +4 -4
  35. package/dist/ErrorBoundary.js +1 -1
  36. package/dist/ErrorBoundary.mjs +3 -3
  37. package/dist/Form.mjs +2 -2
  38. package/dist/IconButton.js +1 -1
  39. package/dist/IconButton.mjs +3 -3
  40. package/dist/IconPicker.d.mts +17 -0
  41. package/dist/IconPicker.d.ts +17 -0
  42. package/dist/IconPicker.js +1424 -0
  43. package/dist/IconPicker.mjs +8 -0
  44. package/dist/ImageUpload.d.mts +3 -1
  45. package/dist/ImageUpload.d.ts +3 -1
  46. package/dist/ImageUpload.js +28 -10
  47. package/dist/ImageUpload.mjs +3 -3
  48. package/dist/ImageViewer.js +1 -1
  49. package/dist/ImageViewer.mjs +5 -5
  50. package/dist/Input.js +1 -1
  51. package/dist/Input.mjs +3 -3
  52. package/dist/LabelValue.js +1 -1
  53. package/dist/LabelValue.mjs +3 -3
  54. package/dist/ListGroup.mjs +2 -2
  55. package/dist/ListItem.d.mts +7 -7
  56. package/dist/ListItem.d.ts +7 -7
  57. package/dist/ListItem.js +13 -8
  58. package/dist/ListItem.mjs +3 -3
  59. package/dist/MediaCard.js +1 -1
  60. package/dist/MediaCard.mjs +3 -3
  61. package/dist/MenuGroup.mjs +2 -2
  62. package/dist/MenuItem.js +1 -1
  63. package/dist/MenuItem.mjs +3 -3
  64. package/dist/MonthPicker.mjs +2 -2
  65. package/dist/NumberStepper.d.mts +19 -0
  66. package/dist/NumberStepper.d.ts +19 -0
  67. package/dist/NumberStepper.js +410 -0
  68. package/dist/NumberStepper.mjs +9 -0
  69. package/dist/PagerDots.js +1 -1
  70. package/dist/PagerDots.mjs +3 -3
  71. package/dist/Pressable.d.mts +15 -7
  72. package/dist/Pressable.d.ts +15 -7
  73. package/dist/Pressable.js +7 -3
  74. package/dist/Pressable.mjs +1 -1
  75. package/dist/PricingCard.js +1 -1
  76. package/dist/PricingCard.mjs +5 -5
  77. package/dist/Progress.mjs +2 -2
  78. package/dist/RadioGroup.mjs +2 -2
  79. package/dist/RetrayProvider.mjs +3 -3
  80. package/dist/Select.mjs +2 -2
  81. package/dist/SelectableGrid.js +1 -1
  82. package/dist/SelectableGrid.mjs +3 -3
  83. package/dist/Separator.mjs +2 -2
  84. package/dist/Sheet.d.mts +4 -46
  85. package/dist/Sheet.d.ts +4 -46
  86. package/dist/Sheet.js +46 -114
  87. package/dist/Sheet.mjs +2 -3
  88. package/dist/SheetSelect.js +1 -1
  89. package/dist/SheetSelect.mjs +3 -3
  90. package/dist/Skeleton.mjs +2 -2
  91. package/dist/Slider.mjs +2 -2
  92. package/dist/Spinner.mjs +2 -2
  93. package/dist/Stats.d.mts +30 -0
  94. package/dist/Stats.d.ts +30 -0
  95. package/dist/Stats.js +429 -0
  96. package/dist/Stats.mjs +9 -0
  97. package/dist/Switch.mjs +2 -2
  98. package/dist/TabBar.js +1 -1
  99. package/dist/TabBar.mjs +3 -3
  100. package/dist/Tabs.mjs +2 -2
  101. package/dist/Text.d.mts +3 -1
  102. package/dist/Text.d.ts +3 -1
  103. package/dist/Text.js +3 -3
  104. package/dist/Text.mjs +2 -2
  105. package/dist/Textarea.js +1 -1
  106. package/dist/Textarea.mjs +3 -3
  107. package/dist/Toast.mjs +2 -2
  108. package/dist/Toggle.js +1 -1
  109. package/dist/Toggle.mjs +3 -3
  110. package/dist/{chunk-DJ7RN37L.mjs → chunk-265G6A46.mjs} +2 -2
  111. package/dist/{chunk-WOEYDUJZ.mjs → chunk-2A2LEFZG.mjs} +2 -2
  112. package/dist/{chunk-ID72TK46.mjs → chunk-2CBQKU7H.mjs} +1 -1
  113. package/dist/{chunk-OB4JUQ3O.mjs → chunk-2I2AYECM.mjs} +1 -1
  114. package/dist/{chunk-WJLKJMKR.mjs → chunk-357YO24D.mjs} +4 -4
  115. package/dist/{chunk-GQYFLP3D.mjs → chunk-3GEYJ7I5.mjs} +1 -1
  116. package/dist/{chunk-AV4EMIRH.mjs → chunk-3N2M3WZL.mjs} +1 -1
  117. package/dist/{chunk-TERDKCLE.mjs → chunk-3UYAZ7I4.mjs} +2 -2
  118. package/dist/{chunk-JMOZEC77.mjs → chunk-4WFMPFZB.mjs} +1 -1
  119. package/dist/chunk-5OLNXP3S.mjs +144 -0
  120. package/dist/{chunk-6OAZJ577.mjs → chunk-7HSILTC4.mjs} +3 -3
  121. package/dist/{chunk-IRRY3CRZ.mjs → chunk-AKM4EPOT.mjs} +1 -1
  122. package/dist/{chunk-VGTDN7SW.mjs → chunk-AQEVCEXV.mjs} +2 -2
  123. package/dist/{chunk-WBOOUHSS.mjs → chunk-BCWEHE34.mjs} +1 -1
  124. package/dist/{chunk-AJ7ZDNBT.mjs → chunk-BOVUP27T.mjs} +1 -1
  125. package/dist/{chunk-BRKYVJVV.mjs → chunk-BQZE3HAW.mjs} +1 -1
  126. package/dist/{chunk-MLF3EZFW.mjs → chunk-D3Y2T42P.mjs} +2 -2
  127. package/dist/{chunk-3U4SSNWP.mjs → chunk-DF6DU42P.mjs} +2 -2
  128. package/dist/{chunk-ZJKGQMYH.mjs → chunk-DI7CBDL6.mjs} +2 -2
  129. package/dist/{chunk-2TFTAWVJ.mjs → chunk-DOGIPOF5.mjs} +2 -2
  130. package/dist/{chunk-MBMXYJJV.mjs → chunk-E7NEHHXV.mjs} +7 -3
  131. package/dist/{chunk-MX6HRKMI.mjs → chunk-EFLFRAHD.mjs} +1 -1
  132. package/dist/{chunk-SOYNZDVY.mjs → chunk-EMUWGDWC.mjs} +6 -1
  133. package/dist/{chunk-4I7D47FH.mjs → chunk-F4V6XLP4.mjs} +4 -4
  134. package/dist/{chunk-UREA2GYY.mjs → chunk-FA2KMTH5.mjs} +2 -2
  135. package/dist/{chunk-Y2NS74WS.mjs → chunk-FFTYLPSB.mjs} +46 -98
  136. package/dist/{chunk-OHBNABL5.mjs → chunk-FUVYSVGR.mjs} +14 -9
  137. package/dist/{chunk-KIHCWCWL.mjs → chunk-FVTVCJAH.mjs} +2 -2
  138. package/dist/{chunk-Y4GL2MHX.mjs → chunk-GK4VRMNE.mjs} +30 -12
  139. package/dist/{chunk-6Q64UFIA.mjs → chunk-HJ46DTJE.mjs} +1 -1
  140. package/dist/{chunk-WF2XDFRK.mjs → chunk-HLMPMUK2.mjs} +1 -1
  141. package/dist/{chunk-GD6KXMG5.mjs → chunk-I4V5XZPS.mjs} +1 -1
  142. package/dist/{chunk-AZJF2BLK.mjs → chunk-ISY26JQJ.mjs} +2 -2
  143. package/dist/{chunk-X4G6APW6.mjs → chunk-J6Q2YJEV.mjs} +1 -1
  144. package/dist/{chunk-KZL5VTYK.mjs → chunk-JCZQOY4O.mjs} +31 -24
  145. package/dist/{chunk-CZCQZHG6.mjs → chunk-JNVAIDLK.mjs} +2 -2
  146. package/dist/{chunk-SOA2Z4RB.mjs → chunk-JULSIZDM.mjs} +1 -1
  147. package/dist/{chunk-T7XZ7H7Y.mjs → chunk-KA7LTET3.mjs} +17 -3
  148. package/dist/chunk-KHYX4IOM.mjs +1114 -0
  149. package/dist/{chunk-LXJIIOYQ.mjs → chunk-LRM4AVYY.mjs} +2 -2
  150. package/dist/{chunk-VQ57HWPL.mjs → chunk-MYZ2EDYU.mjs} +2 -2
  151. package/dist/chunk-N4ZPVCJH.mjs +126 -0
  152. package/dist/{chunk-NA7PARID.mjs → chunk-NXI4YDZ2.mjs} +2 -2
  153. package/dist/{chunk-4K625MVM.mjs → chunk-OULVKTWL.mjs} +2 -2
  154. package/dist/{chunk-A4MDAP7G.mjs → chunk-P64WHW4A.mjs} +2 -2
  155. package/dist/{chunk-URI2WBIV.mjs → chunk-P73V2EKS.mjs} +2 -2
  156. package/dist/{chunk-ZUR7AU5R.mjs → chunk-PGERH3P7.mjs} +2 -2
  157. package/dist/{chunk-2UYENBLV.mjs → chunk-QSFV2P7O.mjs} +1 -1
  158. package/dist/{chunk-JT7HKXRB.mjs → chunk-S3KJCPEJ.mjs} +1 -1
  159. package/dist/{chunk-6MKGPAR2.mjs → chunk-V6NFJXKO.mjs} +2 -2
  160. package/dist/{chunk-A3A6KNQN.mjs → chunk-WOEWGSTU.mjs} +1 -1
  161. package/dist/{chunk-JUXSWN54.mjs → chunk-X26S5EVZ.mjs} +4 -2
  162. package/dist/{chunk-YFZ3ELX5.mjs → chunk-XBAGGKLW.mjs} +2 -2
  163. package/dist/{chunk-JB67UOB5.mjs → chunk-ZHMSAYLT.mjs} +2 -2
  164. package/dist/fonts.d.mts +1 -7
  165. package/dist/fonts.d.ts +1 -7
  166. package/dist/fonts.js +0 -2
  167. package/dist/fonts.mjs +1 -2
  168. package/dist/index.d.mts +7 -1
  169. package/dist/index.d.ts +7 -1
  170. package/dist/index.js +1831 -475
  171. package/dist/index.mjs +54 -51
  172. package/package.json +3 -3
  173. package/src/components/ConfirmDialog/ConfirmDialog.tsx +39 -30
  174. package/src/components/CurrencyInput/CurrencyInput.tsx +4 -7
  175. package/src/components/DetailRow/DetailRow.tsx +1 -1
  176. package/src/components/IconPicker/IconPicker.tsx +395 -0
  177. package/src/components/IconPicker/index.ts +1 -0
  178. package/src/components/ImageUpload/ImageUpload.tsx +34 -12
  179. package/src/components/ListItem/ListItem.tsx +43 -28
  180. package/src/components/NumberStepper/NumberStepper.tsx +147 -0
  181. package/src/components/NumberStepper/index.ts +1 -0
  182. package/src/components/Pressable/Pressable.tsx +20 -8
  183. package/src/components/Sheet/Sheet.tsx +64 -172
  184. package/src/components/Stats/Stats.tsx +226 -0
  185. package/src/components/Stats/index.ts +2 -0
  186. package/src/components/Text/Text.tsx +4 -2
  187. package/src/fonts.ts +0 -7
  188. package/src/index.ts +7 -1
  189. package/src/theme/colorUtils.ts +9 -0
  190. package/src/utils/curatedIcons.ts +849 -0
  191. package/src/utils/fontGuard.ts +2 -1
  192. package/src/utils/icons.ts +20 -2
@@ -0,0 +1,395 @@
1
+ import React, { useRef, useState, useCallback, useMemo, useId } from 'react'
2
+ import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, Dimensions } from 'react-native'
3
+ import { ScrollView } from 'react-native-gesture-handler'
4
+ import {
5
+ BottomSheetModal,
6
+ BottomSheetScrollView,
7
+ BottomSheetBackdrop,
8
+ } from '@gorhom/bottom-sheet'
9
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
+ import { useTheme } from '../../theme'
11
+ import { renderIcon } from '../../utils/icons'
12
+ import { CURATED_ICONS, ALL_CURATED_ICONS } from '../../utils/curatedIcons'
13
+ import { selectionAsync as hapticSelection, impactMedium } from '../../utils/haptics'
14
+ import { s, vs, ms } from '../../utils/scaling'
15
+ import { RADIUS } from '../../tokens'
16
+ import { Spinner } from '../Spinner'
17
+ import type { BottomSheetBackdropProps, BottomSheetModal as BottomSheetModalType } from '@gorhom/bottom-sheet'
18
+
19
+ const NUM_COLUMNS = 6
20
+ const GAP = 6
21
+ const TRIGGER_SIZE = s(56)
22
+ const SCREEN_HEIGHT = Dimensions.get('window').height
23
+
24
+ export interface IconPickerProps {
25
+ value: string | null
26
+ onChange: (iconName: string) => void
27
+ label?: string
28
+ error?: string
29
+ hint?: string
30
+ disabled?: boolean
31
+ numColumns?: number
32
+ gap?: number
33
+ style?: ViewStyle
34
+ }
35
+
36
+ function IconCell({ name, selected, size, onPress }: { name: string; selected: boolean; size: number; onPress: () => void }) {
37
+ const { colors } = useTheme()
38
+
39
+ const handlePress = () => {
40
+ hapticSelection()
41
+ onPress()
42
+ }
43
+
44
+ const iconColor = selected ? colors.primaryForeground : colors.foreground
45
+ const bg = selected ? colors.primary : 'transparent'
46
+
47
+ return (
48
+ <TouchableOpacity
49
+ onPress={handlePress}
50
+ activeOpacity={0.6}
51
+ touchSoundDisabled={true}
52
+ accessibilityRole="button"
53
+ accessibilityState={{ selected }}
54
+ accessibilityLabel={name}
55
+ style={[styles.cell, { width: size, height: size, backgroundColor: bg }]}
56
+ >
57
+ {renderIcon(name, ms(20), iconColor)}
58
+ </TouchableOpacity>
59
+ )
60
+ }
61
+
62
+ const IconCellMemo = React.memo(IconCell)
63
+
64
+ export function IconPicker({
65
+ value,
66
+ onChange,
67
+ label,
68
+ error,
69
+ hint,
70
+ disabled = false,
71
+ numColumns = NUM_COLUMNS,
72
+ gap = GAP,
73
+ style,
74
+ }: IconPickerProps) {
75
+ const { colors } = useTheme()
76
+ const insets = useSafeAreaInsets()
77
+ const sheetRef = useRef<BottomSheetModalType>(null)
78
+ const catScrollRef = useRef<ScrollView>(null)
79
+ const [activeCategory, setActiveCategory] = useState<string | null>(null)
80
+ const [containerWidth, setContainerWidth] = useState(() => Dimensions.get('window').width - s(16) * 2)
81
+ const [ready, setReady] = useState(false)
82
+
83
+ const sheetName = useId()
84
+
85
+ const activeIcons = useMemo(() => {
86
+ if (activeCategory) {
87
+ return CURATED_ICONS.find((c) => c.name === activeCategory)?.icons ?? ALL_CURATED_ICONS
88
+ }
89
+ return ALL_CURATED_ICONS
90
+ }, [activeCategory])
91
+
92
+ const gapPx = s(gap)
93
+ const cellSize = containerWidth > 0
94
+ ? Math.floor((containerWidth - gapPx * (numColumns - 1)) / numColumns)
95
+ : 0
96
+
97
+ const rows = useMemo(() => {
98
+ const result: string[][] = []
99
+ for (let i = 0; i < activeIcons.length; i += numColumns) {
100
+ result.push(activeIcons.slice(i, i + numColumns))
101
+ }
102
+ return result
103
+ }, [activeIcons, numColumns])
104
+
105
+ const handleDismiss = useCallback(() => {
106
+ setActiveCategory(null)
107
+ setReady(false)
108
+ }, [])
109
+
110
+ const handleSelect = useCallback(
111
+ (iconName: string) => {
112
+ onChange(iconName)
113
+ },
114
+ [onChange],
115
+ )
116
+
117
+ const handleOpen = useCallback(() => {
118
+ if (disabled) return
119
+ impactMedium()
120
+ setActiveCategory(null)
121
+ setReady(false)
122
+ sheetRef.current?.present()
123
+ }, [disabled])
124
+
125
+ const renderBackdrop = useCallback(
126
+ (props: BottomSheetBackdropProps) => (
127
+ <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} pressBehavior="close" />
128
+ ),
129
+ [],
130
+ )
131
+
132
+ const selectedIcon = value ? renderIcon(value, ms(28), colors.foreground) : null
133
+
134
+ return (
135
+ <View style={[styles.triggerContainer, style]}>
136
+ {label ? (
137
+ <Text style={[styles.triggerLabel, { color: colors.foreground }]} allowFontScaling={true}>
138
+ {label}
139
+ </Text>
140
+ ) : null}
141
+ <TouchableOpacity
142
+ onPress={handleOpen}
143
+ disabled={disabled}
144
+ activeOpacity={0.7}
145
+ touchSoundDisabled={true}
146
+ accessibilityRole="button"
147
+ accessibilityLabel={label ?? 'Seleccionar icono'}
148
+ accessibilityState={{ disabled }}
149
+ style={[
150
+ styles.trigger,
151
+ {
152
+ backgroundColor: disabled ? colors.surface : colors.background,
153
+ width: TRIGGER_SIZE,
154
+ height: TRIGGER_SIZE,
155
+ borderColor: error ? colors.destructive : value ? colors.primary : colors.border,
156
+ },
157
+ disabled && styles.triggerDisabled,
158
+ ]}
159
+ >
160
+ {selectedIcon ?? renderIcon('plus', ms(24), colors.foregroundMuted)}
161
+ </TouchableOpacity>
162
+ {error ? (
163
+ <Text
164
+ style={[styles.helperText, { color: colors.destructive }]}
165
+ allowFontScaling={true}
166
+ accessibilityLiveRegion="polite"
167
+ >
168
+ {error}
169
+ </Text>
170
+ ) : null}
171
+ {!error && hint ? (
172
+ <Text style={[styles.helperText, { color: colors.foregroundMuted }]} allowFontScaling={true}>
173
+ {hint}
174
+ </Text>
175
+ ) : null}
176
+
177
+ <BottomSheetModal
178
+ ref={sheetRef}
179
+ name={sheetName}
180
+ onDismiss={handleDismiss}
181
+ enableDynamicSizing={true}
182
+ maxDynamicContentSize={SCREEN_HEIGHT * 0.7}
183
+ backdropComponent={renderBackdrop}
184
+ backgroundStyle={{ ...styles.sheetBackground, backgroundColor: colors.card }}
185
+ handleIndicatorStyle={{ ...styles.handle, backgroundColor: colors.border }}
186
+ enablePanDownToClose
187
+ topInset={insets.top}
188
+ android_keyboardInputMode="adjustPan"
189
+ >
190
+ <BottomSheetScrollView
191
+ contentContainerStyle={styles.sheetContent}
192
+ showsVerticalScrollIndicator={true}
193
+ >
194
+ {/* Measuring container — always rendered so onLayout fires */}
195
+ <View
196
+ style={styles.gridContainer}
197
+ onLayout={(e) => {
198
+ setContainerWidth(e.nativeEvent.layout.width)
199
+ setReady(true)
200
+ }}
201
+ >
202
+ {!ready ? (
203
+ <View style={styles.loader}>
204
+ <Spinner size="md" color={colors.primary} label="Cargando iconos..." />
205
+ </View>
206
+ ) : (
207
+ <>
208
+ {/* Category section label */}
209
+ <Text style={[styles.sectionLabel, { color: colors.foregroundSubtle }]} allowFontScaling={true}>
210
+ Categorías
211
+ </Text>
212
+
213
+ {/* Horizontal scrollable category chips */}
214
+ <ScrollView
215
+ ref={catScrollRef}
216
+ horizontal
217
+ showsHorizontalScrollIndicator={false}
218
+ contentContainerStyle={styles.categoryStrip}
219
+ style={styles.categoryScroll}
220
+ >
221
+ <TouchableOpacity
222
+ onPress={() => setActiveCategory(null)}
223
+ activeOpacity={0.7}
224
+ touchSoundDisabled={true}
225
+ accessibilityRole="button"
226
+ accessibilityLabel="Todos"
227
+ accessibilityState={{ selected: activeCategory === null }}
228
+ style={[
229
+ styles.categoryChip,
230
+ {
231
+ backgroundColor: activeCategory === null ? colors.primary : colors.surface,
232
+ borderColor: activeCategory === null ? colors.primary : colors.border,
233
+ },
234
+ ]}
235
+ >
236
+ <View style={styles.categoryChipInner}>
237
+ {renderIcon('grid', ms(14), activeCategory === null ? colors.primaryForeground : colors.foregroundSubtle)}
238
+ <Text
239
+ style={[
240
+ styles.categoryChipText,
241
+ { color: activeCategory === null ? colors.primaryForeground : colors.foreground },
242
+ ]}
243
+ allowFontScaling={true}
244
+ numberOfLines={1}
245
+ >
246
+ Todos
247
+ </Text>
248
+ </View>
249
+ </TouchableOpacity>
250
+ {CURATED_ICONS.map((cat) => (
251
+ <TouchableOpacity
252
+ key={cat.name}
253
+ onPress={() => setActiveCategory(cat.name)}
254
+ activeOpacity={0.7}
255
+ touchSoundDisabled={true}
256
+ accessibilityRole="button"
257
+ accessibilityLabel={cat.labelEs}
258
+ accessibilityState={{ selected: activeCategory === cat.name }}
259
+ style={[
260
+ styles.categoryChip,
261
+ {
262
+ backgroundColor: activeCategory === cat.name ? colors.primary : colors.surface,
263
+ borderColor: activeCategory === cat.name ? colors.primary : colors.border,
264
+ },
265
+ ]}
266
+ >
267
+ <View style={styles.categoryChipInner}>
268
+ {renderIcon(cat.categoryIcon, ms(14), activeCategory === cat.name ? colors.primaryForeground : colors.foregroundSubtle)}
269
+ <Text
270
+ style={[
271
+ styles.categoryChipText,
272
+ { color: activeCategory === cat.name ? colors.primaryForeground : colors.foreground },
273
+ ]}
274
+ allowFontScaling={true}
275
+ numberOfLines={1}
276
+ >
277
+ {cat.labelEs}
278
+ </Text>
279
+ </View>
280
+ </TouchableOpacity>
281
+ ))}
282
+ </ScrollView>
283
+
284
+ {/* Separator */}
285
+ <View style={[styles.separator, { backgroundColor: colors.border }]} />
286
+
287
+ {/* Icon grid */}
288
+ {cellSize > 0 ? rows.map((row, i) => (
289
+ <View key={row[0] ?? `row-${i}`} style={[styles.row, { marginBottom: gapPx }]}>
290
+ {row.map((name) => (
291
+ <IconCellMemo
292
+ key={name}
293
+ name={name}
294
+ selected={value === name}
295
+ size={cellSize}
296
+ onPress={() => { handleSelect(name); sheetRef.current?.dismiss() }}
297
+ />
298
+ ))}
299
+ {Array.from({ length: numColumns - row.length }).map((_, j) => (
300
+ <View key={`spacer-${j}`} style={{ width: cellSize, height: cellSize }} />
301
+ ))}
302
+ </View>
303
+ )) : null}
304
+ </>
305
+ )}
306
+ </View>
307
+ </BottomSheetScrollView>
308
+ </BottomSheetModal>
309
+ </View>
310
+ )
311
+ }
312
+
313
+ const styles = StyleSheet.create({
314
+ triggerContainer: {
315
+ gap: vs(8),
316
+ },
317
+ triggerLabel: {
318
+ fontFamily: 'Sohne-Medium',
319
+ fontSize: ms(14),
320
+ },
321
+ trigger: {
322
+ borderRadius: RADIUS.md,
323
+ borderWidth: 1,
324
+ alignItems: 'center',
325
+ justifyContent: 'center',
326
+ },
327
+ triggerDisabled: {
328
+ opacity: 0.6,
329
+ },
330
+ helperText: {
331
+ fontFamily: 'Sohne-Regular',
332
+ fontSize: ms(13),
333
+ },
334
+ sheetBackground: {
335
+ borderTopLeftRadius: ms(16),
336
+ borderTopRightRadius: ms(16),
337
+ },
338
+ handle: {
339
+ width: s(36),
340
+ height: vs(4),
341
+ borderRadius: ms(2),
342
+ },
343
+ sheetContent: {
344
+ paddingHorizontal: s(16),
345
+ paddingBottom: vs(24),
346
+ },
347
+ sectionLabel: {
348
+ fontFamily: 'Sohne-Medium',
349
+ fontSize: ms(12),
350
+ marginBottom: vs(8),
351
+ textTransform: 'uppercase',
352
+ letterSpacing: 0.5,
353
+ },
354
+ categoryScroll: {
355
+ flexGrow: 0,
356
+ flexShrink: 0,
357
+ },
358
+ categoryStrip: {
359
+ gap: s(8),
360
+ },
361
+ categoryChip: {
362
+ borderRadius: RADIUS.full,
363
+ borderWidth: 1,
364
+ paddingVertical: vs(6),
365
+ paddingHorizontal: s(12),
366
+ },
367
+ categoryChipInner: {
368
+ flexDirection: 'row',
369
+ alignItems: 'center',
370
+ gap: s(6),
371
+ },
372
+ categoryChipText: {
373
+ fontFamily: 'Sohne-Medium',
374
+ fontSize: ms(12),
375
+ },
376
+ separator: {
377
+ height: StyleSheet.hairlineWidth,
378
+ marginVertical: vs(12),
379
+ },
380
+ gridContainer: {},
381
+ row: {
382
+ flexDirection: 'row',
383
+ gap: GAP,
384
+ },
385
+ cell: {
386
+ borderRadius: RADIUS.md,
387
+ alignItems: 'center',
388
+ justifyContent: 'center',
389
+ },
390
+ loader: {
391
+ minHeight: vs(200),
392
+ alignItems: 'center',
393
+ justifyContent: 'center',
394
+ },
395
+ })
@@ -0,0 +1 @@
1
+ export * from './IconPicker'
@@ -17,6 +17,8 @@ export interface ImageUploadProps {
17
17
  loading?: boolean
18
18
  /** Text shown when no image is selected. */
19
19
  placeholder?: string
20
+ /** Whether to show the placeholder text. Use false for compact/avatar variants. */
21
+ showPlaceholderText?: boolean
20
22
  /** Width of the upload area. Defaults to full width (undefined). */
21
23
  width?: number
22
24
  /** Height of the upload area. Defaults to 200. */
@@ -35,6 +37,7 @@ export function ImageUpload({
35
37
  onChange,
36
38
  loading = false,
37
39
  placeholder = 'Tap to add image',
40
+ showPlaceholderText = true,
38
41
  width,
39
42
  height = 200,
40
43
  borderRadius = RADIUS.lg,
@@ -50,27 +53,39 @@ export function ImageUpload({
50
53
  impactLight()
51
54
 
52
55
  // Dynamic import so expo-image-picker is optional at module load time.
53
- // Consumers who don't use ImageUpload never pull this dep.
54
- let ImagePicker: typeof import('expo-image-picker')
56
+ // Import ExponentImagePicker (the raw native module proxy) directly to
57
+ // avoid expo-image-picker's top-level createPermissionHook dependency.
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ let picker: any
55
60
  try {
56
- ImagePicker = await import('expo-image-picker')
61
+ const mod = await import('expo-image-picker/build/ExponentImagePicker')
62
+ picker = (mod as { default: unknown }).default
57
63
  } catch {
58
- if (__DEV__) console.warn('[ImageUpload] expo-image-picker not installed. Add it as a dependency.')
59
- return
64
+ // Fallback: try the main module
65
+ try {
66
+ picker = await import('expo-image-picker')
67
+ } catch {
68
+ if (__DEV__) console.warn('[ImageUpload] expo-image-picker not installed.')
69
+ return
70
+ }
60
71
  }
61
72
 
62
73
  if (Platform.OS !== 'web') {
63
- const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync()
64
- if (status !== 'granted') return
74
+ try {
75
+ const { status } = await picker.requestMediaLibraryPermissionsAsync()
76
+ if (status !== 'granted') return
77
+ } catch {
78
+ // Permission check failed — try picker anyway
79
+ }
65
80
  }
66
81
 
67
- const result = await ImagePicker.launchImageLibraryAsync({
82
+ const result = await picker.launchImageLibraryAsync({
68
83
  mediaTypes: ['images'],
69
84
  allowsEditing: true,
70
85
  quality: 0.8,
71
86
  })
72
87
 
73
- if (!result.canceled && result.assets[0]) {
88
+ if (!result.canceled && result.assets?.[0]) {
74
89
  onChange?.(result.assets[0].uri)
75
90
  }
76
91
  }
@@ -106,9 +121,15 @@ export function ImageUpload({
106
121
  ) : (
107
122
  <View style={styles.placeholder}>
108
123
  <Feather name="image" size={ms(28)} color={colors.foregroundMuted} />
109
- <Text style={[styles.placeholderText, { color: colors.foregroundMuted }]} allowFontScaling={true}>
110
- {placeholder}
111
- </Text>
124
+ {showPlaceholderText ? (
125
+ <Text
126
+ style={[styles.placeholderText, { color: colors.foregroundMuted }]}
127
+ numberOfLines={1}
128
+ allowFontScaling={true}
129
+ >
130
+ {placeholder}
131
+ </Text>
132
+ ) : null}
112
133
  </View>
113
134
  )}
114
135
  {loading ? (
@@ -137,6 +158,7 @@ const styles = StyleSheet.create({
137
158
  placeholderText: {
138
159
  fontFamily: 'Sohne-Regular',
139
160
  fontSize: ms(13),
161
+ textAlign: 'center',
140
162
  },
141
163
  loadingOverlay: {
142
164
  ...StyleSheet.absoluteFillObject,
@@ -31,13 +31,13 @@ export interface ListItemProps {
31
31
  leftRender?: React.ReactNode
32
32
  /**
33
33
  * Arbitrary content rendered on the right (badge, price, chevron, switch, etc.).
34
- * Replaces the old `trailing` prop (still accepted as an alias).
35
34
  */
36
35
  rightRender?: React.ReactNode | string
37
- /** @deprecated Use `rightRender` instead. */
38
- trailing?: React.ReactNode | string
39
- /** @deprecated Use `leftRender` instead. */
40
- icon?: React.ReactNode
36
+ /**
37
+ * Multiple action buttons rendered on the right with automatic gap.
38
+ * Takes precedence over `rightRender` and `showChevron`.
39
+ */
40
+ rightActions?: React.ReactNode[]
41
41
  /**
42
42
  * Icon name from `@expo/vector-icons` rendered in the left slot.
43
43
  * See https://icons.expo.fyi. Takes precedence over `leftRender`.
@@ -65,7 +65,7 @@ export interface ListItemProps {
65
65
  */
66
66
  variant?: ListItemVariant
67
67
 
68
- /** Show a right-pointing chevron on the far right. Ignored when `rightRender` / `trailing` is set. */
68
+ /** Show a right-pointing chevron on the far right. Ignored when `rightRender` / `rightActions` / `rightIcon` is set. */
69
69
  showChevron?: boolean
70
70
 
71
71
  /** Visual separator line at the bottom of the item. Useful when rendering multiple plain items in a list. */
@@ -91,8 +91,7 @@ function ListItemBase({
91
91
  imageSource,
92
92
  leftRender,
93
93
  rightRender,
94
- trailing,
95
- icon,
94
+ rightActions,
96
95
  leftIcon,
97
96
  rightIcon,
98
97
  leftIconColor,
@@ -119,16 +118,14 @@ function ListItemBase({
119
118
  onPress?.()
120
119
  }
121
120
 
122
- // imageSource takes precedence, then leftIcon, then leftRender/icon
121
+ // imageSource takes precedence, then leftIcon, then leftRender
123
122
  const effectiveLeft: React.ReactNode = imageSource
124
123
  ? <Image source={imageSource} style={styles.image} />
125
124
  : leftIcon
126
125
  ? renderIcon(leftIcon, 24, leftIconColor ?? colors.foreground)
127
- : leftRender ?? icon
126
+ : leftRender
128
127
 
129
- const effectiveRight: React.ReactNode | string | undefined = rightIcon
130
- ? renderIcon(rightIcon, 24, rightIconColor ?? colors.foregroundMuted)
131
- : rightRender ?? trailing
128
+ const hasRightContent = !!(rightIcon || (rightActions && rightActions.length > 0) || rightRender !== undefined || showChevron)
132
129
 
133
130
  const cardStyle: ViewStyle =
134
131
  variant === 'card'
@@ -181,21 +178,33 @@ function ListItemBase({
181
178
  ) : null}
182
179
  </View>
183
180
 
184
- {effectiveRight !== undefined ? (
185
- <View style={styles.rightContainer}>
186
- {typeof effectiveRight === 'string' ? (
187
- <Text
188
- style={[styles.rightText, { color: colors.foregroundMuted }]}
189
- allowFontScaling={true}
190
- >
191
- {effectiveRight}
192
- </Text>
193
- ) : (
194
- effectiveRight
195
- )}
196
- </View>
197
- ) : showChevron ? (
198
- <Entypo name="chevron-with-circle-right" size={20} color={colors.foregroundMuted} />
181
+ {hasRightContent ? (
182
+ rightIcon ? (
183
+ <View style={styles.rightContainer}>
184
+ {renderIcon(rightIcon, 24, rightIconColor ?? colors.foregroundMuted)}
185
+ </View>
186
+ ) : rightActions && rightActions.length > 0 ? (
187
+ <View style={styles.rightActionsContainer}>
188
+ {rightActions.map((action, i) => (
189
+ <React.Fragment key={i}>{action}</React.Fragment>
190
+ ))}
191
+ </View>
192
+ ) : rightRender !== undefined ? (
193
+ <View style={styles.rightContainer}>
194
+ {typeof rightRender === 'string' ? (
195
+ <Text
196
+ style={[styles.rightText, { color: colors.foregroundMuted }]}
197
+ allowFontScaling={true}
198
+ >
199
+ {rightRender}
200
+ </Text>
201
+ ) : (
202
+ rightRender
203
+ )}
204
+ </View>
205
+ ) : showChevron ? (
206
+ <Entypo name="chevron-with-circle-right" size={20} color={colors.foregroundMuted} />
207
+ ) : null
199
208
  ) : null}
200
209
  </>
201
210
  )
@@ -283,6 +292,12 @@ const styles = StyleSheet.create({
283
292
  flexShrink: 0,
284
293
  maxWidth: s(160),
285
294
  },
295
+ rightActionsContainer: {
296
+ flexDirection: 'row',
297
+ alignItems: 'center',
298
+ gap: s(8),
299
+ flexShrink: 0,
300
+ },
286
301
  rightText: {
287
302
  fontFamily: 'Sohne-Regular',
288
303
  fontSize: ms(14),