orn-ui 0.1.0 → 0.2.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 (307) hide show
  1. package/README.md +9 -2
  2. package/lib/commonjs/atoms/Avatar.js +1 -1
  3. package/lib/commonjs/atoms/Avatar.js.map +1 -1
  4. package/lib/commonjs/atoms/Badge.js +10 -8
  5. package/lib/commonjs/atoms/Badge.js.map +1 -1
  6. package/lib/commonjs/atoms/Button.js +8 -6
  7. package/lib/commonjs/atoms/Button.js.map +1 -1
  8. package/lib/commonjs/atoms/Checkbox.js +4 -1
  9. package/lib/commonjs/atoms/Checkbox.js.map +1 -1
  10. package/lib/commonjs/atoms/Fab.js +1 -1
  11. package/lib/commonjs/atoms/Fab.js.map +1 -1
  12. package/lib/commonjs/atoms/Image.js +2 -2
  13. package/lib/commonjs/atoms/Image.js.map +1 -1
  14. package/lib/commonjs/atoms/Input.js +2 -2
  15. package/lib/commonjs/atoms/Input.js.map +1 -1
  16. package/lib/commonjs/atoms/PressableScale.js +9 -10
  17. package/lib/commonjs/atoms/PressableScale.js.map +1 -1
  18. package/lib/commonjs/atoms/Skeleton.js +96 -0
  19. package/lib/commonjs/atoms/Skeleton.js.map +1 -0
  20. package/lib/commonjs/atoms/Transition.js +171 -0
  21. package/lib/commonjs/atoms/Transition.js.map +1 -0
  22. package/lib/commonjs/atoms/index.js +20 -0
  23. package/lib/commonjs/atoms/index.js.map +1 -1
  24. package/lib/commonjs/molecules/AvatarHeader.js +2 -2
  25. package/lib/commonjs/molecules/AvatarHeader.js.map +1 -1
  26. package/lib/commonjs/molecules/OptionCard.js +57 -24
  27. package/lib/commonjs/molecules/OptionCard.js.map +1 -1
  28. package/lib/commonjs/molecules/{ThemeToggle.js → SegmentedControl.js} +37 -37
  29. package/lib/commonjs/molecules/SegmentedControl.js.map +1 -0
  30. package/lib/commonjs/molecules/Stepper.js +124 -47
  31. package/lib/commonjs/molecules/Stepper.js.map +1 -1
  32. package/lib/commonjs/molecules/Steps.js +2 -2
  33. package/lib/commonjs/molecules/Steps.js.map +1 -1
  34. package/lib/commonjs/molecules/index.js +9 -3
  35. package/lib/commonjs/molecules/index.js.map +1 -1
  36. package/lib/commonjs/organisms/Alert.js +6 -5
  37. package/lib/commonjs/organisms/Alert.js.map +1 -1
  38. package/lib/commonjs/organisms/AlertProvider.js +26 -0
  39. package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
  40. package/lib/commonjs/organisms/BottomSheet.js +118 -39
  41. package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
  42. package/lib/commonjs/organisms/DateField.js +2 -2
  43. package/lib/commonjs/organisms/DateField.js.map +1 -1
  44. package/lib/commonjs/organisms/DatePicker.js +3 -3
  45. package/lib/commonjs/organisms/DatePicker.js.map +1 -1
  46. package/lib/commonjs/organisms/List.js +66 -44
  47. package/lib/commonjs/organisms/List.js.map +1 -1
  48. package/lib/commonjs/organisms/Modal.js +42 -87
  49. package/lib/commonjs/organisms/Modal.js.map +1 -1
  50. package/lib/commonjs/organisms/NavigationBar.js +205 -0
  51. package/lib/commonjs/organisms/NavigationBar.js.map +1 -0
  52. package/lib/commonjs/organisms/Screen.js +9 -2
  53. package/lib/commonjs/organisms/Screen.js.map +1 -1
  54. package/lib/commonjs/organisms/SearchList.js +99 -30
  55. package/lib/commonjs/organisms/SearchList.js.map +1 -1
  56. package/lib/commonjs/organisms/Select.js +226 -54
  57. package/lib/commonjs/organisms/Select.js.map +1 -1
  58. package/lib/commonjs/organisms/ThemeToggle.js +52 -0
  59. package/lib/commonjs/organisms/ThemeToggle.js.map +1 -0
  60. package/lib/commonjs/organisms/Toast.js +20 -25
  61. package/lib/commonjs/organisms/Toast.js.map +1 -1
  62. package/lib/commonjs/organisms/ToastProvider.js +24 -0
  63. package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
  64. package/lib/commonjs/organisms/Wizard.js +22 -2
  65. package/lib/commonjs/organisms/Wizard.js.map +1 -1
  66. package/lib/commonjs/organisms/imperativeBridge.js +28 -0
  67. package/lib/commonjs/organisms/imperativeBridge.js.map +1 -0
  68. package/lib/commonjs/organisms/index.js +50 -0
  69. package/lib/commonjs/organisms/index.js.map +1 -1
  70. package/lib/commonjs/safe-area/SafeAreaUIProvider.js +65 -0
  71. package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -0
  72. package/lib/commonjs/safe-area/index.js +13 -0
  73. package/lib/commonjs/safe-area/index.js.map +1 -0
  74. package/lib/commonjs/theme/UIProvider.js +11 -0
  75. package/lib/commonjs/theme/UIProvider.js.map +1 -1
  76. package/lib/commonjs/theme/colors.js +207 -0
  77. package/lib/commonjs/theme/colors.js.map +1 -0
  78. package/lib/commonjs/theme/createTheme.js +25 -2
  79. package/lib/commonjs/theme/createTheme.js.map +1 -1
  80. package/lib/commonjs/theme/index.js +25 -0
  81. package/lib/commonjs/theme/index.js.map +1 -1
  82. package/lib/commonjs/theme/palettes.js +73 -11
  83. package/lib/commonjs/theme/palettes.js.map +1 -1
  84. package/lib/module/atoms/Avatar.js +1 -1
  85. package/lib/module/atoms/Avatar.js.map +1 -1
  86. package/lib/module/atoms/Badge.js +10 -8
  87. package/lib/module/atoms/Badge.js.map +1 -1
  88. package/lib/module/atoms/Button.js +8 -6
  89. package/lib/module/atoms/Button.js.map +1 -1
  90. package/lib/module/atoms/Checkbox.js +4 -1
  91. package/lib/module/atoms/Checkbox.js.map +1 -1
  92. package/lib/module/atoms/Fab.js +1 -1
  93. package/lib/module/atoms/Fab.js.map +1 -1
  94. package/lib/module/atoms/Image.js +2 -2
  95. package/lib/module/atoms/Image.js.map +1 -1
  96. package/lib/module/atoms/Input.js +2 -2
  97. package/lib/module/atoms/Input.js.map +1 -1
  98. package/lib/module/atoms/PressableScale.js +9 -10
  99. package/lib/module/atoms/PressableScale.js.map +1 -1
  100. package/lib/module/atoms/Skeleton.js +91 -0
  101. package/lib/module/atoms/Skeleton.js.map +1 -0
  102. package/lib/module/atoms/Transition.js +165 -0
  103. package/lib/module/atoms/Transition.js.map +1 -0
  104. package/lib/module/atoms/index.js +2 -0
  105. package/lib/module/atoms/index.js.map +1 -1
  106. package/lib/module/molecules/AvatarHeader.js +2 -2
  107. package/lib/module/molecules/AvatarHeader.js.map +1 -1
  108. package/lib/module/molecules/OptionCard.js +58 -25
  109. package/lib/module/molecules/OptionCard.js.map +1 -1
  110. package/lib/module/molecules/{ThemeToggle.js → SegmentedControl.js} +37 -37
  111. package/lib/module/molecules/SegmentedControl.js.map +1 -0
  112. package/lib/module/molecules/Stepper.js +125 -49
  113. package/lib/module/molecules/Stepper.js.map +1 -1
  114. package/lib/module/molecules/Steps.js +2 -2
  115. package/lib/module/molecules/Steps.js.map +1 -1
  116. package/lib/module/molecules/index.js +2 -2
  117. package/lib/module/molecules/index.js.map +1 -1
  118. package/lib/module/organisms/Alert.js +6 -5
  119. package/lib/module/organisms/Alert.js.map +1 -1
  120. package/lib/module/organisms/AlertProvider.js +24 -1
  121. package/lib/module/organisms/AlertProvider.js.map +1 -1
  122. package/lib/module/organisms/BottomSheet.js +120 -41
  123. package/lib/module/organisms/BottomSheet.js.map +1 -1
  124. package/lib/module/organisms/DateField.js +2 -2
  125. package/lib/module/organisms/DateField.js.map +1 -1
  126. package/lib/module/organisms/DatePicker.js +3 -3
  127. package/lib/module/organisms/DatePicker.js.map +1 -1
  128. package/lib/module/organisms/List.js +68 -46
  129. package/lib/module/organisms/List.js.map +1 -1
  130. package/lib/module/organisms/Modal.js +44 -89
  131. package/lib/module/organisms/Modal.js.map +1 -1
  132. package/lib/module/organisms/NavigationBar.js +200 -0
  133. package/lib/module/organisms/NavigationBar.js.map +1 -0
  134. package/lib/module/organisms/Screen.js +9 -2
  135. package/lib/module/organisms/Screen.js.map +1 -1
  136. package/lib/module/organisms/SearchList.js +100 -31
  137. package/lib/module/organisms/SearchList.js.map +1 -1
  138. package/lib/module/organisms/Select.js +228 -56
  139. package/lib/module/organisms/Select.js.map +1 -1
  140. package/lib/module/organisms/ThemeToggle.js +47 -0
  141. package/lib/module/organisms/ThemeToggle.js.map +1 -0
  142. package/lib/module/organisms/Toast.js +22 -27
  143. package/lib/module/organisms/Toast.js.map +1 -1
  144. package/lib/module/organisms/ToastProvider.js +21 -0
  145. package/lib/module/organisms/ToastProvider.js.map +1 -1
  146. package/lib/module/organisms/Wizard.js +23 -3
  147. package/lib/module/organisms/Wizard.js.map +1 -1
  148. package/lib/module/organisms/imperativeBridge.js +24 -0
  149. package/lib/module/organisms/imperativeBridge.js.map +1 -0
  150. package/lib/module/organisms/index.js +4 -2
  151. package/lib/module/organisms/index.js.map +1 -1
  152. package/lib/module/safe-area/SafeAreaUIProvider.js +60 -0
  153. package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -0
  154. package/lib/module/safe-area/index.js +4 -0
  155. package/lib/module/safe-area/index.js.map +1 -0
  156. package/lib/module/theme/UIProvider.js +11 -0
  157. package/lib/module/theme/UIProvider.js.map +1 -1
  158. package/lib/module/theme/colors.js +194 -0
  159. package/lib/module/theme/colors.js.map +1 -0
  160. package/lib/module/theme/createTheme.js +25 -2
  161. package/lib/module/theme/createTheme.js.map +1 -1
  162. package/lib/module/theme/index.js +1 -0
  163. package/lib/module/theme/index.js.map +1 -1
  164. package/lib/module/theme/palettes.js +73 -11
  165. package/lib/module/theme/palettes.js.map +1 -1
  166. package/lib/typescript/atoms/Badge.d.ts.map +1 -1
  167. package/lib/typescript/atoms/Button.d.ts.map +1 -1
  168. package/lib/typescript/atoms/Checkbox.d.ts.map +1 -1
  169. package/lib/typescript/atoms/PressableScale.d.ts +3 -2
  170. package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
  171. package/lib/typescript/atoms/Skeleton.d.ts +29 -0
  172. package/lib/typescript/atoms/Skeleton.d.ts.map +1 -0
  173. package/lib/typescript/atoms/Transition.d.ts +51 -0
  174. package/lib/typescript/atoms/Transition.d.ts.map +1 -0
  175. package/lib/typescript/atoms/index.d.ts +4 -0
  176. package/lib/typescript/atoms/index.d.ts.map +1 -1
  177. package/lib/typescript/molecules/OptionCard.d.ts +11 -6
  178. package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
  179. package/lib/typescript/molecules/SegmentedControl.d.ts +24 -0
  180. package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -0
  181. package/lib/typescript/molecules/Stepper.d.ts +35 -3
  182. package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
  183. package/lib/typescript/molecules/index.d.ts +4 -4
  184. package/lib/typescript/molecules/index.d.ts.map +1 -1
  185. package/lib/typescript/organisms/Alert.d.ts.map +1 -1
  186. package/lib/typescript/organisms/AlertProvider.d.ts +9 -0
  187. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  188. package/lib/typescript/organisms/BottomSheet.d.ts +26 -4
  189. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  190. package/lib/typescript/organisms/List.d.ts +9 -2
  191. package/lib/typescript/organisms/List.d.ts.map +1 -1
  192. package/lib/typescript/organisms/Modal.d.ts +3 -5
  193. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  194. package/lib/typescript/organisms/NavigationBar.d.ts +32 -0
  195. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -0
  196. package/lib/typescript/organisms/Screen.d.ts +2 -1
  197. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  198. package/lib/typescript/organisms/SearchList.d.ts +21 -5
  199. package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
  200. package/lib/typescript/organisms/Select.d.ts +19 -4
  201. package/lib/typescript/organisms/Select.d.ts.map +1 -1
  202. package/lib/typescript/{molecules → organisms}/ThemeToggle.d.ts +4 -4
  203. package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -0
  204. package/lib/typescript/organisms/Toast.d.ts.map +1 -1
  205. package/lib/typescript/organisms/ToastProvider.d.ts +8 -0
  206. package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
  207. package/lib/typescript/organisms/Wizard.d.ts +7 -1
  208. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  209. package/lib/typescript/organisms/imperativeBridge.d.ts +12 -0
  210. package/lib/typescript/organisms/imperativeBridge.d.ts.map +1 -0
  211. package/lib/typescript/organisms/index.d.ts +7 -3
  212. package/lib/typescript/organisms/index.d.ts.map +1 -1
  213. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +46 -0
  214. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -0
  215. package/lib/typescript/safe-area/index.d.ts +3 -0
  216. package/lib/typescript/safe-area/index.d.ts.map +1 -0
  217. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  218. package/lib/typescript/theme/colors.d.ts +44 -0
  219. package/lib/typescript/theme/colors.d.ts.map +1 -0
  220. package/lib/typescript/theme/createTheme.d.ts +3 -0
  221. package/lib/typescript/theme/createTheme.d.ts.map +1 -1
  222. package/lib/typescript/theme/index.d.ts +3 -1
  223. package/lib/typescript/theme/index.d.ts.map +1 -1
  224. package/lib/typescript/theme/palettes.d.ts +41 -3
  225. package/lib/typescript/theme/palettes.d.ts.map +1 -1
  226. package/lib/typescript/theme/types.d.ts +9 -0
  227. package/lib/typescript/theme/types.d.ts.map +1 -1
  228. package/package.json +101 -18
  229. package/registry/alert-provider.json +9 -2
  230. package/registry/alert.json +1 -1
  231. package/registry/avatar-header.json +1 -1
  232. package/registry/avatar.json +1 -1
  233. package/registry/badge.json +1 -1
  234. package/registry/bottom-sheet.json +6 -2
  235. package/registry/button.json +1 -1
  236. package/registry/checkbox.json +1 -1
  237. package/registry/core.json +9 -5
  238. package/registry/date-field.json +2 -1
  239. package/registry/date-picker.json +1 -1
  240. package/registry/fab.json +1 -1
  241. package/registry/image.json +1 -1
  242. package/registry/index.json +108 -39
  243. package/registry/input.json +1 -1
  244. package/registry/list.json +2 -1
  245. package/registry/manifest.json +44 -6
  246. package/registry/modal.json +3 -2
  247. package/registry/navigation-bar.json +18 -0
  248. package/registry/option-card.json +3 -2
  249. package/registry/pressable-scale.json +1 -1
  250. package/registry/screen.json +1 -1
  251. package/registry/search-list.json +2 -1
  252. package/registry/segmented-control.json +18 -0
  253. package/registry/select.json +1 -1
  254. package/registry/skeleton.json +18 -0
  255. package/registry/stepper.json +3 -2
  256. package/registry/steps.json +1 -1
  257. package/registry/theme-toggle.json +5 -4
  258. package/registry/toast-provider.json +11 -3
  259. package/registry/toast.json +3 -2
  260. package/registry/transition.json +19 -0
  261. package/registry/wizard.json +3 -2
  262. package/src/atoms/Avatar.tsx +1 -1
  263. package/src/atoms/Badge.tsx +6 -4
  264. package/src/atoms/Button.tsx +8 -6
  265. package/src/atoms/Checkbox.tsx +4 -1
  266. package/src/atoms/Fab.tsx +1 -1
  267. package/src/atoms/Image.tsx +2 -2
  268. package/src/atoms/Input.tsx +2 -2
  269. package/src/atoms/PressableScale.tsx +16 -6
  270. package/src/atoms/Skeleton.tsx +114 -0
  271. package/src/atoms/Transition.tsx +189 -0
  272. package/src/atoms/index.ts +4 -0
  273. package/src/molecules/AvatarHeader.tsx +2 -2
  274. package/src/molecules/OptionCard.tsx +64 -27
  275. package/src/molecules/SegmentedControl.tsx +84 -0
  276. package/src/molecules/Stepper.tsx +147 -32
  277. package/src/molecules/Steps.tsx +2 -2
  278. package/src/molecules/index.ts +4 -4
  279. package/src/organisms/Alert.tsx +6 -5
  280. package/src/organisms/AlertProvider.tsx +26 -1
  281. package/src/organisms/BottomSheet.tsx +214 -62
  282. package/src/organisms/DateField.tsx +2 -2
  283. package/src/organisms/DatePicker.tsx +3 -3
  284. package/src/organisms/List.tsx +73 -41
  285. package/src/organisms/Modal.tsx +49 -61
  286. package/src/organisms/NavigationBar.tsx +223 -0
  287. package/src/organisms/Screen.tsx +11 -3
  288. package/src/organisms/SearchList.tsx +134 -38
  289. package/src/organisms/Select.tsx +227 -49
  290. package/src/organisms/ThemeToggle.tsx +30 -0
  291. package/src/organisms/Toast.tsx +19 -24
  292. package/src/organisms/ToastProvider.tsx +24 -0
  293. package/src/organisms/Wizard.tsx +36 -3
  294. package/src/organisms/imperativeBridge.ts +28 -0
  295. package/src/organisms/index.ts +7 -3
  296. package/src/safe-area/SafeAreaUIProvider.tsx +67 -0
  297. package/src/safe-area/index.ts +2 -0
  298. package/src/theme/UIProvider.tsx +15 -0
  299. package/src/theme/colors.ts +175 -0
  300. package/src/theme/createTheme.ts +38 -4
  301. package/src/theme/index.ts +3 -1
  302. package/src/theme/palettes.ts +108 -11
  303. package/src/theme/types.ts +10 -0
  304. package/lib/commonjs/molecules/ThemeToggle.js.map +0 -1
  305. package/lib/module/molecules/ThemeToggle.js.map +0 -1
  306. package/lib/typescript/molecules/ThemeToggle.d.ts.map +0 -1
  307. package/src/molecules/ThemeToggle.tsx +0 -67
