orn-ui 0.1.1 → 0.2.1

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 (240) hide show
  1. package/README.md +20 -2
  2. package/lib/commonjs/atoms/PressableScale.js +9 -10
  3. package/lib/commonjs/atoms/PressableScale.js.map +1 -1
  4. package/lib/commonjs/atoms/Skeleton.js +96 -0
  5. package/lib/commonjs/atoms/Skeleton.js.map +1 -0
  6. package/lib/commonjs/atoms/Transition.js +171 -0
  7. package/lib/commonjs/atoms/Transition.js.map +1 -0
  8. package/lib/commonjs/atoms/index.js +20 -0
  9. package/lib/commonjs/atoms/index.js.map +1 -1
  10. package/lib/commonjs/molecules/OptionCard.js +55 -22
  11. package/lib/commonjs/molecules/OptionCard.js.map +1 -1
  12. package/lib/commonjs/molecules/{ThemeToggle.js → SegmentedControl.js} +36 -36
  13. package/lib/commonjs/molecules/SegmentedControl.js.map +1 -0
  14. package/lib/commonjs/molecules/Stepper.js +124 -47
  15. package/lib/commonjs/molecules/Stepper.js.map +1 -1
  16. package/lib/commonjs/molecules/Steps.js +36 -6
  17. package/lib/commonjs/molecules/Steps.js.map +1 -1
  18. package/lib/commonjs/molecules/index.js +9 -3
  19. package/lib/commonjs/molecules/index.js.map +1 -1
  20. package/lib/commonjs/organisms/AlertProvider.js +26 -0
  21. package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
  22. package/lib/commonjs/organisms/BottomSheet.js +118 -39
  23. package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
  24. package/lib/commonjs/organisms/DateField.js +46 -15
  25. package/lib/commonjs/organisms/DateField.js.map +1 -1
  26. package/lib/commonjs/organisms/DatePicker.js +129 -50
  27. package/lib/commonjs/organisms/DatePicker.js.map +1 -1
  28. package/lib/commonjs/organisms/List.js +66 -44
  29. package/lib/commonjs/organisms/List.js.map +1 -1
  30. package/lib/commonjs/organisms/Modal.js +42 -87
  31. package/lib/commonjs/organisms/Modal.js.map +1 -1
  32. package/lib/commonjs/organisms/NavigationBar.js +210 -0
  33. package/lib/commonjs/organisms/NavigationBar.js.map +1 -0
  34. package/lib/commonjs/organisms/Screen.js +9 -2
  35. package/lib/commonjs/organisms/Screen.js.map +1 -1
  36. package/lib/commonjs/organisms/SearchList.js +95 -27
  37. package/lib/commonjs/organisms/SearchList.js.map +1 -1
  38. package/lib/commonjs/organisms/Select.js +222 -50
  39. package/lib/commonjs/organisms/Select.js.map +1 -1
  40. package/lib/commonjs/organisms/ThemeToggle.js +52 -0
  41. package/lib/commonjs/organisms/ThemeToggle.js.map +1 -0
  42. package/lib/commonjs/organisms/Toast.js +8 -22
  43. package/lib/commonjs/organisms/Toast.js.map +1 -1
  44. package/lib/commonjs/organisms/ToastProvider.js +24 -0
  45. package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
  46. package/lib/commonjs/organisms/Wizard.js +23 -2
  47. package/lib/commonjs/organisms/Wizard.js.map +1 -1
  48. package/lib/commonjs/organisms/imperativeBridge.js +28 -0
  49. package/lib/commonjs/organisms/imperativeBridge.js.map +1 -0
  50. package/lib/commonjs/organisms/index.js +50 -0
  51. package/lib/commonjs/organisms/index.js.map +1 -1
  52. package/lib/commonjs/theme/colors.js +207 -0
  53. package/lib/commonjs/theme/colors.js.map +1 -0
  54. package/lib/commonjs/theme/createTheme.js +25 -2
  55. package/lib/commonjs/theme/createTheme.js.map +1 -1
  56. package/lib/commonjs/theme/index.js +25 -0
  57. package/lib/commonjs/theme/index.js.map +1 -1
  58. package/lib/commonjs/theme/labels.js +5 -1
  59. package/lib/commonjs/theme/labels.js.map +1 -1
  60. package/lib/commonjs/theme/palettes.js +5 -2
  61. package/lib/commonjs/theme/palettes.js.map +1 -1
  62. package/lib/module/atoms/PressableScale.js +9 -10
  63. package/lib/module/atoms/PressableScale.js.map +1 -1
  64. package/lib/module/atoms/Skeleton.js +91 -0
  65. package/lib/module/atoms/Skeleton.js.map +1 -0
  66. package/lib/module/atoms/Transition.js +165 -0
  67. package/lib/module/atoms/Transition.js.map +1 -0
  68. package/lib/module/atoms/index.js +2 -0
  69. package/lib/module/atoms/index.js.map +1 -1
  70. package/lib/module/molecules/OptionCard.js +56 -23
  71. package/lib/module/molecules/OptionCard.js.map +1 -1
  72. package/lib/module/molecules/{ThemeToggle.js → SegmentedControl.js} +36 -36
  73. package/lib/module/molecules/SegmentedControl.js.map +1 -0
  74. package/lib/module/molecules/Stepper.js +125 -49
  75. package/lib/module/molecules/Stepper.js.map +1 -1
  76. package/lib/module/molecules/Steps.js +37 -7
  77. package/lib/module/molecules/Steps.js.map +1 -1
  78. package/lib/module/molecules/index.js +2 -2
  79. package/lib/module/molecules/index.js.map +1 -1
  80. package/lib/module/organisms/AlertProvider.js +24 -1
  81. package/lib/module/organisms/AlertProvider.js.map +1 -1
  82. package/lib/module/organisms/BottomSheet.js +120 -41
  83. package/lib/module/organisms/BottomSheet.js.map +1 -1
  84. package/lib/module/organisms/DateField.js +49 -18
  85. package/lib/module/organisms/DateField.js.map +1 -1
  86. package/lib/module/organisms/DatePicker.js +131 -52
  87. package/lib/module/organisms/DatePicker.js.map +1 -1
  88. package/lib/module/organisms/List.js +68 -46
  89. package/lib/module/organisms/List.js.map +1 -1
  90. package/lib/module/organisms/Modal.js +44 -89
  91. package/lib/module/organisms/Modal.js.map +1 -1
  92. package/lib/module/organisms/NavigationBar.js +205 -0
  93. package/lib/module/organisms/NavigationBar.js.map +1 -0
  94. package/lib/module/organisms/Screen.js +9 -2
  95. package/lib/module/organisms/Screen.js.map +1 -1
  96. package/lib/module/organisms/SearchList.js +96 -28
  97. package/lib/module/organisms/SearchList.js.map +1 -1
  98. package/lib/module/organisms/Select.js +224 -52
  99. package/lib/module/organisms/Select.js.map +1 -1
  100. package/lib/module/organisms/ThemeToggle.js +47 -0
  101. package/lib/module/organisms/ThemeToggle.js.map +1 -0
  102. package/lib/module/organisms/Toast.js +10 -24
  103. package/lib/module/organisms/Toast.js.map +1 -1
  104. package/lib/module/organisms/ToastProvider.js +21 -0
  105. package/lib/module/organisms/ToastProvider.js.map +1 -1
  106. package/lib/module/organisms/Wizard.js +24 -3
  107. package/lib/module/organisms/Wizard.js.map +1 -1
  108. package/lib/module/organisms/imperativeBridge.js +24 -0
  109. package/lib/module/organisms/imperativeBridge.js.map +1 -0
  110. package/lib/module/organisms/index.js +4 -2
  111. package/lib/module/organisms/index.js.map +1 -1
  112. package/lib/module/theme/colors.js +194 -0
  113. package/lib/module/theme/colors.js.map +1 -0
  114. package/lib/module/theme/createTheme.js +25 -2
  115. package/lib/module/theme/createTheme.js.map +1 -1
  116. package/lib/module/theme/index.js +1 -0
  117. package/lib/module/theme/index.js.map +1 -1
  118. package/lib/module/theme/labels.js +5 -1
  119. package/lib/module/theme/labels.js.map +1 -1
  120. package/lib/module/theme/palettes.js +5 -2
  121. package/lib/module/theme/palettes.js.map +1 -1
  122. package/lib/typescript/atoms/PressableScale.d.ts +3 -2
  123. package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
  124. package/lib/typescript/atoms/Skeleton.d.ts +29 -0
  125. package/lib/typescript/atoms/Skeleton.d.ts.map +1 -0
  126. package/lib/typescript/atoms/Transition.d.ts +51 -0
  127. package/lib/typescript/atoms/Transition.d.ts.map +1 -0
  128. package/lib/typescript/atoms/index.d.ts +4 -0
  129. package/lib/typescript/atoms/index.d.ts.map +1 -1
  130. package/lib/typescript/molecules/OptionCard.d.ts +11 -6
  131. package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
  132. package/lib/typescript/molecules/SegmentedControl.d.ts +24 -0
  133. package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -0
  134. package/lib/typescript/molecules/Stepper.d.ts +35 -3
  135. package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
  136. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  137. package/lib/typescript/molecules/index.d.ts +4 -4
  138. package/lib/typescript/molecules/index.d.ts.map +1 -1
  139. package/lib/typescript/organisms/AlertProvider.d.ts +9 -0
  140. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  141. package/lib/typescript/organisms/BottomSheet.d.ts +26 -4
  142. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  143. package/lib/typescript/organisms/DateField.d.ts +23 -5
  144. package/lib/typescript/organisms/DateField.d.ts.map +1 -1
  145. package/lib/typescript/organisms/DatePicker.d.ts +5 -1
  146. package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
  147. package/lib/typescript/organisms/List.d.ts +9 -2
  148. package/lib/typescript/organisms/List.d.ts.map +1 -1
  149. package/lib/typescript/organisms/Modal.d.ts +3 -5
  150. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  151. package/lib/typescript/organisms/NavigationBar.d.ts +32 -0
  152. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -0
  153. package/lib/typescript/organisms/Screen.d.ts +2 -1
  154. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  155. package/lib/typescript/organisms/SearchList.d.ts +21 -5
  156. package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
  157. package/lib/typescript/organisms/Select.d.ts +19 -4
  158. package/lib/typescript/organisms/Select.d.ts.map +1 -1
  159. package/lib/typescript/{molecules → organisms}/ThemeToggle.d.ts +4 -4
  160. package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -0
  161. package/lib/typescript/organisms/Toast.d.ts.map +1 -1
  162. package/lib/typescript/organisms/ToastProvider.d.ts +8 -0
  163. package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
  164. package/lib/typescript/organisms/Wizard.d.ts +7 -1
  165. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  166. package/lib/typescript/organisms/imperativeBridge.d.ts +12 -0
  167. package/lib/typescript/organisms/imperativeBridge.d.ts.map +1 -0
  168. package/lib/typescript/organisms/index.d.ts +7 -3
  169. package/lib/typescript/organisms/index.d.ts.map +1 -1
  170. package/lib/typescript/theme/colors.d.ts +44 -0
  171. package/lib/typescript/theme/colors.d.ts.map +1 -0
  172. package/lib/typescript/theme/createTheme.d.ts +3 -0
  173. package/lib/typescript/theme/createTheme.d.ts.map +1 -1
  174. package/lib/typescript/theme/index.d.ts +3 -1
  175. package/lib/typescript/theme/index.d.ts.map +1 -1
  176. package/lib/typescript/theme/labels.d.ts +6 -0
  177. package/lib/typescript/theme/labels.d.ts.map +1 -1
  178. package/lib/typescript/theme/palettes.d.ts.map +1 -1
  179. package/lib/typescript/theme/types.d.ts +9 -0
  180. package/lib/typescript/theme/types.d.ts.map +1 -1
  181. package/package.json +67 -6
  182. package/registry/alert-provider.json +9 -2
  183. package/registry/bottom-sheet.json +6 -2
  184. package/registry/core.json +9 -5
  185. package/registry/date-field.json +2 -1
  186. package/registry/date-picker.json +1 -1
  187. package/registry/index.json +99 -30
  188. package/registry/list.json +2 -1
  189. package/registry/manifest.json +44 -6
  190. package/registry/modal.json +3 -2
  191. package/registry/navigation-bar.json +18 -0
  192. package/registry/option-card.json +3 -2
  193. package/registry/pressable-scale.json +1 -1
  194. package/registry/screen.json +1 -1
  195. package/registry/search-list.json +2 -1
  196. package/registry/segmented-control.json +18 -0
  197. package/registry/select.json +1 -1
  198. package/registry/skeleton.json +18 -0
  199. package/registry/stepper.json +3 -2
  200. package/registry/steps.json +1 -1
  201. package/registry/theme-toggle.json +5 -4
  202. package/registry/toast-provider.json +11 -3
  203. package/registry/toast.json +3 -2
  204. package/registry/transition.json +19 -0
  205. package/registry/wizard.json +3 -2
  206. package/src/atoms/PressableScale.tsx +16 -6
  207. package/src/atoms/Skeleton.tsx +114 -0
  208. package/src/atoms/Transition.tsx +189 -0
  209. package/src/atoms/index.ts +4 -0
  210. package/src/molecules/OptionCard.tsx +62 -25
  211. package/src/molecules/SegmentedControl.tsx +84 -0
  212. package/src/molecules/Stepper.tsx +147 -32
  213. package/src/molecules/Steps.tsx +27 -8
  214. package/src/molecules/index.ts +4 -4
  215. package/src/organisms/AlertProvider.tsx +26 -1
  216. package/src/organisms/BottomSheet.tsx +214 -62
  217. package/src/organisms/DateField.tsx +79 -19
  218. package/src/organisms/DatePicker.tsx +198 -80
  219. package/src/organisms/List.tsx +73 -41
  220. package/src/organisms/Modal.tsx +49 -61
  221. package/src/organisms/NavigationBar.tsx +226 -0
  222. package/src/organisms/Screen.tsx +11 -3
  223. package/src/organisms/SearchList.tsx +131 -35
  224. package/src/organisms/Select.tsx +223 -45
  225. package/src/organisms/ThemeToggle.tsx +30 -0
  226. package/src/organisms/Toast.tsx +8 -21
  227. package/src/organisms/ToastProvider.tsx +24 -0
  228. package/src/organisms/Wizard.tsx +37 -3
  229. package/src/organisms/imperativeBridge.ts +28 -0
  230. package/src/organisms/index.ts +7 -3
  231. package/src/theme/colors.ts +175 -0
  232. package/src/theme/createTheme.ts +38 -4
  233. package/src/theme/index.ts +3 -1
  234. package/src/theme/labels.ts +13 -0
  235. package/src/theme/palettes.ts +5 -2
  236. package/src/theme/types.ts +10 -0
  237. package/lib/commonjs/molecules/ThemeToggle.js.map +0 -1
  238. package/lib/module/molecules/ThemeToggle.js.map +0 -1
  239. package/lib/typescript/molecules/ThemeToggle.d.ts.map +0 -1
  240. package/src/molecules/ThemeToggle.tsx +0 -67
