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,7 +1,7 @@
1
- import React, { memo, useMemo, useState } from 'react';
1
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { createStyles } from '../theme/createStyles';
4
- import { useAllowFontScaling, useColors } from '../theme/UIProvider';
4
+ import { useAllowFontScaling, useLabels } from '../theme/UIProvider';
5
5
  import { IconButton } from '../atoms/IconButton';
6
6
 
7
7
  export const DEFAULT_MONTH_NAMES = [
@@ -36,34 +36,40 @@ export interface DatePickerProps {
36
36
  maxDate?: Date;
37
37
  /** Mes mostrado al abrir si no hay `value`. @default hoy */
38
38
  defaultMonth?: Date;
39
+ /** Nombres de los meses. @default los de `useLabels()` */
39
40
  monthNames?: string[];
41
+ /** Iniciales de los días, índice 0 = domingo. @default las de `useLabels()` */
40
42
  weekdayNames?: string[];
41
43
  /** 0 = domingo, 1 = lunes. @default 0 */
42
44
  firstDayOfWeek?: 0 | 1;
45
+ /** Avisa qué mes quedó a la vista al navegar con las flechas. */
46
+ onVisibleMonthChange?: (month: Date) => void;
43
47
  style?: StyleProp<ViewStyle>;
44
48
  testID?: string;
45
49
  }
46
50
 
47
- /** Compara sólo año/mes/día, ignorando hora — dos Date del mismo día son "iguales". */
48
- function isSameDay(a: Date, b: Date): boolean {
49
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
50
- }
51
-
52
51
  function startOfDay(d: Date): Date {
53
52
  return new Date(d.getFullYear(), d.getMonth(), d.getDate());
54
53
  }
55
54
 
56
- /** Estrictamente entre start y end, sin incluir los extremos. */
57
- function isBetween(day: Date, start: Date, end: Date): boolean {
58
- const t = startOfDay(day).getTime();
59
- return t > startOfDay(start).getTime() && t < startOfDay(end).getTime();
55
+ /** Timestamp del día, para comparar sin volver a crear Date en cada celda. */
56
+ function dayTime(d: Date): number {
57
+ return startOfDay(d).getTime();
58
+ }
59
+
60
+ function dayTimeOf(d: Date | undefined): number | undefined {
61
+ return d ? dayTime(d) : undefined;
62
+ }
63
+
64
+ /** Milisegundos que faltan para la próxima medianoche local. */
65
+ function msUntilNextMidnight(from: Date): number {
66
+ const next = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);
67
+ return next.getTime() - from.getTime();
60
68
  }
61
69
 
62
- function isOutOfRange(day: Date, minDate?: Date, maxDate?: Date): boolean {
63
- const t = startOfDay(day).getTime();
64
- if (minDate && t < startOfDay(minDate).getTime()) return true;
65
- if (maxDate && t > startOfDay(maxDate).getTime()) return true;
66
- return false;
70
+ /** Número de mes absoluto (año * 12 + mes), para comparar meses con un `<`. */
71
+ function monthIndex(d: Date): number {
72
+ return d.getFullYear() * 12 + d.getMonth();
67
73
  }
68
74
 
69
75
  /**
@@ -121,6 +127,74 @@ const useStyles = createStyles((theme) => ({
121
127
  dayTextDisabled: { color: theme.colors.textLight, opacity: 0.4 },
122
128
  }));
123
129
 
130
+ type Styles = ReturnType<typeof useStyles>;
131
+
132
+ interface DayCellProps {
133
+ day: Date;
134
+ label: string;
135
+ selected: boolean;
136
+ inRange: boolean;
137
+ isRangeStart: boolean;
138
+ isRangeEnd: boolean;
139
+ isToday: boolean;
140
+ disabled: boolean;
141
+ allowFontScaling: boolean;
142
+ styles: Styles;
143
+ onPress: (day: Date) => void;
144
+ }
145
+
146
+ /**
147
+ * Una celda del calendario. Va memoizada aparte porque el mes entero son 42
148
+ * celdas: sin esto, cualquier render del padre reconcilia las 42.
149
+ */
150
+ const DayCell = memo(
151
+ ({
152
+ day,
153
+ label,
154
+ selected,
155
+ inRange,
156
+ isRangeStart,
157
+ isRangeEnd,
158
+ isToday,
159
+ disabled,
160
+ allowFontScaling,
161
+ styles,
162
+ onPress,
163
+ }: DayCellProps) => (
164
+ <View
165
+ style={[
166
+ styles.cell,
167
+ (inRange || isRangeStart || isRangeEnd) && styles.cellInRange,
168
+ isRangeStart && styles.cellRangeStart,
169
+ isRangeEnd && styles.cellRangeEnd,
170
+ ]}
171
+ >
172
+ <TouchableOpacity
173
+ style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}
174
+ onPress={() => onPress(day)}
175
+ disabled={disabled}
176
+ activeOpacity={0.7}
177
+ accessibilityRole="button"
178
+ accessibilityLabel={label}
179
+ accessibilityState={{ selected, disabled }}
180
+ >
181
+ <Text
182
+ allowFontScaling={allowFontScaling}
183
+ style={[
184
+ styles.dayText,
185
+ inRange && styles.dayTextInRange,
186
+ selected && styles.dayTextSelected,
187
+ disabled && styles.dayTextDisabled,
188
+ ]}
189
+ >
190
+ {day.getDate()}
191
+ </Text>
192
+ </TouchableOpacity>
193
+ </View>
194
+ )
195
+ );
196
+ DayCell.displayName = 'DayCell';
197
+
124
198
  /**
125
199
  * DatePicker
126
200
  * Calendario mensual dibujado con View/Text puros — sin
@@ -137,58 +211,117 @@ export const DatePicker = memo(
137
211
  minDate,
138
212
  maxDate,
139
213
  defaultMonth,
140
- monthNames = DEFAULT_MONTH_NAMES,
141
- weekdayNames = DEFAULT_WEEKDAY_NAMES,
214
+ monthNames,
215
+ weekdayNames,
142
216
  firstDayOfWeek = 0,
217
+ onVisibleMonthChange,
143
218
  style,
144
219
  testID,
145
220
  }: DatePickerProps) => {
146
- const colors = useColors();
147
221
  const styles = useStyles();
222
+ const labels = useLabels();
148
223
  const allowFontScaling = useAllowFontScaling();
149
224
  const isRange = mode === 'range';
150
225
 
151
- const handlePress = (day: Date) => {
152
- if (!isRange) {
153
- onChange?.(day);
154
- return;
155
- }
156
- // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca
157
- // un rango nuevo. Segundo toque en adelante: lo cierra.
158
- if (!range?.start || range.end || startOfDay(day) < startOfDay(range.start)) {
159
- onRangeChange?.({ start: day });
160
- } else {
161
- onRangeChange?.({ start: range.start, end: day });
162
- }
163
- };
164
-
165
- const initial = value ?? range?.start ?? defaultMonth ?? new Date();
166
- const [visibleMonth, setVisibleMonth] = useState(() => new Date(initial.getFullYear(), initial.getMonth(), 1));
167
-
168
- const today = useMemo(() => new Date(), []);
226
+ const months = monthNames ?? labels.months;
227
+ const weekdays = weekdayNames ?? labels.weekdaysShort;
228
+
229
+ const handlePress = useCallback(
230
+ (day: Date) => {
231
+ if (!isRange) {
232
+ onChange?.(day);
233
+ return;
234
+ }
235
+ // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca
236
+ // un rango nuevo. Segundo toque en adelante: lo cierra.
237
+ if (!range?.start || range.end || dayTime(day) < dayTime(range.start)) {
238
+ onRangeChange?.({ start: day });
239
+ } else {
240
+ onRangeChange?.({ start: range.start, end: day });
241
+ }
242
+ },
243
+ [isRange, onChange, onRangeChange, range?.start, range?.end]
244
+ );
245
+
246
+ const anchor = value ?? range?.start ?? defaultMonth;
247
+ const [visibleMonth, setVisibleMonth] = useState(
248
+ () => new Date((anchor ?? new Date()).getFullYear(), (anchor ?? new Date()).getMonth(), 1)
249
+ );
250
+
251
+ // La fecha elegida puede cambiar desde afuera (uso controlado): sin esto,
252
+ // el calendario se quedaba clavado en el mes con el que montó.
253
+ const anchorMonth = anchor ? monthIndex(anchor) : undefined;
254
+ const lastAnchorMonth = useRef(anchorMonth);
255
+ useEffect(() => {
256
+ if (anchorMonth === undefined || anchorMonth === lastAnchorMonth.current) return;
257
+ lastAnchorMonth.current = anchorMonth;
258
+ setVisibleMonth(new Date(Math.floor(anchorMonth / 12), anchorMonth % 12, 1));
259
+ }, [anchorMonth]);
260
+
261
+ // "Hoy" se recalcula en la próxima medianoche: una app que queda abierta
262
+ // toda la noche marcaba el día anterior.
263
+ const [todayTs, setTodayTs] = useState(() => dayTime(new Date()));
264
+ useEffect(() => {
265
+ const timer = setTimeout(() => setTodayTs(dayTime(new Date())), msUntilNextMidnight(new Date()));
266
+ return () => clearTimeout(timer);
267
+ }, [todayTs]);
268
+
169
269
  const grid = useMemo(
170
270
  () => buildMonthGrid(visibleMonth.getFullYear(), visibleMonth.getMonth(), firstDayOfWeek),
171
271
  [visibleMonth, firstDayOfWeek]
172
272
  );
173
273
 
174
274
  const orderedWeekdays = useMemo(
175
- () => (firstDayOfWeek === 0 ? weekdayNames : [...weekdayNames.slice(1), weekdayNames[0]!]),
176
- [weekdayNames, firstDayOfWeek]
275
+ () => (firstDayOfWeek === 0 ? weekdays : [...weekdays.slice(1), weekdays[0]!]),
276
+ [weekdays, firstDayOfWeek]
277
+ );
278
+
279
+ // Los límites se resuelven una vez y no 42 veces por render: comparar
280
+ // timestamps sale gratis, crear un Date por celda no.
281
+ const bounds = useMemo(
282
+ () => ({
283
+ min: dayTimeOf(minDate),
284
+ max: dayTimeOf(maxDate),
285
+ start: dayTimeOf(range?.start),
286
+ end: dayTimeOf(range?.end),
287
+ selected: dayTimeOf(value),
288
+ }),
289
+ [minDate, maxDate, range?.start, range?.end, value]
177
290
  );
178
291
 
179
292
  const shiftMonth = (delta: number) =>
180
- setVisibleMonth((m) => new Date(m.getFullYear(), m.getMonth() + delta, 1));
293
+ setVisibleMonth((m) => {
294
+ const next = new Date(m.getFullYear(), m.getMonth() + delta, 1);
295
+ onVisibleMonthChange?.(next);
296
+ return next;
297
+ });
298
+
299
+ // Un mes entero fuera de los límites no tiene nada que elegir: la flecha
300
+ // que lleva ahí se apaga en vez de dejar navegar al vacío.
301
+ const visibleIndex = monthIndex(visibleMonth);
302
+ const canGoBack = !minDate || visibleIndex > monthIndex(minDate);
303
+ const canGoForward = !maxDate || visibleIndex < monthIndex(maxDate);
181
304
 
182
- const monthLabel = `${monthNames[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;
305
+ const monthLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;
183
306
 
184
307
  return (
185
308
  <View style={[styles.container, style]} testID={testID}>
186
309
  <View style={styles.header}>
187
- <IconButton iconName="chevron-left" accessibilityLabel="Previous month" onPress={() => shiftMonth(-1)} />
310
+ <IconButton
311
+ iconName="chevron-left"
312
+ accessibilityLabel="Previous month"
313
+ disabled={!canGoBack}
314
+ onPress={() => shiftMonth(-1)}
315
+ />
188
316
  <Text allowFontScaling={allowFontScaling} style={styles.monthLabel} accessibilityRole="header">
189
317
  {monthLabel}
190
318
  </Text>
191
- <IconButton iconName="chevron-right" accessibilityLabel="Next month" onPress={() => shiftMonth(1)} />
319
+ <IconButton
320
+ iconName="chevron-right"
321
+ accessibilityLabel="Next month"
322
+ disabled={!canGoForward}
323
+ onPress={() => shiftMonth(1)}
324
+ />
192
325
  </View>
193
326
 
194
327
  <View style={styles.weekRow}>
@@ -204,46 +337,31 @@ export const DatePicker = memo(
204
337
  {row.map((day, cellIndex) => {
205
338
  if (!day) return <View style={styles.cell} key={cellIndex} />;
206
339
 
207
- const isStart = isRange && !!range?.start && isSameDay(day, range.start);
208
- const isEnd = isRange && !!range?.end && isSameDay(day, range.end);
209
- const inRange =
210
- isRange && !!range?.start && !!range.end && isBetween(day, range.start, range.end);
211
- const selected = isRange ? isStart || isEnd : !!value && isSameDay(day, value);
340
+ const t = dayTime(day);
341
+ const disabled = (bounds.min !== undefined && t < bounds.min) || (bounds.max !== undefined && t > bounds.max);
342
+ const isStart = isRange && bounds.start === t;
343
+ const isEnd = isRange && bounds.end === t;
344
+ const closed = isRange && bounds.start !== undefined && bounds.end !== undefined;
345
+ const between = closed && t > bounds.start! && t < bounds.end!;
346
+ const selected = isRange ? isStart || isEnd : bounds.selected === t;
212
347
 
213
- const disabled = isOutOfRange(day, minDate, maxDate);
214
- const isToday = isSameDay(day, today);
215
348
  return (
216
- <View
217
- style={[
218
- styles.cell,
219
- (inRange || (isStart && !!range?.end) || isEnd) && styles.cellInRange,
220
- isStart && !!range?.end && styles.cellRangeStart,
221
- isEnd && styles.cellRangeEnd,
222
- ]}
349
+ <DayCell
223
350
  key={cellIndex}
224
- >
225
- <TouchableOpacity
226
- style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}
227
- onPress={() => handlePress(day)}
228
- disabled={disabled}
229
- activeOpacity={0.7}
230
- accessibilityRole="button"
231
- accessibilityLabel={`${monthNames[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}
232
- accessibilityState={{ selected, disabled }}
233
- >
234
- <Text
235
- allowFontScaling={allowFontScaling}
236
- style={[
237
- styles.dayText,
238
- inRange && styles.dayTextInRange,
239
- selected && styles.dayTextSelected,
240
- disabled && styles.dayTextDisabled,
241
- ]}
242
- >
243
- {day.getDate()}
244
- </Text>
245
- </TouchableOpacity>
246
- </View>
351
+ day={day}
352
+ label={`${months[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}
353
+ selected={selected}
354
+ // Un día fuera de los límites no se pinta como parte del
355
+ // rango: se ve deshabilitado, y eso es lo que es.
356
+ inRange={between && !disabled}
357
+ isRangeStart={!!closed && isStart && !disabled}
358
+ isRangeEnd={isEnd && !disabled}
359
+ isToday={t === todayTs}
360
+ disabled={disabled}
361
+ allowFontScaling={allowFontScaling}
362
+ styles={styles}
363
+ onPress={handlePress}
364
+ />
247
365
  );
248
366
  })}
249
367
  </View>
@@ -1,32 +1,32 @@
1
- import React, { memo, useCallback } from 'react';
2
- import {
3
- ActivityIndicator,
4
- FlatList,
5
- Platform,
6
- RefreshControl,
7
- Text,
8
- View,
9
- type FlatListProps,
10
- type ViewStyle,
11
- } from 'react-native';
1
+ import React, { useCallback } from 'react';
2
+ import { FlatList, RefreshControl, View, type FlatListProps, type ViewStyle } from 'react-native';
12
3
  import { createStyles } from '../theme/createStyles';
13
4
  import { useColors, useLabels } from '../theme/UIProvider';
14
5
  import { EmptyState } from '../atoms/EmptyState';
6
+ import { Skeleton } from '../atoms/Skeleton';
15
7
  import { Spinner } from '../atoms/Spinner';
16
8
  import type { IconName } from '../icons/types';
17
9
 
18
10
  export interface ListProps<T> {
19
11
  data: T[];
20
- keyExtractor: (item: T) => string;
12
+ keyExtractor: (item: T, index: number) => string;
21
13
  renderItem: FlatListProps<T>['renderItem'];
22
14
 
15
+ /** Carga inicial: mientras sea true y no haya datos, se muestran placeholders. */
23
16
  isLoading: boolean;
17
+ /** Recarga con datos ya en pantalla (pull-to-refresh). @default false */
18
+ isRefreshing?: boolean;
24
19
  isLoadingMore?: boolean;
25
20
  /** Controlado por el consumidor: mientras es false, se muestra un spinner de pantalla completa. @default true */
26
21
  isReady?: boolean;
27
22
  loadingText?: string;
28
23
  loadingMoreText?: string;
29
24
 
25
+ /** Filas fantasma de la carga inicial. @default 6 */
26
+ skeletonCount?: number;
27
+ /** Placeholder propio, para que calce con la forma real del ítem. */
28
+ renderSkeletonItem?: () => React.ReactElement;
29
+
30
30
  onEndReached?: () => void;
31
31
  onEndReachedThreshold?: number;
32
32
  onRefresh?: () => void;
@@ -48,20 +48,15 @@ export interface ListProps<T> {
48
48
 
49
49
  const useStyles = createStyles((theme) => ({
50
50
  container: { flex: 1, backgroundColor: theme.colors.background },
51
- loadingOverlay: {
52
- position: 'absolute',
53
- top: 0,
54
- left: 0,
55
- right: 0,
56
- bottom: 0,
57
- backgroundColor: theme.colors.scrim,
58
- zIndex: 10,
59
- justifyContent: 'center',
51
+ listContent: { padding: theme.tokens.spacing.md },
52
+ footerLoader: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center' },
53
+ skeletonRow: {
54
+ flexDirection: 'row',
60
55
  alignItems: 'center',
56
+ gap: theme.tokens.spacing.md,
57
+ padding: theme.tokens.spacing.md,
61
58
  },
62
- loadingText: { marginTop: theme.tokens.spacing.md, color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },
63
- listContent: { padding: theme.tokens.spacing.md, paddingBottom: Platform.OS === 'android' ? 100 : 80 },
64
- footerLoader: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center' },
59
+ skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },
65
60
  }));
66
61
 
67
62
  /**
@@ -75,10 +70,13 @@ export function List<T>({
75
70
  keyExtractor,
76
71
  renderItem,
77
72
  isLoading,
73
+ isRefreshing = false,
78
74
  isLoadingMore = false,
79
75
  isReady = true,
80
76
  loadingText,
81
77
  loadingMoreText,
78
+ skeletonCount = 6,
79
+ renderSkeletonItem,
82
80
  onEndReached,
83
81
  onEndReachedThreshold = 0.3,
84
82
  onRefresh,
@@ -98,22 +96,36 @@ export function List<T>({
98
96
  const resolvedLoadingText = loadingText ?? labels.loading;
99
97
  const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;
100
98
 
99
+ const isInitialLoading = isLoading && data.length === 0;
100
+
101
+ // El footer propio y el loader de paginación conviven: antes el primero
102
+ // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.
101
103
  const renderFooter = useCallback(() => {
102
- if (ListFooterComponent) {
103
- return typeof ListFooterComponent === 'function' ? <ListFooterComponent /> : (ListFooterComponent as React.ReactElement);
104
- }
105
- if (!isLoadingMore) return null;
104
+ const custom =
105
+ typeof ListFooterComponent === 'function' ? (
106
+ <ListFooterComponent />
107
+ ) : (
108
+ (ListFooterComponent as React.ReactElement | undefined) ?? null
109
+ );
110
+
106
111
  return (
107
- <View style={styles.footerLoader}>
108
- <Spinner text={resolvedLoadingMoreText} fullscreen={false} />
109
- </View>
112
+ <>
113
+ {custom}
114
+ {isLoadingMore && (
115
+ <View style={styles.footerLoader}>
116
+ <Spinner text={resolvedLoadingMoreText} fullscreen={false} />
117
+ </View>
118
+ )}
119
+ </>
110
120
  );
111
121
  }, [isLoadingMore, resolvedLoadingMoreText, ListFooterComponent, styles.footerLoader]);
112
122
 
123
+ // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se
124
+ // muestran placeholders, no "sin resultados".
113
125
  const renderEmpty = useCallback(() => {
114
- if (isLoading) return null;
126
+ if (isLoading || isRefreshing) return null;
115
127
  return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;
116
- }, [isLoading, emptyTitle, emptyDescription, emptyIconName]);
128
+ }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);
117
129
 
118
130
  if (!isReady) {
119
131
  return (
@@ -123,15 +135,30 @@ export function List<T>({
123
135
  );
124
136
  }
125
137
 
126
- return (
127
- <View style={[styles.container, containerStyle]}>
128
- {isLoading && data.length === 0 && (
129
- <View style={styles.loadingOverlay} pointerEvents="none">
130
- <ActivityIndicator size="large" color={colors.primary} />
131
- <Text style={styles.loadingText}>{resolvedLoadingText}</Text>
138
+ if (isInitialLoading) {
139
+ return (
140
+ <View style={[styles.container, containerStyle]} testID="list-skeleton">
141
+ <View style={[styles.listContent, contentContainerStyle]}>
142
+ {Array.from({ length: skeletonCount }, (_, i) => (
143
+ <React.Fragment key={i}>
144
+ {renderSkeletonItem?.() ?? (
145
+ <View style={styles.skeletonRow}>
146
+ <Skeleton variant="circle" width={40} />
147
+ <View style={styles.skeletonText}>
148
+ <Skeleton width="60%" height={14} />
149
+ <Skeleton width="35%" height={12} />
150
+ </View>
151
+ </View>
152
+ )}
153
+ </React.Fragment>
154
+ ))}
132
155
  </View>
133
- )}
156
+ </View>
157
+ );
158
+ }
134
159
 
160
+ return (
161
+ <View style={[styles.container, containerStyle]}>
135
162
  <ListComponent
136
163
  data={data}
137
164
  keyExtractor={keyExtractor}
@@ -144,7 +171,12 @@ export function List<T>({
144
171
  ListEmptyComponent={renderEmpty}
145
172
  refreshControl={
146
173
  onRefresh ? (
147
- <RefreshControl refreshing={isLoading} onRefresh={onRefresh} colors={[colors.primary]} tintColor={colors.primary} />
174
+ <RefreshControl
175
+ refreshing={isRefreshing}
176
+ onRefresh={onRefresh}
177
+ colors={[colors.primary]}
178
+ tintColor={colors.primary}
179
+ />
148
180
  ) : undefined
149
181
  }
150
182
  {...listProps}