@@ -1,9 +1,19 @@
1
- import React, { memo } from 'react';
2
- import { ActivityIndicator, FlatList, Text, View, type FlatListProps, type StyleProp, type ViewStyle } from 'react-native';
1
+ import React, { useCallback } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ FlatList,
5
+ RefreshControl,
6
+ Text,
7
+ View,
8
+ type FlatListProps,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from 'react-native';
3
12
  import { createStyles } from '../theme/createStyles';
4
13
  import { useColors, useLabels } from '../theme/UIProvider';
5
14
  import { Input } from '../atoms/Input';
6
15
  import { EmptyState } from '../atoms/EmptyState';
16
+ import { Skeleton } from '../atoms/Skeleton';
7
17
  import { Spinner } from '../atoms/Spinner';
8
18
  import { IconButton } from '../atoms/IconButton';
9
19
  import type { IconName } from '../icons/types';
@@ -21,31 +31,50 @@ export interface SearchListProps<T> {
21
31
  extraActions?: React.ReactNode;
22
32
 
23
33
  data: T[];
24
- keyExtractor: (item: T) => string;
34
+ keyExtractor: (item: T, index: number) => string;
25
35
  renderItem: FlatListProps<T>['renderItem'];
26
36
 
37
+ /** Carga inicial: mientras sea true y no haya datos, se muestran placeholders. */
27
38
  isLoading?: boolean;
28
- /** 'replace' cambia toda la lista por un spinner; 'overlay' lo muestra encima manteniendo el layout. */
39
+ /** 'replace' cambia toda la lista por un spinner/skeleton; 'overlay' lo muestra encima manteniendo el layout. */
29
40
  loadingMode?: 'replace' | 'overlay';
30
- loadingText?: string;
31
-
32
- onLoadMore?: () => void;
41
+ /** Recarga con datos ya en pantalla (pull-to-refresh). @default false */
42
+ isRefreshing?: boolean;
33
43
  isLoadingMore?: boolean;
44
+ /** Controlado por el consumidor: mientras es false, se muestra un spinner de pantalla completa. @default true */
45
+ isReady?: boolean;
46
+ loadingText?: string;
34
47
  loadingMoreText?: string;
35
48
  hasMore?: boolean;
36
49
  noMoreText?: string;
37
50
 
51
+ /** Filas fantasma de la carga inicial. @default 6 */
52
+ skeletonCount?: number;
53
+ /** Placeholder propio, para que calce con la forma real del ítem. */
54
+ renderSkeletonItem?: () => React.ReactElement;
55
+
56
+ onLoadMore?: () => void;
57
+ onEndReached?: () => void;
58
+ onEndReachedThreshold?: number;
59
+ onRefresh?: () => void;
60
+
38
61
  emptyTitle?: string;
39
62
  emptyDescription?: string;
40
63
  emptyIconName?: IconName;
41
64
 
65
+ ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];
66
+ ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];
67
+
42
68
  contentContainerStyle?: StyleProp<ViewStyle>;