@@ -1,6 +1,5 @@
1
- import React, { memo, useEffect, useRef, useState } from 'react';
1
+ import React, { memo, useEffect, useState } from 'react';
2
2
  import {
3
- Animated,
4
3
  Dimensions,
5
4
  KeyboardAvoidingView,
6
5
  Modal as RNModal,
@@ -15,10 +14,11 @@ import { createStyles } from '../theme/createStyles';
15
14
  import { useColors, useInsets } from '../theme/UIProvider';
16
15
  import { Title } from '../atoms/Text';
17
16
  import { IconButton } from '../atoms/IconButton';
17
+ import { Transition, type TransitionPreset } from '../atoms/Transition';
18
18
 
19
19
  const { height: SCREEN_HEIGHT } = Dimensions.get('window');
20
20
 
21
- export type ModalVariant = 'full' | 'overlay' | 'fullScreen' | 'bottomSheet';
21
+ export type ModalVariant = 'full' | 'overlay' | 'fullScreen';
22
22
 
23
23
  export interface ModalProps
24
24
  extends Pick<RNModalProps, 'statusBarTranslucent' | 'onRequestClose' | 'hardwareAccelerated'> {
@@ -38,7 +38,6 @@ const useStyles = createStyles((theme) => ({
38
38
  flex1: { flex: 1 },
39
39
  fullWrapper: { flex: 1, backgroundColor: theme.colors.background },
40
40
  overlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xxl - 4 },
41
- bottomSheetContainer: { justifyContent: 'flex-end', padding: 0 },
42
41
  backdrop: { backgroundColor: theme.colors.overlay },
43
42
  overlayCard: {
44
43
  width: '100%',
@@ -48,15 +47,6 @@ const useStyles = createStyles((theme) => ({
48
47
  overflow: 'hidden',
49
48
  ...theme.tokens.shadow.lg,
50
49
  },
51
- bottomSheetCard: {
52
- width: '100%',
53
- maxHeight: '90%',
54
- backgroundColor: theme.colors.surface,
55
- borderTopLeftRadius: theme.tokens.radius.xxl,
56
- borderTopRightRadius: theme.tokens.radius.xxl,
57
- overflow: 'hidden',
58
- ...theme.tokens.shadow.lg,
59
- },
60
50
  header: {
61
51
  flexDirection: 'row',
62
52
  justifyContent: 'space-between',
@@ -83,10 +73,8 @@ const useStyles = createStyles((theme) => ({
83
73
  /**
84
74
  * Modal
85
75
  * Un solo componente para todos los modales de la app: maneja scroll,
86
- * teclado, insets y layout según `variant`. overlay/bottomSheet/full animan
87
- * su entrada/salida con Animated (solo transform, nunca opacity sobre la
88
- * tarjeta: animar opacity ahí dispara un bug de Android donde los hijos con
89
- * elevation parpadean/redibujan). El fondo semitransparente sí anima opacity.
76
+ * teclado, insets y layout según `variant`. Para una hoja desde abajo está
77
+ * BottomSheet, que además trae arrastre para cerrar.
90
78
  */
91
79
  export const Modal = memo(
92
80
  ({
@@ -107,43 +95,22 @@ export const Modal = memo(
107
95
  const isFull = variant === 'full';
108
96
  const isOverlay = variant === 'overlay';
109
97
  const isFullScreen = variant === 'fullScreen';
110
- const isBottomSheet = variant === 'bottomSheet';
111
- const isAnimatedVariant = isOverlay || isBottomSheet || isFull;
98
+ const isAnimatedVariant = isOverlay || isFull;
112
99
  const insets = useInsets();
113
100
  const colors = useColors();
114
101
  const styles = useStyles();
115
102
 
116
103
  const [mounted, setMounted] = useState(!isAnimatedVariant && visible);
117
- const backdropOpacity = useRef(new Animated.Value(0)).current;
118
- const cardProgress = useRef(new Animated.Value(0)).current;
119
104
 
120
105
  useEffect(() => {
121
- if (!isAnimatedVariant) {
122
- setMounted(visible);
123
- return;
124
- }
125
- if (visible) {
126
- setMounted(true);
127
- Animated.timing(backdropOpacity, { toValue: 1, duration: 200, useNativeDriver: true }).start();
128
- Animated.timing(cardProgress, { toValue: 1, duration: 250, useNativeDriver: true }).start();
129
- } else {
130
- Animated.timing(backdropOpacity, { toValue: 0, duration: 180, useNativeDriver: true }).start();
131
- Animated.timing(cardProgress, { toValue: 0, duration: 200, useNativeDriver: true }).start(({ finished }) => {
132
- if (finished) setMounted(false);
133
- });
134
- }
135
- // eslint-disable-next-line react-hooks/exhaustive-deps
106
+ if (!isAnimatedVariant) setMounted(visible);
107
+ else if (visible) setMounted(true);
136
108
  }, [visible, isAnimatedVariant]);
137
109
 
138
- const cardAnimatedStyle =
139
- isBottomSheet || isFull
140
- ? { transform: [{ translateY: cardProgress.interpolate({ inputRange: [0, 1], outputRange: [SCREEN_HEIGHT, 0] }) }] }
141
- : {
142
- transform: [
143
- { translateY: cardProgress.interpolate({ inputRange: [0, 1], outputRange: [-40, 0] }) },
144
- { scale: cardProgress.interpolate({ inputRange: [0, 1], outputRange: [0.9, 1] }) },
145
- ],
146
- };
110
+ // La tarjeta nunca anima opacity: hacerlo dispara un bug de Android donde
111
+ // los hijos con elevation parpadean. El fondo semitransparente sí.
112
+ const cardPreset: TransitionPreset[] = isFull ? ['slide-up'] : ['slide-down', 'scale'];
113
+ const cardDistance = isFull ? SCREEN_HEIGHT : 40;
147
114
 
148
115
  const behavior = Platform.OS === 'ios' ? (isFull ? undefined : 'padding') : 'height';
149
116
  const keyboardOffset = Platform.OS === 'ios' ? 0 : 20;
@@ -204,42 +171,63 @@ export const Modal = memo(
204
171
  presentationStyle={
205
172
  Platform.OS === 'ios' ? (isFullScreen ? 'fullScreen' : 'overFullScreen') : undefined
206
173
  }
207
- transparent={isOverlay || isBottomSheet || isFull}
174
+ transparent={isOverlay || isFull}
208
175
  statusBarTranslucent={statusBarTranslucent}
209
176
  hardwareAccelerated={hardwareAccelerated}
210
177
  >
211
178
  <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset} style={styles.flex1}>
212
- {isOverlay || isBottomSheet ? (
213
- <View style={[styles.overlayContainer, isBottomSheet && styles.bottomSheetContainer]}>
179
+ {isOverlay ? (
180
+ <View style={styles.overlayContainer}>
214
181
  {/* Sin accessibilityLabel propio: el header ya expone un botón "Close"
215
182
  accesible; duplicar el label aquí generaría dos elementos con el
216
183
  mismo nombre para lectores de pantalla. Tocar afuera es un atajo
217
184
  puramente táctil. */}
218
- <Animated.View style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop, { opacity: backdropOpacity }]}>
185
+ <Transition
186
+ visible={visible}
187
+ preset="fade"
188
+ duration={200}
189
+ keepMounted
190
+ style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}
191
+ >
219
192
  <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID="modal-backdrop" />
220
- </Animated.View>
221
-
222
- <Animated.View
223
- style={[
224
- isOverlay ? styles.overlayCard : styles.bottomSheetCard,
225
- isBottomSheet && { paddingBottom: Math.max(insets.bottom, Platform.OS === 'ios' ? 20 : 0) },
226
- containerStyle,
227
- cardAnimatedStyle,
228
- ]}
193
+ </Transition>
194
+
195
+ <Transition
196
+ visible={visible}
197
+ preset={cardPreset}
198
+ distance={cardDistance}
199
+ duration={250}
200
+ onExited={() => setMounted(false)}
201
+ style={[styles.overlayCard, containerStyle]}
229
202
  >
230
203
  {renderHeader()}
231
204
  {renderMainContent()}
232
205
  {renderFooter()}
233
- </Animated.View>
206
+ </Transition>
234
207
  </View>
208
+ ) : isFull ? (
209
+ <Transition
210
+ visible={visible}
211
+ preset={cardPreset}
212
+ distance={cardDistance}
213
+ duration={250}
214
+ onExited={() => setMounted(false)}
215
+ style={[styles.fullWrapper, containerStyle]}
216
+ >
217
+ <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
218
+ {renderHeader()}
219
+ {renderMainContent()}
220
+ {renderFooter()}
221
+ </View>
222
+ </Transition>
235
223
  ) : (
236
- <Animated.View style={[styles.fullWrapper, containerStyle, isFull && cardAnimatedStyle]}>
224
+ <View style={[styles.fullWrapper, containerStyle]}>
237
225
  <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
238
226
  {renderHeader()}
239
227
  {renderMainContent()}
240
228
  {renderFooter()}
241
229
  </View>
242
- </Animated.View>
230
+ </View>
243
231
  )}
244
232
  </KeyboardAvoidingView>
245
233
  </RNModal>
@@ -0,0 +1,226 @@
1
+ import React, { memo, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ StyleSheet,
5
+ Text,
6
+ TouchableOpacity,
7
+ View,
8
+ type LayoutChangeEvent,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+ import { createStyles } from '../theme/createStyles';
13
+ import { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';
14
+ import { Icon } from '../icons/Icon';
15
+ import type { IconName } from '../icons/types';
16
+
17
+ export interface NavigationBarItem {
18
+ key: string;
19
+ label: string;
20
+ iconName?: IconName;
21
+ /** Contador sobre el ícono. `true` pinta sólo el punto. */
22
+ badge?: number | string | true;
23
+ }
24
+
25
+ export interface NavigationBarProps {
26
+ items: NavigationBarItem[];
27
+ activeKey: string;
28
+ onChange: (key: string) => void;
29
+ /** Borde de la pantalla donde vive la barra. @default 'bottom' */
30
+ position?: 'bottom' | 'top';
31
+ /** @default true */
32
+ showLabels?: boolean;
33
+ /** Suma el inset de safe area del borde donde vive la barra. @default true */
34
+ safeArea?: boolean;
35
+ style?: StyleProp<ViewStyle>;
36
+ testID?: string;
37
+ }
38
+
39
+ const useStyles = createStyles((theme) => ({
40
+ bar: {
41
+ flexDirection: 'row',
42
+ backgroundColor: theme.colors.surface,
43
+ borderColor: theme.colors.border,
44
+ paddingHorizontal: theme.tokens.spacing.xs,
45
+ paddingVertical: theme.tokens.spacing.sm,
46
+ },
47
+ barBottom: { borderTopWidth: StyleSheet.hairlineWidth },
48
+ barTop: { borderBottomWidth: StyleSheet.hairlineWidth },
49
+ indicator: {
50
+ position: 'absolute',
51
+ top: theme.tokens.spacing.xs,
52
+ bottom: theme.tokens.spacing.xs,
53
+ left: theme.tokens.spacing.sm,
54
+ borderRadius: theme.tokens.radius.lg,
55
+ backgroundColor: theme.colors.primarySoft,
56
+ },
57
+ item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },
58
+ itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },
59
+ label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },
60
+ badge: {
61
+ position: 'absolute',
62
+ top: -4,
63
+ left: '50%',
64
+ marginLeft: 4,
65
+ minWidth: 16,
66
+ height: 16,
67
+ paddingHorizontal: 4,
68
+ borderRadius: theme.tokens.radius.full,
69
+ backgroundColor: theme.colors.error,
70
+ alignItems: 'center',
71
+ justifyContent: 'center',
72
+ },
73
+ badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },
74
+ badgeText: {
75
+ fontSize: 10,
76
+ fontWeight: theme.tokens.fontWeight.bold,
77
+ color: theme.colors.onError,
78
+ },
79
+ }));
80
+
81
+ const ICON_SIZE = 22;
82
+
83
+ /**
84
+ * NavigationBar
85
+ * Barra de navegación por pestañas con la misma apariencia y las mismas
86
+ * transiciones en iOS y Android: el indicador se desliza hasta la pestaña
87
+ * activa y su contenido crece y toma el color de acento con un cross-fade.
88
+ * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.
89
+ */
90
+ export const NavigationBar = memo(
91
+ ({
92
+ items,
93
+ activeKey,
94
+ onChange,
95
+ position = 'bottom',
96
+ showLabels = true,
97
+ safeArea = true,
98
+ style,
99
+ testID,
100
+ }: NavigationBarProps) => {
101
+ const theme = useTheme();
102
+ const colors = useColors();
103
+ const styles = useStyles();
104
+ const insets = useInsets();
105
+ const allowFontScaling = useAllowFontScaling();
106
+
107
+ const [barWidth, setBarWidth] = useState(0);
108
+ const slide = useRef(new Animated.Value(0)).current;
109
+ const placed = useRef(false);
110
+
111
+ const activeIndex = Math.max(
112
+ 0,
113
+ items.findIndex((item) => item.key === activeKey)
114
+ );
115
+ const itemWidth = items.length > 0 ? barWidth / items.length : 0;
116
+
117
+ useEffect(() => {
118
+ if (itemWidth <= 0) return;
119
+ const to = activeIndex * itemWidth;
120
+ // En el primer layout el indicador aparece en su lugar; animarlo desde 0
121
+ // sería un deslizamiento que el usuario no pidió.
122
+ if (!placed.current) {
123
+ placed.current = true;
124
+ slide.setValue(to);
125
+ return;
126
+ }
127
+ Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();
128
+ }, [activeIndex, itemWidth, slide]);
129
+
130
+ const onLayout = (e: LayoutChangeEvent) => {
131
+ const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;
132
+ if (next !== barWidth) setBarWidth(next);
133
+ };
134
+
135
+ const renderContent = (item: NavigationBarItem, color: string) => (
136
+ <>
137
+ {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}
138
+ {showLabels && (
139
+ <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>
140
+ {item.label}
141
+ </Text>
142
+ )}
143
+ </>
144
+ );
145
+
146
+ return (
147
+ <View
148
+ testID={testID}
149
+ onLayout={onLayout}
150
+ accessibilityRole="tablist"
151
+ style={[
152
+ styles.bar,
153
+ position === 'bottom' ? styles.barBottom : styles.barTop,
154
+ safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },
155
+ safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },
156
+ style,
157
+ ]}
158
+ >
159
+ {itemWidth > 0 && (
160
+ <Animated.View
161
+ testID="navigation-bar-indicator"
162
+ pointerEvents="none"
163
+ style={[
164
+ styles.indicator,
165
+ safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },
166
+ safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },
167
+ {
168
+ width: itemWidth - theme.tokens.spacing.sm * 2,
169
+ transform: [{ translateX: slide }],
170
+ },
171
+ ]}
172
+ />
173
+ )}
174
+
175
+ {items.map((item, index) => {
176
+ const active = index === activeIndex;
177
+ const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];
178
+ const scale =
179
+ itemWidth > 0
180
+ ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })
181
+ : 1;
182
+ const activeOpacity =
183
+ itemWidth > 0
184
+ ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })
185
+ : 0;
186
+
187
+ return (
188
+ <TouchableOpacity
189
+ key={item.key}
190
+ style={styles.item}
191
+ activeOpacity={0.7}
192
+ onPress={() => onChange(item.key)}
193
+ accessibilityRole="tab"
194
+ accessibilityLabel={item.label}
195
+ accessibilityState={{ selected: active }}
196
+ >
197
+ <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>
198
+ {renderContent(item, colors.textLight)}
199
+ {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}
200
+ <Animated.View
201
+ pointerEvents="none"
202
+ accessibilityElementsHidden
203
+ importantForAccessibility="no-hide-descendants"
204
+ style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}
205
+ >
206
+ {renderContent(item, colors.primaryText)}
207
+ </Animated.View>
208
+
209
+ {item.badge !== undefined && (
210
+ <View style={[styles.badge, item.badge === true && styles.badgeDot]}>
211
+ {item.badge !== true && (
212
+ <Text allowFontScaling={false} style={styles.badgeText}>
213
+ {item.badge}
214
+ </Text>
215
+ )}
216
+ </View>
217
+ )}
218
+ </Animated.View>
219
+ </TouchableOpacity>
220
+ );
221
+ })}
222
+ </View>
223
+ );
224
+ }
225
+ );
226
+ NavigationBar.displayName = 'NavigationBar';
@@ -13,7 +13,8 @@ export interface ScreenProps {
13
13
  /** Qué insets de safe area aplicar. @default ['top', 'bottom'] */
14
14
  edges?: ScreenEdge[];
15
15
  /**
16
- * Compensa el teclado con un KeyboardAvoidingView. Ponelo en false cuando
16
+ * Compensa el teclado: en iOS con scroll lo hace el propio ScrollView, en el
17
+ * resto de los casos con un KeyboardAvoidingView. Ponelo en false cuando
17
18
  * el contenido ya trae su propio scroller que maneja el teclado (Wizard,
18
19
  * List, SearchList): las dos compensaciones se suman y el campo enfocado
19
20
  * termina empujado fuera de la pantalla.
@@ -46,8 +47,15 @@ export const Screen = memo(({
46
47
  const applyTop = edges.includes('top');
47
48
  const applyBottom = edges.includes('bottom');
48
49
 
49
- const KeyboardWrapper: React.ComponentType<any> = keyboardAvoiding ? KeyboardAvoidingView : View;
50
- const keyboardProps = keyboardAvoiding
50
+ // En iOS el ScrollView de abajo ya sube el campo enfocado con
51
+ // automaticallyAdjustKeyboardInsets; envolverlo además en un
52
+ // KeyboardAvoidingView compensa el teclado dos veces y el input termina
53
+ // empujado fuera del borde superior. Android no tiene ese prop, así que ahí
54
+ // el KeyboardAvoidingView sigue siendo el único mecanismo.
55
+ const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');
56
+
57
+ const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;
58
+ const keyboardProps = avoidWithView
51
59
  ? {
52
60
  behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),
53
61
  keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,
@@ -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,25 +31,44 @@ 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
  }
@@ -64,6 +93,13 @@ const useStyles = createStyles((theme) => ({
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}>
@@ -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
+