69
+ containerStyle?: ViewStyle;
70
+
71
+ /** Componente de lista a usar (FlashList, etc). @default FlatList */
43
72
  ListComponent?: React.ComponentType<any>;
44
73
  listProps?: Record<string, unknown>;
45
74
  }
46
75
 
47
76
  const useStyles = createStyles((theme) => ({
48
- container: { flex: 1 },
77
+ container: { flex: 1, backgroundColor: theme.colors.background },
49
78
  searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },
50
79
  searchInput: { flex: 1, marginBottom: 0 },
51
80
  scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },
@@ -57,13 +86,20 @@ const useStyles = createStyles((theme) => ({
57
86
  left: 0,
58
87
  right: 0,
59
88
  bottom: 0,
60
- backgroundColor: theme.colors.surface + 'B3',
89
+ backgroundColor: theme.colors.scrim,
61
90
  zIndex: 10,
62
91
  justifyContent: 'center',
63
92
  alignItems: 'center',
64
93
  },
65
94
  footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },
66
95
  footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },
96
+ skeletonRow: {
97
+ flexDirection: 'row',
98
+ alignItems: 'center',
99
+ gap: theme.tokens.spacing.md,
100
+ padding: theme.tokens.spacing.md,
101
+ },
102
+ skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },
67
103
  }));
68
104
 
69
105
  /**
@@ -86,16 +122,26 @@ export function SearchList<T>({
86
122
  renderItem,
87
123
  isLoading = false,
88
124
  loadingMode = 'replace',
89
- loadingText,
90
- onLoadMore,
125
+ isRefreshing = false,
91
126
  isLoadingMore = false,
127
+ isReady = true,
128
+ loadingText,
92
129
  loadingMoreText,
93
130
  hasMore = false,
94
131
  noMoreText,
132
+ skeletonCount = 6,
133
+ renderSkeletonItem,
134
+ onLoadMore,
135
+ onEndReached,
136
+ onEndReachedThreshold = 0.3,
137
+ onRefresh,
95
138
  emptyTitle,
96
139
  emptyDescription,
97
140
  emptyIconName,
141
+ ListHeaderComponent,
142
+ ListFooterComponent,
98
143
  contentContainerStyle,
144
+ containerStyle,
99
145
  ListComponent = FlatList,
100
146
  listProps,
101
147
  }: SearchListProps<T>) {
@@ -106,30 +152,55 @@ export function SearchList<T>({
106
152
  const resolvedLoadingText = loadingText ?? labels.loading;
107
153
  const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;
108
154
 
109
- const renderFooter = () => {
110
- if (isLoadingMore) {
111
- return (
112
- <View style={styles.footer}>
113
- <Spinner text={resolvedLoadingMoreText} fullscreen={false} />
114
- </View>
115
- );
116
- }
117
- if (noMoreText && !hasMore && data.length > 0) {
118
- return (
119
- <View style={styles.footer}>
120
- <Text style={styles.footerText}>{noMoreText}</Text>
121
- </View>
155
+ const handleEndReached = onLoadMore ?? onEndReached;
156
+
157
+ // El footer propio y el loader de paginación conviven: antes el primero
158
+ // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.
159
+ const renderFooter = useCallback(() => {
160
+ const custom =
161
+ typeof ListFooterComponent === 'function' ? (
162
+ <ListFooterComponent />
163
+ ) : (
164
+ (ListFooterComponent as React.ReactElement | undefined) ?? null
122
165
  );
123
- }
124
- return null;
125
- };
126
166
 
127
- const showReplaceLoading = isLoading && loadingMode === 'replace' && data.length === 0;
167
+ return (
168
+ <>
169
+ {custom}
170
+ {isLoadingMore && (
171
+ <View style={styles.footer}>
172
+ <Spinner text={resolvedLoadingMoreText} fullscreen={false} />
173
+ </View>
174
+ )}
175
+ {noMoreText && !hasMore && data.length > 0 && (
176
+ <View style={styles.footer}>
177
+ <Text style={styles.footerText}>{noMoreText}</Text>
178
+ </View>
179
+ )}
180
+ </>
181
+ );
182
+ }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);
183
+
184
+ // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se
185
+ // muestran placeholders, no "sin resultados".
186
+ const renderEmpty = useCallback(() => {
187
+ if (isLoading || isRefreshing) return null;
188
+ return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;
189
+ }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);
190
+
191
+ if (!isReady) {
192
+ return (
193
+ <View style={[styles.container, containerStyle]}>
194
+ <Spinner text={resolvedLoadingText} />
195
+ </View>
196
+ );
197
+ }
198
+
199
+ const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;
128
200
  const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;
129
- const showEmpty = !isLoading && data.length === 0;
130
201
 
131
202
  return (
132
- <View style={styles.container}>
203
+ <View style={[styles.container, containerStyle]}>
133
204
  {header}
134
205
 
135
206
  <View style={styles.searchRow}>
@@ -147,7 +218,7 @@ export function SearchList<T>({
147
218
  <IconButton
148
219
  iconName="search"
149
220
  onPress={onScanPress}
150
- color={colors.white}
221
+ color={colors.onPrimary}
151
222
  style={styles.scanButton}
152
223
  accessibilityLabel={scanAccessibilityLabel}
153
224
  />
@@ -163,20 +234,44 @@ export function SearchList<T>({
163
234
  </View>
164
235
  )}
165
236
 
166
- {showReplaceLoading ? (
167
- <Spinner text={resolvedLoadingText} />
168
- ) : showEmpty ? (
169
- <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} />
237
+ {isInitialLoading ? (
238
+ <View testID="list-skeleton" style={[styles.listContent, contentContainerStyle]}>
239
+ {Array.from({ length: skeletonCount }, (_, i) => (
240
+ <React.Fragment key={i}>
241
+ {renderSkeletonItem?.() ?? (
242
+ <View style={styles.skeletonRow}>
243
+ <Skeleton variant="circle" width={40} />
244
+ <View style={styles.skeletonText}>
245
+ <Skeleton width="60%" height={14} />
246
+ <Skeleton width="35%" height={12} />
247
+ </View>
248
+ </View>
249
+ )}
250
+ </React.Fragment>
251
+ ))}
252
+ </View>
170
253
  ) : (
171
254
  <ListComponent
172
255
  data={data}
173
256
  keyExtractor={keyExtractor}
174
257
  keyboardDismissMode="on-drag"
175
- contentContainerStyle={contentContainerStyle ?? styles.listContent}
176
- onEndReached={onLoadMore}
177
- onEndReachedThreshold={0.2}
258
+ contentContainerStyle={[styles.listContent, contentContainerStyle]}
259
+ onEndReached={handleEndReached}
260
+ onEndReachedThreshold={onEndReachedThreshold}
261
+ ListHeaderComponent={ListHeaderComponent}
178
262
  ListFooterComponent={renderFooter}
263
+ ListEmptyComponent={renderEmpty}
179
264
  renderItem={renderItem}
265
+ refreshControl={
266
+ onRefresh ? (
267
+ <RefreshControl
268
+ refreshing={isRefreshing}
269
+ onRefresh={onRefresh}
270
+ colors={[colors.primary]}
271
+ tintColor={colors.primary}
272
+ />
273
+ ) : undefined
274
+ }
180
275
  {...listProps}
181
276
  />
182
277
  )}
@@ -184,3 +279,4 @@ export function SearchList<T>({
184
279
  </View>
185
280
  );
186
281
  }
282
+
@@ -1,12 +1,31 @@
1
- import React, { memo, useState } from 'react';
2
- import { ActivityIndicator, Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Keyboard,
5
+ Modal,
6
+ Platform,
7
+ Pressable,
8
+ ScrollView,
9
+ Text,
10
+ TextInput,
11
+ View,
12
+ useWindowDimensions,
13
+ type StyleProp,
14
+ type ViewStyle,
15
+ } from 'react-native';
3
16
  import { createStyles } from '../theme/createStyles';
4
17
  import { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';
5
18
  import { Icon } from '../icons/Icon';
19
+ import type { IconName } from '../icons/types';
6
20
 
7
21
  export interface SelectOption<T> {
8
22
  label: string;
9
23
  value: T;
24
+ /** Glifo del set de la librería, a la izquierda del label. */
25
+ iconName?: IconName;
26
+ /** Emoji a la izquierda del label. Gana sobre `iconName`. */
27
+ emoji?: string;
28
+ disabled?: boolean;
10
29
  }
11
30
 
12
31
  export interface SelectProps<T> {
@@ -18,14 +37,26 @@ export interface SelectProps<T> {
18
37
  placeholder?: string;
19
38
  error?: string;
20
39
  isLoading?: boolean;
40
+ disabled?: boolean;
41
+ /** Agrega un buscador dentro del desplegable. @default false */
42
+ searchable?: boolean;
43
+ searchPlaceholder?: string;
44
+ /** Alto máximo del desplegable, si el espacio disponible lo permite. @default 280 */
45
+ maxHeight?: number;
46
+ onOpenChange?: (open: boolean) => void;
21
47
  containerStyle?: StyleProp<ViewStyle>;
48
+ testID?: string;
22
49
  }
23
50
 
51
+ /** Aire entre el campo y el desplegable. */
52
+ const GAP = 8;
53
+ /** Por debajo de esto el desplegable no muestra ni dos filas. */
54
+ const MIN_HEIGHT = 120;
55
+
24
56
  const useStyles = createStyles((theme) => ({
25
57
  container: { gap: theme.tokens.spacing.sm },
26
- containerOpen: { zIndex: 20, elevation: 20 },
27
58
  label: { fontWeight: theme.tokens.fontWeight.bold, fontSize: theme.tokens.fontSize.md, color: theme.colors.text },
28
- required: { color: theme.colors.error },
59
+ required: { color: theme.colors.errorText },
29
60
  field: {
30
61
  flexDirection: 'row',
31
62
  alignItems: 'center',
@@ -37,23 +68,29 @@ const useStyles = createStyles((theme) => ({
37
68
  minHeight: 50,
38
69
  paddingHorizontal: theme.tokens.spacing.lg,
39
70
  },
40
- fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },
71
+ fieldOpen: { borderColor: theme.colors.primary },
72
+ fieldDisabled: { opacity: 0.5 },
73
+ fieldValue: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.sm, flexShrink: 1 },
74
+ fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text, flexShrink: 1 },
41
75
  fieldPlaceholder: { color: theme.colors.textLight },
42
- optionsList: {
76
+ backdrop: { flex: 1 },
77
+ sheet: {
43
78
  position: 'absolute',
44
- top: '100%',
45
- left: 0,
46
- right: 0,
47
- marginTop: theme.tokens.spacing.sm,
48
79
  borderWidth: 2,
49
80
  borderColor: theme.colors.border,
50
81
  borderRadius: theme.tokens.radius.lg,
51
82
  backgroundColor: theme.colors.surface,
52
83
  overflow: 'hidden',
53
- zIndex: 20,
54
- elevation: 20,
55
84
  ...theme.tokens.shadow.md,
56
85
  },
86
+ search: {
87
+ fontSize: theme.tokens.fontSize.md,
88
+ color: theme.colors.text,
89
+ paddingHorizontal: theme.tokens.spacing.lg,
90
+ paddingVertical: theme.tokens.spacing.md,
91
+ borderBottomWidth: 1,
92
+ borderBottomColor: theme.colors.border,
93
+ },
57
94
  optionRow: {
58
95
  flexDirection: 'row',
59
96
  alignItems: 'center',
@@ -64,17 +101,34 @@ const useStyles = createStyles((theme) => ({
64
101
  borderBottomColor: theme.colors.border,
65
102
  },
66
103
  optionRowLast: { borderBottomWidth: 0 },
67
- optionRowSelected: { backgroundColor: theme.colors.primary + '15' },
104
+ optionRowSelected: { backgroundColor: theme.colors.primarySoft },
105
+ optionRowDisabled: { opacity: 0.4 },
106
+ optionLabel: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.sm, flexShrink: 1 },
68
107
  optionText: { fontSize: theme.tokens.fontSize.lg - 1, color: theme.colors.text },
69
- optionTextSelected: { fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.primary },
70
- errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.error },
108
+ optionTextSelected: { fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.primaryText },
109
+ emoji: { fontSize: theme.tokens.fontSize.lg },
110
+ empty: {
111
+ padding: theme.tokens.spacing.lg,
112
+ textAlign: 'center',
113
+ color: theme.colors.textLight,
114
+ fontSize: theme.tokens.fontSize.md,
115
+ },
116
+ errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },
71
117
  }));
72
118
 
119
+ interface Anchor {
120
+ x: number;
121
+ y: number;
122
+ width: number;
123
+ height: number;
124
+ }
125
+
73
126
  /**
74
127
  * Select
75
- * Dropdown que expande la lista de opciones flotando sobre el resto del
76
- * contenido (position: absolute), en vez de empujarlo hacia abajo o abrir
77
- * un picker nativo del sistema.
128
+ * Dropdown de una sola opción. La lista se mide contra la ventana y se dibuja
129
+ * dentro de un Modal, no en el árbol del campo: así ningún `overflow: hidden`
130
+ * la recorta, no depende del zIndex (poco confiable en Android) y el fondo del
131
+ * Modal resuelve cerrar tocando afuera y con el botón atrás.
78
132
  */
79
133
  export function Select<T extends string | number>({
80
134
  label,
@@ -85,17 +139,93 @@ export function Select<T extends string | number>({
85
139
  placeholder,
86
140
  error,
87
141
  isLoading = false,
142
+ disabled = false,
143
+ searchable = false,
144
+ searchPlaceholder,
145
+ maxHeight = 280,
146
+ onOpenChange,
88
147
  containerStyle,
148
+ testID,
89
149
  }: SelectProps<T>) {
90
150
  const colors = useColors();
91
151
  const styles = useStyles();
92
152
  const labels = useLabels();
93
153
  const allowFontScaling = useAllowFontScaling();
154
+ const { height: windowHeight } = useWindowDimensions();
155
+
156
+ const fieldRef = useRef<View>(null);
94
157
  const [open, setOpen] = useState(false);
158
+ const [anchor, setAnchor] = useState<Anchor | null>(null);
159
+ const [query, setQuery] = useState('');
160
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
161
+ const isDisabled = disabled || isLoading;
95
162
  const selectedOption = options.find((o) => o.value === selectedValue);
96
163
 
164
+ const close = useCallback(() => {
165
+ setOpen(false);
166
+ setAnchor(null);
167
+ setQuery('');
168
+ onOpenChange?.(false);
169
+ }, [onOpenChange]);
170
+
171
+ // Abrir no espera a la medición: si `measureInWindow` no responde, la lista
172
+ // igual aparece (centrada) en vez de que el campo deje de funcionar.
173
+ const openList = () => {
174
+ fieldRef.current?.measureInWindow((x, y, width, height) => setAnchor({ x, y, width, height }));
175
+ setOpen(true);
176
+ onOpenChange?.(true);
177
+ };
178
+
179
+ // El buscador abre el teclado encima del desplegable: sin descontarlo, la
180
+ // lista se dibuja donde ya no se ve.
181
+ useEffect(() => {
182
+ if (!open) return;
183
+ setKeyboardHeight(Keyboard.metrics()?.height ?? 0);
184
+ const [show, hide] =
185
+ Platform.OS === 'ios' ? ['keyboardWillShow', 'keyboardWillHide'] : ['keyboardDidShow', 'keyboardDidHide'];
186
+ const subs = [
187
+ Keyboard.addListener(show as 'keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)),
188
+ Keyboard.addListener(hide as 'keyboardDidHide', () => setKeyboardHeight(0)),
189
+ ];
190
+ return () => subs.forEach((sub) => sub.remove());
191
+ }, [open]);
192
+
193
+ const visibleOptions = useMemo(() => {
194
+ if (!searchable || query.trim() === '') return options;
195
+ const needle = query.trim().toLowerCase();
196
+ return options.filter((option) => option.label.toLowerCase().includes(needle));
197
+ }, [options, query, searchable]);
198
+
199
+ // Se abre hacia donde haya más aire: contra el borde inferior de la pantalla,
200
+ // hacia arriba es la única forma de que entre.
201
+ const placement = useMemo((): ViewStyle => {
202
+ if (!anchor) return { left: GAP * 2, right: GAP * 2, top: windowHeight * 0.25, maxHeight };
203
+ const usableHeight = windowHeight - keyboardHeight;
204
+ const below = usableHeight - (anchor.y + anchor.height) - GAP * 2;
205
+ const above = anchor.y - GAP * 2;
206
+ const downwards = below >= Math.min(maxHeight, above);
207
+ return {
208
+ left: anchor.x,
209
+ width: anchor.width,
210
+ maxHeight: Math.max(Math.min(maxHeight, downwards ? below : above), MIN_HEIGHT),
211
+ ...(downwards ? { top: anchor.y + anchor.height + GAP } : { bottom: windowHeight - anchor.y + GAP }),
212
+ };
213
+ }, [anchor, keyboardHeight, maxHeight, windowHeight]);
214
+
215
+ const renderLeading = (option: SelectOption<T> | undefined, color: string) => {
216
+ if (!option) return null;
217
+ if (option.emoji) {
218
+ return (
219
+ <Text allowFontScaling={allowFontScaling} style={styles.emoji}>
220
+ {option.emoji}
221
+ </Text>
222
+ );
223
+ }
224
+ return option.iconName ? <Icon name={option.iconName} size={20} color={color} /> : null;
225
+ };
226
+
97
227
  return (
98
- <View style={[styles.container, open && styles.containerOpen, containerStyle]}>
228
+ <View style={[styles.container, containerStyle]} testID={testID}>
99
229
  {label && (
100
230
  <Text allowFontScaling={allowFontScaling} style={styles.label}>
101
231
  {label}
@@ -104,16 +234,25 @@ export function Select<T extends string | number>({
104
234
  )}
105
235
 
106
236
  <Pressable
107
- style={styles.field}
108
- onPress={() => !isLoading && setOpen((o) => !o)}
109
- disabled={isLoading}
110
- accessibilityRole="button"
237
+ ref={fieldRef}
238
+ style={[styles.field, open && styles.fieldOpen, isDisabled && styles.fieldDisabled]}
239
+ onPress={openList}
240
+ disabled={isDisabled}
241
+ accessibilityRole="combobox"
111
242
  accessibilityLabel={label ?? placeholder ?? labels.selectPlaceholder}
112
- accessibilityState={{ expanded: open, disabled: isLoading }}
243
+ accessibilityValue={{ text: selectedOption?.label ?? '' }}
244
+ accessibilityState={{ expanded: open, disabled: isDisabled }}
113
245
  >
114
- <Text allowFontScaling={allowFontScaling} style={[styles.fieldText, !selectedOption && styles.fieldPlaceholder]}>
115
- {selectedOption?.label || placeholder || labels.selectPlaceholder}
116
- </Text>
246
+ <View style={styles.fieldValue}>
247
+ {renderLeading(selectedOption, colors.textLight)}
248
+ <Text
249
+ allowFontScaling={allowFontScaling}
250
+ numberOfLines={1}
251
+ style={[styles.fieldText, !selectedOption && styles.fieldPlaceholder]}
252
+ >
253
+ {selectedOption?.label || placeholder || labels.selectPlaceholder}
254
+ </Text>
255
+ </View>
117
256
  {isLoading ? (
118
257
  <ActivityIndicator size="small" color={colors.primary} />
119
258
  ) : (
@@ -121,29 +260,68 @@ export function Select<T extends string | number>({
121
260
  )}
122
261
  </Pressable>
123
262
 
124
- {open && !isLoading && (
125
- <View style={styles.optionsList}>
126
- {options.map((option, index) => {
127
- const isSelected = option.value === selectedValue;
128
- return (
129
- <Pressable
130
- key={String(option.value)}
131
- style={[styles.optionRow, index === options.length - 1 && styles.optionRowLast, isSelected && styles.optionRowSelected]}
132
- onPress={() => {
133
- onSelect(option.value);
134
- setOpen(false);
135
- }}
136
- accessibilityRole="menuitem"
137
- accessibilityState={{ selected: isSelected }}
138
- >
139
- <Text allowFontScaling={allowFontScaling} style={[styles.optionText, isSelected && styles.optionTextSelected]}>
140
- {option.label}
263
+ {open && (
264
+ <Modal visible transparent animationType="fade" statusBarTranslucent onRequestClose={close}>
265
+ <Pressable style={styles.backdrop} onPress={close} testID="select-backdrop" />
266
+
267
+ <View style={[styles.sheet, placement]} accessibilityRole="radiogroup" testID="select-list">
268
+ {searchable && (
269
+ <TextInput
270
+ allowFontScaling={allowFontScaling}
271
+ style={styles.search}
272
+ value={query}
273
+ onChangeText={setQuery}
274
+ placeholder={searchPlaceholder ?? labels.search}
275
+ placeholderTextColor={colors.textLight}
276
+ autoFocus
277
+ accessibilityLabel={searchPlaceholder ?? labels.search}
278
+ />
279
+ )}
280
+
281
+ <ScrollView keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} bounces={false}>
282
+ {visibleOptions.map((option, index) => {
283
+ const isSelected = option.value === selectedValue;
284
+ return (
285
+ <Pressable
286
+ key={String(option.value)}
287
+ style={[
288
+ styles.optionRow,
289
+ index === visibleOptions.length - 1 && styles.optionRowLast,
290
+ isSelected && styles.optionRowSelected,
291
+ option.disabled && styles.optionRowDisabled,
292
+ ]}
293
+ onPress={() => {
294
+ onSelect(option.value);
295
+ close();
296
+ }}
297
+ disabled={option.disabled}
298
+ accessibilityRole="radio"
299
+ accessibilityLabel={option.label}
300
+ accessibilityState={{ selected: isSelected, checked: isSelected, disabled: !!option.disabled }}
301
+ >
302
+ <View style={styles.optionLabel}>
303
+ {renderLeading(option, isSelected ? colors.primary : colors.textLight)}
304
+ <Text
305
+ allowFontScaling={allowFontScaling}
306
+ numberOfLines={1}
307
+ style={[styles.optionText, isSelected && styles.optionTextSelected]}
308
+ >
309
+ {option.label}
310
+ </Text>
311
+ </View>
312
+ {isSelected && <Icon name="check" size={18} color={colors.primary} />}
313
+ </Pressable>
314
+ );
315
+ })}
316
+
317
+ {visibleOptions.length === 0 && (
318
+ <Text allowFontScaling={allowFontScaling} style={styles.empty}>
319
+ {labels.noResultsTitle}
141
320
  </Text>
142
- {isSelected && <Icon name="check" size={18} color={colors.primary} />}
143
- </Pressable>
144
- );
145
- })}
146
- </View>
321
+ )}
322
+ </ScrollView>
323
+ </View>
324
+ </Modal>
147
325
  )}
148
326
 
149
327
  {!!error && (
@@ -0,0 +1,30 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useThemeMode, type ThemeMode } from '../theme/UIProvider';
4
+ import { SegmentedControl } from '../molecules/SegmentedControl';
5
+
6
+ export interface ThemeToggleProps {
7
+ style?: StyleProp<ViewStyle>;
8
+ labels?: Partial<Record<ThemeMode, string>>;
9
+ testID?: string;
10
+ }
11
+
12
+ const MODES: ThemeMode[] = ['system', 'light', 'dark'];
13
+ const DEFAULT_LABELS: Record<ThemeMode, string> = { system: 'Auto', light: 'Light', dark: 'Dark' };
14
+
15
+ /**
16
+ * ThemeToggle
17
+ * Conecta un SegmentedControl con UIProvider.setMode. Sin persistencia — el
18
+ * consumidor la resuelve vía onModeChange en <UIProvider>.
19
+ */
20
+ export const ThemeToggle = memo(({ style, labels: labelOverrides, testID }: ThemeToggleProps) => {
21
+ const { mode, setMode } = useThemeMode();
22
+
23
+ const options = useMemo(() => {
24
+ const labels = { ...DEFAULT_LABELS, ...labelOverrides };
25
+ return MODES.map((value) => ({ value, label: labels[value] }));
26
+ }, [labelOverrides]);
27
+
28
+ return <SegmentedControl options={options} value={mode} onChange={setMode} style={style} testID={testID} />;
29
+ });
30
+ ThemeToggle.displayName = 'ThemeToggle';