orn-ui 0.2.6 → 0.2.8

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 (146) hide show
  1. package/AGENTS.md +372 -0
  2. package/README.md +46 -15
  3. package/lib/commonjs/atoms/Gradient.js +111 -0
  4. package/lib/commonjs/atoms/Gradient.js.map +1 -0
  5. package/lib/commonjs/atoms/index.js +7 -0
  6. package/lib/commonjs/atoms/index.js.map +1 -1
  7. package/lib/commonjs/organisms/Slides.js +406 -0
  8. package/lib/commonjs/organisms/Slides.js.map +1 -0
  9. package/lib/commonjs/organisms/index.js +7 -0
  10. package/lib/commonjs/organisms/index.js.map +1 -1
  11. package/lib/commonjs/organisms/useSlides.js +186 -0
  12. package/lib/commonjs/organisms/useSlides.js.map +1 -0
  13. package/lib/module/atoms/Gradient.js +106 -0
  14. package/lib/module/atoms/Gradient.js.map +1 -0
  15. package/lib/module/atoms/index.js +1 -0
  16. package/lib/module/atoms/index.js.map +1 -1
  17. package/lib/module/organisms/Slides.js +401 -0
  18. package/lib/module/organisms/Slides.js.map +1 -0
  19. package/lib/module/organisms/index.js +1 -0
  20. package/lib/module/organisms/index.js.map +1 -1
  21. package/lib/module/organisms/useSlides.js +182 -0
  22. package/lib/module/organisms/useSlides.js.map +1 -0
  23. package/lib/typescript/atoms/Badge.d.ts +1 -1
  24. package/lib/typescript/atoms/Badge.d.ts.map +1 -1
  25. package/lib/typescript/atoms/Gradient.d.ts +37 -0
  26. package/lib/typescript/atoms/Gradient.d.ts.map +1 -0
  27. package/lib/typescript/atoms/Image.d.ts +6 -6
  28. package/lib/typescript/atoms/Image.d.ts.map +1 -1
  29. package/lib/typescript/atoms/Input.d.ts +1 -1
  30. package/lib/typescript/atoms/Input.d.ts.map +1 -1
  31. package/lib/typescript/atoms/PressableScale.d.ts +1 -1
  32. package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
  33. package/lib/typescript/atoms/Skeleton.d.ts +5 -5
  34. package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
  35. package/lib/typescript/atoms/Spinner.d.ts +4 -4
  36. package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
  37. package/lib/typescript/atoms/Transition.d.ts +10 -10
  38. package/lib/typescript/atoms/Transition.d.ts.map +1 -1
  39. package/lib/typescript/atoms/index.d.ts +2 -0
  40. package/lib/typescript/atoms/index.d.ts.map +1 -1
  41. package/lib/typescript/molecules/AvatarHeader.d.ts +2 -2
  42. package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
  43. package/lib/typescript/molecules/FormActions.d.ts +1 -1
  44. package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
  45. package/lib/typescript/molecules/OptionCard.d.ts +2 -2
  46. package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
  47. package/lib/typescript/molecules/Stepper.d.ts +8 -8
  48. package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
  49. package/lib/typescript/molecules/Steps.d.ts +3 -3
  50. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  51. package/lib/typescript/molecules/SymmetricGrid.d.ts +4 -4
  52. package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
  53. package/lib/typescript/organisms/BottomSheet.d.ts +8 -8
  54. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  55. package/lib/typescript/organisms/DateField.d.ts +10 -10
  56. package/lib/typescript/organisms/DateField.d.ts.map +1 -1
  57. package/lib/typescript/organisms/DatePicker.d.ts +10 -10
  58. package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
  59. package/lib/typescript/organisms/List.d.ts +6 -6
  60. package/lib/typescript/organisms/List.d.ts.map +1 -1
  61. package/lib/typescript/organisms/NavigationBar.d.ts +2 -2
  62. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
  63. package/lib/typescript/organisms/ReorderableList.d.ts +7 -7
  64. package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
  65. package/lib/typescript/organisms/Screen.d.ts +6 -6
  66. package/lib/typescript/organisms/SearchList.d.ts +8 -8
  67. package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
  68. package/lib/typescript/organisms/Select.d.ts +2 -2
  69. package/lib/typescript/organisms/Select.d.ts.map +1 -1
  70. package/lib/typescript/organisms/Slides.d.ts +69 -0
  71. package/lib/typescript/organisms/Slides.d.ts.map +1 -0
  72. package/lib/typescript/organisms/Toast.d.ts +2 -2
  73. package/lib/typescript/organisms/Toast.d.ts.map +1 -1
  74. package/lib/typescript/organisms/ToastProvider.d.ts +2 -2
  75. package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
  76. package/lib/typescript/organisms/Wizard.d.ts +7 -7
  77. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  78. package/lib/typescript/organisms/index.d.ts +2 -0
  79. package/lib/typescript/organisms/index.d.ts.map +1 -1
  80. package/lib/typescript/organisms/useSlides.d.ts +52 -0
  81. package/lib/typescript/organisms/useSlides.d.ts.map +1 -0
  82. package/lib/typescript/theme/UIProvider.d.ts +9 -9
  83. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  84. package/llms.txt +34 -0
  85. package/package.json +34 -6
  86. package/registry/avatar-header.json +1 -1
  87. package/registry/badge.json +1 -1
  88. package/registry/bottom-sheet.json +1 -1
  89. package/registry/core.json +1 -1
  90. package/registry/date-field.json +1 -1
  91. package/registry/date-picker.json +1 -1
  92. package/registry/form-actions.json +1 -1
  93. package/registry/gradient.json +18 -0
  94. package/registry/image.json +1 -1
  95. package/registry/index.json +52 -26
  96. package/registry/input.json +1 -1
  97. package/registry/list.json +1 -1
  98. package/registry/manifest.json +14 -0
  99. package/registry/navigation-bar.json +1 -1
  100. package/registry/option-card.json +1 -1
  101. package/registry/pressable-scale.json +1 -1
  102. package/registry/reorderable-list.json +1 -1
  103. package/registry/screen.json +1 -1
  104. package/registry/search-list.json +1 -1
  105. package/registry/select.json +1 -1
  106. package/registry/skeleton.json +1 -1
  107. package/registry/slides.json +24 -0
  108. package/registry/spinner.json +1 -1
  109. package/registry/stepper.json +1 -1
  110. package/registry/steps.json +1 -1
  111. package/registry/symmetric-grid.json +1 -1
  112. package/registry/toast-provider.json +1 -1
  113. package/registry/toast.json +1 -1
  114. package/registry/transition.json +1 -1
  115. package/registry/wizard.json +1 -1
  116. package/src/atoms/Badge.tsx +1 -1
  117. package/src/atoms/Gradient.tsx +99 -0
  118. package/src/atoms/Image.tsx +6 -6
  119. package/src/atoms/Input.tsx +1 -1
  120. package/src/atoms/PressableScale.tsx +1 -1
  121. package/src/atoms/Skeleton.tsx +5 -5
  122. package/src/atoms/Spinner.tsx +4 -4
  123. package/src/atoms/Transition.tsx +10 -10
  124. package/src/atoms/index.ts +2 -0
  125. package/src/molecules/AvatarHeader.tsx +2 -2
  126. package/src/molecules/FormActions.tsx +1 -1
  127. package/src/molecules/OptionCard.tsx +2 -2
  128. package/src/molecules/Stepper.tsx +8 -8
  129. package/src/molecules/Steps.tsx +3 -3
  130. package/src/molecules/SymmetricGrid.tsx +4 -4
  131. package/src/organisms/BottomSheet.tsx +8 -8
  132. package/src/organisms/DateField.tsx +10 -10
  133. package/src/organisms/DatePicker.tsx +10 -10
  134. package/src/organisms/List.tsx +6 -6
  135. package/src/organisms/NavigationBar.tsx +2 -2
  136. package/src/organisms/ReorderableList.tsx +7 -7
  137. package/src/organisms/Screen.tsx +6 -6
  138. package/src/organisms/SearchList.tsx +8 -8
  139. package/src/organisms/Select.tsx +2 -2
  140. package/src/organisms/Slides.tsx +413 -0
  141. package/src/organisms/Toast.tsx +2 -2
  142. package/src/organisms/ToastProvider.tsx +2 -2
  143. package/src/organisms/Wizard.tsx +7 -7
  144. package/src/organisms/index.ts +8 -0
  145. package/src/organisms/useSlides.ts +220 -0
  146. package/src/theme/UIProvider.tsx +9 -9
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "core": {
3
- "bytes": 37338
3
+ "bytes": 37332
4
4
  },
5
5
  "components": [
6
6
  {
@@ -53,7 +53,7 @@
53
53
  "registryDependencies": [
54
54
  "core"
55
55
  ],
56
- "bytes": 5447
56
+ "bytes": 5455
57
57
  },
58
58
  {
59
59
  "name": "Checkbox",
@@ -77,7 +77,7 @@
77
77
  "registryDependencies": [
78
78
  "core"
79
79
  ],
80
- "bytes": 2172
80
+ "bytes": 2183
81
81
  },
82
82
  {
83
83
  "name": "Card",
@@ -103,6 +103,18 @@
103
103
  ],
104
104
  "bytes": 489
105
105
  },
106
+ {
107
+ "name": "Gradient",
108
+ "slug": "gradient",
109
+ "category": "atoms",
110
+ "exportedNames": [
111
+ "Gradient"
112
+ ],
113
+ "registryDependencies": [
114
+ "core"
115
+ ],
116
+ "bytes": 3852
117
+ },
106
118
  {
107
119
  "name": "Avatar",
108
120
  "slug": "avatar",
@@ -127,7 +139,7 @@
127
139
  "core",
128
140
  "spinner"
129
141
  ],
130
- "bytes": 4610
142
+ "bytes": 4566
131
143
  },
132
144
  {
133
145
  "name": "Spinner",
@@ -139,7 +151,7 @@
139
151
  "registryDependencies": [
140
152
  "core"
141
153
  ],
142
- "bytes": 4626
154
+ "bytes": 4640
143
155
  },
144
156
  {
145
157
  "name": "Skeleton",
@@ -151,7 +163,7 @@
151
163
  "registryDependencies": [
152
164
  "core"
153
165
  ],
154
- "bytes": 3554
166
+ "bytes": 3550
155
167
  },
156
168
  {
157
169
  "name": "Transition",
@@ -164,7 +176,7 @@
164
176
  "registryDependencies": [
165
177
  "core"
166
178
  ],
167
- "bytes": 6558
179
+ "bytes": 6569
168
180
  },
169
181
  {
170
182
  "name": "EmptyState",
@@ -213,7 +225,7 @@
213
225
  "registryDependencies": [
214
226
  "core"
215
227
  ],
216
- "bytes": 1709
228
+ "bytes": 1706
217
229
  },
218
230
  {
219
231
  "name": "Stepper",
@@ -226,7 +238,7 @@
226
238
  "registryDependencies": [
227
239
  "core"
228
240
  ],
229
- "bytes": 6717
241
+ "bytes": 6745
230
242
  },
231
243
  {
232
244
  "name": "OptionCard",
@@ -239,7 +251,7 @@
239
251
  "core",
240
252
  "pressable-scale"
241
253
  ],
242
- "bytes": 4156
254
+ "bytes": 4152
243
255
  },
244
256
  {
245
257
  "name": "InfoRow",
@@ -265,7 +277,7 @@
265
277
  "core",
266
278
  "button"
267
279
  ],
268
- "bytes": 1527
280
+ "bytes": 1524
269
281
  },
270
282
  {
271
283
  "name": "AvatarHeader",
@@ -277,7 +289,7 @@
277
289
  "registryDependencies": [
278
290
  "core"
279
291
  ],
280
- "bytes": 2487
292
+ "bytes": 2492
281
293
  },
282
294
  {
283
295
  "name": "SegmentedControl",
@@ -301,7 +313,7 @@
301
313
  "registryDependencies": [
302
314
  "core"
303
315
  ],
304
- "bytes": 8623
316
+ "bytes": 8628
305
317
  },
306
318
  {
307
319
  "name": "SymmetricGrid",
@@ -313,7 +325,7 @@
313
325
  "registryDependencies": [
314
326
  "core"
315
327
  ],
316
- "bytes": 2789
328
+ "bytes": 2773
317
329
  },
318
330
  {
319
331
  "name": "Modal",
@@ -345,7 +357,7 @@
345
357
  "pressable-scale",
346
358
  "transition"
347
359
  ],
348
- "bytes": 9679
360
+ "bytes": 9678
349
361
  },
350
362
  {
351
363
  "name": "Select",
@@ -357,7 +369,7 @@
357
369
  "registryDependencies": [
358
370
  "core"
359
371
  ],
360
- "bytes": 12122
372
+ "bytes": 12117
361
373
  },
362
374
  {
363
375
  "name": "Alert",
@@ -400,7 +412,7 @@
400
412
  "registryDependencies": [
401
413
  "core"
402
414
  ],
403
- "bytes": 3237
415
+ "bytes": 3227
404
416
  },
405
417
  {
406
418
  "name": "List",
@@ -415,7 +427,7 @@
415
427
  "skeleton",
416
428
  "spinner"
417
429
  ],
418
- "bytes": 6039
430
+ "bytes": 5999
419
431
  },
420
432
  {
421
433
  "name": "SearchList",
@@ -433,7 +445,7 @@
433
445
  "icon-button",
434
446
  "pressable-scale"
435
447
  ],
436
- "bytes": 9285
448
+ "bytes": 9245
437
449
  },
438
450
  {
439
451
  "name": "Toast",
@@ -446,7 +458,7 @@
446
458
  "core",
447
459
  "transition"
448
460
  ],
449
- "bytes": 4402
461
+ "bytes": 4405
450
462
  },
451
463
  {
452
464
  "name": "ToastProvider",
@@ -464,7 +476,7 @@
464
476
  "toast",
465
477
  "transition"
466
478
  ],
467
- "bytes": 6368
479
+ "bytes": 6359
468
480
  },
469
481
  {
470
482
  "name": "DatePicker",
@@ -481,7 +493,7 @@
481
493
  "icon-button",
482
494
  "pressable-scale"
483
495
  ],
484
- "bytes": 13400
496
+ "bytes": 13397
485
497
  },
486
498
  {
487
499
  "name": "DateField",
@@ -500,7 +512,7 @@
500
512
  "date-picker",
501
513
  "button"
502
514
  ],
503
- "bytes": 6455
515
+ "bytes": 6476
504
516
  },
505
517
  {
506
518
  "name": "Wizard",
@@ -515,7 +527,7 @@
515
527
  "button",
516
528
  "transition"
517
529
  ],
518
- "bytes": 5625
530
+ "bytes": 5599
519
531
  },
520
532
  {
521
533
  "name": "ThemeToggle",
@@ -540,7 +552,7 @@
540
552
  "registryDependencies": [
541
553
  "core"
542
554
  ],
543
- "bytes": 7875
555
+ "bytes": 7867
544
556
  },
545
557
  {
546
558
  "name": "ReorderableList",
@@ -552,7 +564,21 @@
552
564
  "registryDependencies": [
553
565
  "core"
554
566
  ],
555
- "bytes": 7326
567
+ "bytes": 7324
568
+ },
569
+ {
570
+ "name": "Slides",
571
+ "slug": "slides",
572
+ "category": "organisms",
573
+ "exportedNames": [
574
+ "Slides"
575
+ ],
576
+ "registryDependencies": [
577
+ "core",
578
+ "transition",
579
+ "gradient"
580
+ ],
581
+ "bytes": 21434
556
582
  }
557
583
  ]
558
584
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "atoms/Input.tsx",
12
- "content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /** Muestra un asterisco junto al label. Puramente visual, no valida nada. */\n required?: boolean;\n error?: string;\n containerStyle?: StyleProp<ViewStyle>;\n style?: TextInputProps['style'];\n leftIconName?: IconName;\n rightIconName?: IconName;\n onRightIconPress?: () => void;\n isPassword?: boolean;\n isLoading?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%' },\n label: {\n marginBottom: theme.tokens.spacing.sm,\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.medium,\n color: theme.colors.text,\n },\n required: { color: theme.colors.errorText },\n inputWrapper: {\n flexDirection: 'row',\n alignItems: 'center',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n minHeight: 50,\n },\n input: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n fontSize: theme.tokens.fontSize.lg,\n color: theme.colors.text,\n },\n leftIcon: { marginLeft: theme.tokens.spacing.lg, marginRight: -theme.tokens.spacing.xs },\n rightIcon: { padding: theme.tokens.spacing.sm + 2, marginRight: theme.tokens.spacing.xs },\n errorText: { marginTop: theme.tokens.spacing.xs, fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * Input\n * El borde anima color entre border/primary/error con Animated (no\n * useNativeDriver: los navegadores de color en RN sin Fabric solo son\n * confiables en el hilo de JS; el costo es despreciable, se anima un borde).\n */\nexport const Input = memo(\n ({\n label,\n required = false,\n error,\n containerStyle,\n leftIconName,\n rightIconName,\n onRightIconPress,\n isPassword = false,\n isLoading = false,\n style,\n onFocus,\n onBlur,\n testID,\n ...props\n }: InputProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [isFocused, setIsFocused] = useState(false);\n const [showPassword, setShowPassword] = useState(false);\n const focusProgress = useRef(new Animated.Value(0)).current;\n\n const handleFocus: TextInputProps['onFocus'] = (e) => {\n setIsFocused(true);\n Animated.timing(focusProgress, { toValue: 1, duration: 200, useNativeDriver: false }).start();\n onFocus?.(e);\n };\n\n const handleBlur: TextInputProps['onBlur'] = (e) => {\n setIsFocused(false);\n Animated.timing(focusProgress, { toValue: 0, duration: 200, useNativeDriver: false }).start();\n onBlur?.(e);\n };\n\n const borderColor = error\n ? colors.error\n : focusProgress.interpolate({ outputRange: [colors.border, colors.primary], inputRange: [0, 1] });\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Animated.View style={[styles.inputWrapper, { borderColor }]}>\n {leftIconName && <Icon name={leftIconName} size={20} color={colors.textLight} style={styles.leftIcon} />}\n\n <TextInput\n style={[styles.input, style]}\n placeholderTextColor={colors.textLight}\n onFocus={handleFocus}\n onBlur={handleBlur}\n secureTextEntry={isPassword && !showPassword}\n allowFontScaling={allowFontScaling}\n textAlignVertical=\"center\"\n accessibilityLabel={label ?? props.placeholder}\n {...props}\n />\n\n {isLoading ? (\n <View style={styles.rightIcon}>\n <ActivityIndicator size=\"small\" color={colors.primary} />\n </View>\n ) : isPassword ? (\n <TouchableOpacity\n onPress={() => setShowPassword((v) => !v)}\n style={styles.rightIcon}\n accessibilityRole=\"button\"\n accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}\n >\n <Icon name={showPassword ? 'eye-off' : 'eye'} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : rightIconName ? (\n <TouchableOpacity\n onPress={onRightIconPress}\n style={styles.rightIcon}\n disabled={!onRightIconPress}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Clear\"\n >\n <Icon name={rightIconName} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : null}\n </Animated.View>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n }\n);\nInput.displayName = 'Input';\n"
12
+ "content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /** Shows an asterisk next to the label. Purely visual, doesn't validate anything. */\n required?: boolean;\n error?: string;\n containerStyle?: StyleProp<ViewStyle>;\n style?: TextInputProps['style'];\n leftIconName?: IconName;\n rightIconName?: IconName;\n onRightIconPress?: () => void;\n isPassword?: boolean;\n isLoading?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%' },\n label: {\n marginBottom: theme.tokens.spacing.sm,\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.medium,\n color: theme.colors.text,\n },\n required: { color: theme.colors.errorText },\n inputWrapper: {\n flexDirection: 'row',\n alignItems: 'center',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n minHeight: 50,\n },\n input: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n fontSize: theme.tokens.fontSize.lg,\n color: theme.colors.text,\n },\n leftIcon: { marginLeft: theme.tokens.spacing.lg, marginRight: -theme.tokens.spacing.xs },\n rightIcon: { padding: theme.tokens.spacing.sm + 2, marginRight: theme.tokens.spacing.xs },\n errorText: { marginTop: theme.tokens.spacing.xs, fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * Input\n * El borde anima color entre border/primary/error con Animated (no\n * useNativeDriver: los navegadores de color en RN sin Fabric solo son\n * confiables en el hilo de JS; el costo es despreciable, se anima un borde).\n */\nexport const Input = memo(\n ({\n label,\n required = false,\n error,\n containerStyle,\n leftIconName,\n rightIconName,\n onRightIconPress,\n isPassword = false,\n isLoading = false,\n style,\n onFocus,\n onBlur,\n testID,\n ...props\n }: InputProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [isFocused, setIsFocused] = useState(false);\n const [showPassword, setShowPassword] = useState(false);\n const focusProgress = useRef(new Animated.Value(0)).current;\n\n const handleFocus: TextInputProps['onFocus'] = (e) => {\n setIsFocused(true);\n Animated.timing(focusProgress, { toValue: 1, duration: 200, useNativeDriver: false }).start();\n onFocus?.(e);\n };\n\n const handleBlur: TextInputProps['onBlur'] = (e) => {\n setIsFocused(false);\n Animated.timing(focusProgress, { toValue: 0, duration: 200, useNativeDriver: false }).start();\n onBlur?.(e);\n };\n\n const borderColor = error\n ? colors.error\n : focusProgress.interpolate({ outputRange: [colors.border, colors.primary], inputRange: [0, 1] });\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Animated.View style={[styles.inputWrapper, { borderColor }]}>\n {leftIconName && <Icon name={leftIconName} size={20} color={colors.textLight} style={styles.leftIcon} />}\n\n <TextInput\n style={[styles.input, style]}\n placeholderTextColor={colors.textLight}\n onFocus={handleFocus}\n onBlur={handleBlur}\n secureTextEntry={isPassword && !showPassword}\n allowFontScaling={allowFontScaling}\n textAlignVertical=\"center\"\n accessibilityLabel={label ?? props.placeholder}\n {...props}\n />\n\n {isLoading ? (\n <View style={styles.rightIcon}>\n <ActivityIndicator size=\"small\" color={colors.primary} />\n </View>\n ) : isPassword ? (\n <TouchableOpacity\n onPress={() => setShowPassword((v) => !v)}\n style={styles.rightIcon}\n accessibilityRole=\"button\"\n accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}\n >\n <Icon name={showPassword ? 'eye-off' : 'eye'} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : rightIconName ? (\n <TouchableOpacity\n onPress={onRightIconPress}\n style={styles.rightIcon}\n disabled={!onRightIconPress}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Clear\"\n >\n <Icon name={rightIconName} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : null}\n </Animated.View>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n }\n);\nInput.displayName = 'Input';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/List.tsx",
12
- "content": "import React, { useCallback } from 'react';\nimport { FlatList, RefreshControl, View, type FlatListProps, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Carga inicial: mientras sea true y no haya datos, se muestran placeholders. */\n isLoading: boolean;\n /** Recarga con datos ya en pantalla (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlado por el consumidor: mientras es false, se muestra un spinner de pantalla completa. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n\n /** Filas fantasma de la carga inicial. @default 6 */\n skeletonCount?: number;\n /** Placeholder propio, para que calce con la forma real del ítem. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: ViewStyle;\n containerStyle?: ViewStyle;\n\n /** Componente de lista a usar (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, backgroundColor: theme.colors.background },\n listContent: { padding: theme.tokens.spacing.md },\n footerLoader: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center' },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * List\n * Lista con estados de carga/paginación/vacío resueltos, sin atarse a\n * expo-router ni a FlashList: `ListComponent` permite inyectar cualquiera\n * (por defecto FlatList del propio React Native).\n */\nexport function List<T>({\n data,\n keyExtractor,\n renderItem,\n isLoading,\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: ListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const isInitialLoading = isLoading && data.length === 0;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footerLoader}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n </>\n );\n }, [isLoadingMore, resolvedLoadingMoreText, ListFooterComponent, styles.footerLoader]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={styles.container}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n if (isInitialLoading) {\n return (\n <View style={[styles.container, containerStyle]} testID=\"list-skeleton\">\n <View style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n </View>\n );\n }\n\n return (\n <View style={[styles.container, containerStyle]}>\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n renderItem={renderItem}\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={onEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n </View>\n );\n}\n"
12
+ "content": "import React, { useCallback } from 'react';\nimport { FlatList, RefreshControl, View, type FlatListProps, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Initial load: while true and there's no data, placeholders are shown. */\n isLoading: boolean;\n /** Reload with data already on screen (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlled by the consumer: while false, a fullscreen spinner is shown. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n\n /** Ghost rows for the initial load. @default 6 */\n skeletonCount?: number;\n /** Custom placeholder, to match the real shape of the item. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: ViewStyle;\n containerStyle?: ViewStyle;\n\n /** List component to use (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, backgroundColor: theme.colors.background },\n listContent: { padding: theme.tokens.spacing.md },\n footerLoader: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center' },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * List\n * Lista con estados de carga/paginación/vacío resueltos, sin atarse a\n * expo-router ni a FlashList: `ListComponent` permite inyectar cualquiera\n * (por defecto FlatList del propio React Native).\n */\nexport function List<T>({\n data,\n keyExtractor,\n renderItem,\n isLoading,\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: ListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const isInitialLoading = isLoading && data.length === 0;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footerLoader}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n </>\n );\n }, [isLoadingMore, resolvedLoadingMoreText, ListFooterComponent, styles.footerLoader]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={styles.container}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n if (isInitialLoading) {\n return (\n <View style={[styles.container, containerStyle]} testID=\"list-skeleton\">\n <View style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n </View>\n );\n }\n\n return (\n <View style={[styles.container, containerStyle]}>\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n renderItem={renderItem}\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={onEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -60,6 +60,13 @@
60
60
  "file": "atoms/Divider.tsx",
61
61
  "slug": "divider"
62
62
  },
63
+ {
64
+ "name": "Gradient",
65
+ "siblingNames": [],
66
+ "category": "atoms",
67
+ "file": "atoms/Gradient.tsx",
68
+ "slug": "gradient"
69
+ },
63
70
  {
64
71
  "name": "Avatar",
65
72
  "siblingNames": [],
@@ -310,6 +317,13 @@
310
317
  "category": "organisms",
311
318
  "file": "organisms/ReorderableList.tsx",
312
319
  "slug": "reorderable-list"
320
+ },
321
+ {
322
+ "name": "Slides",
323
+ "siblingNames": [],
324
+ "category": "organisms",
325
+ "file": "organisms/Slides.tsx",
326
+ "slug": "slides"
313
327
  }
314
328
  ]
315
329
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/NavigationBar.tsx",
12
- "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /** Borde de la pantalla donde vive la barra. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Suma el inset de safe area del borde donde vive la barra. @default true */\n safeArea?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n // El indicador se posiciona contra el borde de la barra, no contra su caja\n // interna: al `left` hay que sumarle el padding horizontal para que quede\n // centrado sobre la pestaña y no corrido hacia la izquierda.\n left: theme.tokens.spacing.xs + theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n testID=\"navigation-bar-indicator\"\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },\n safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
12
+ "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /** Screen edge where the bar lives. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Adds the safe area inset for the edge where the bar lives. @default true */\n safeArea?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n // El indicador se posiciona contra el borde de la barra, no contra su caja\n // interna: al `left` hay que sumarle el padding horizontal para que quede\n // centrado sobre la pestaña y no corrido hacia la izquierda.\n left: theme.tokens.spacing.xs + theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n testID=\"navigation-bar-indicator\"\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },\n safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "molecules/OptionCard.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { PressableScale } from '../atoms/PressableScale';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n /** Línea secundaria: el detalle que decide la elección (\"llega en 3 días\"). */\n description?: string;\n iconName?: IconName;\n isSelected: boolean;\n onPress: () => void;\n disabled?: boolean;\n /** 'vertical': ícono arriba, texto abajo centrado. 'horizontal': en fila. @default 'horizontal' */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n card: {\n // Las tarjetas se reparten la fila que las contenga, sean dos o cinco.\n flex: 1,\n alignItems: 'center',\n justifyContent: 'center',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.md,\n borderWidth: 1,\n borderColor: theme.colors.border,\n ...theme.tokens.shadow.sm,\n },\n cardHorizontal: { flexDirection: 'row', padding: theme.tokens.spacing.md - 2, gap: theme.tokens.spacing.sm },\n cardVertical: { padding: theme.tokens.spacing.md - 2, gap: 6, minHeight: 100 },\n cardSelected: {\n borderColor: theme.colors.primary,\n backgroundColor: theme.colors.primarySoft,\n borderWidth: 1.5,\n elevation: 0,\n shadowOpacity: 0,\n },\n cardDisabled: { opacity: 0.5 },\n text: { flexShrink: 1 },\n textVertical: { alignItems: 'center' },\n label: { color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },\n labelVertical: { fontSize: theme.tokens.fontSize.xs, textAlign: 'center' },\n labelSelected: { color: theme.colors.primaryText },\n description: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.textLight, marginTop: 2 },\n descriptionVertical: { textAlign: 'center' },\n}));\n\n/**\n * OptionCard\n * Opción excluyente con ícono y label que resalta cuando `isSelected`. Es un\n * radio con cara de tarjeta, así que se anuncia como tal: envolver el grupo en\n * una View con `accessibilityRole=\"radiogroup\"` para que el lector de pantalla\n * lea \"1 de 3\".\n */\nexport const OptionCard = memo(\n ({\n label,\n description,\n iconName,\n isSelected,\n onPress,\n disabled = false,\n layout = 'horizontal',\n style,\n testID,\n }: OptionCardProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = layout === 'vertical';\n\n // Feedback por transform y no por opacity: con elevation, animar la opacity\n // dispara un bug de Android que redibuja la sombra como un borde oscuro.\n return (\n <PressableScale\n style={[\n styles.card,\n isVertical ? styles.cardVertical : styles.cardHorizontal,\n isSelected && styles.cardSelected,\n disabled && styles.cardDisabled,\n style,\n ]}\n onPress={onPress}\n disabled={disabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}\n accessibilityLabel={label}\n accessibilityHint={description}\n testID={testID}\n >\n {!!iconName && <Icon name={iconName} size={24} color={isSelected ? colors.primary : colors.textLight} />}\n\n <View style={[styles.text, isVertical && styles.textVertical]}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.label, isVertical && styles.labelVertical, isSelected && styles.labelSelected]}\n >\n {label}\n </Text>\n {!!description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, isVertical && styles.descriptionVertical]}\n >\n {description}\n </Text>\n )}\n </View>\n </PressableScale>\n );\n }\n);\nOptionCard.displayName = 'OptionCard';\n"
12
+ "content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { PressableScale } from '../atoms/PressableScale';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n /** Secondary line: the detail that decides the choice (\"arrives in 3 days\"). */\n description?: string;\n iconName?: IconName;\n isSelected: boolean;\n onPress: () => void;\n disabled?: boolean;\n /** 'vertical': icon on top, text centered below. 'horizontal': in a row. @default 'horizontal' */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n card: {\n // Las tarjetas se reparten la fila que las contenga, sean dos o cinco.\n flex: 1,\n alignItems: 'center',\n justifyContent: 'center',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.md,\n borderWidth: 1,\n borderColor: theme.colors.border,\n ...theme.tokens.shadow.sm,\n },\n cardHorizontal: { flexDirection: 'row', padding: theme.tokens.spacing.md - 2, gap: theme.tokens.spacing.sm },\n cardVertical: { padding: theme.tokens.spacing.md - 2, gap: 6, minHeight: 100 },\n cardSelected: {\n borderColor: theme.colors.primary,\n backgroundColor: theme.colors.primarySoft,\n borderWidth: 1.5,\n elevation: 0,\n shadowOpacity: 0,\n },\n cardDisabled: { opacity: 0.5 },\n text: { flexShrink: 1 },\n textVertical: { alignItems: 'center' },\n label: { color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },\n labelVertical: { fontSize: theme.tokens.fontSize.xs, textAlign: 'center' },\n labelSelected: { color: theme.colors.primaryText },\n description: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.textLight, marginTop: 2 },\n descriptionVertical: { textAlign: 'center' },\n}));\n\n/**\n * OptionCard\n * Opción excluyente con ícono y label que resalta cuando `isSelected`. Es un\n * radio con cara de tarjeta, así que se anuncia como tal: envolver el grupo en\n * una View con `accessibilityRole=\"radiogroup\"` para que el lector de pantalla\n * lea \"1 de 3\".\n */\nexport const OptionCard = memo(\n ({\n label,\n description,\n iconName,\n isSelected,\n onPress,\n disabled = false,\n layout = 'horizontal',\n style,\n testID,\n }: OptionCardProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = layout === 'vertical';\n\n // Feedback por transform y no por opacity: con elevation, animar la opacity\n // dispara un bug de Android que redibuja la sombra como un borde oscuro.\n return (\n <PressableScale\n style={[\n styles.card,\n isVertical ? styles.cardVertical : styles.cardHorizontal,\n isSelected && styles.cardSelected,\n disabled && styles.cardDisabled,\n style,\n ]}\n onPress={onPress}\n disabled={disabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}\n accessibilityLabel={label}\n accessibilityHint={description}\n testID={testID}\n >\n {!!iconName && <Icon name={iconName} size={24} color={isSelected ? colors.primary : colors.textLight} />}\n\n <View style={[styles.text, isVertical && styles.textVertical]}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.label, isVertical && styles.labelVertical, isSelected && styles.labelSelected]}\n >\n {label}\n </Text>\n {!!description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, isVertical && styles.descriptionVertical]}\n >\n {description}\n </Text>\n )}\n </View>\n </PressableScale>\n );\n }\n);\nOptionCard.displayName = 'OptionCard';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "atoms/PressableScale.tsx",
12
- "content": "import React, { memo, useRef } from 'react';\nimport {\n Animated,\n Pressable,\n type GestureResponderEvent,\n type PressableProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\n\nexport interface PressableScaleProps extends Omit<PressableProps, 'style'> {\n /** Escala objetivo al presionar. @default 0.96 */\n scaleTo?: number;\n style?: StyleProp<ViewStyle>;\n children: React.ReactNode;\n}\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\n/**\n * PressableScale\n * Feedback táctil de \"achicarse\" al presionar, con Animated.spring de RN\n * (useNativeDriver: true — corre en el hilo nativo, sin coste de JS por frame).\n * No depende de reanimated: la librería entera es cero-dependencias.\n */\nexport const PressableScale = memo(\n ({ scaleTo = 0.96, style, children, onPressIn, onPressOut, disabled, ...props }: PressableScaleProps) => {\n const scale = useRef(new Animated.Value(1)).current;\n\n const animateTo = (value: number) => {\n Animated.spring(scale, {\n toValue: value,\n useNativeDriver: true,\n speed: 50,\n bounciness: 6,\n }).start();\n };\n\n const handlePressIn = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(scaleTo);\n onPressIn?.(e);\n };\n\n const handlePressOut = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(1);\n onPressOut?.(e);\n };\n\n return (\n <AnimatedPressable\n {...props}\n disabled={disabled}\n onPressIn={handlePressIn}\n onPressOut={handlePressOut}\n style={[style, { transform: [{ scale }] }]}\n >\n {children}\n </AnimatedPressable>\n );\n }\n);\nPressableScale.displayName = 'PressableScale';\n"
12
+ "content": "import React, { memo, useRef } from 'react';\nimport {\n Animated,\n Pressable,\n type GestureResponderEvent,\n type PressableProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\n\nexport interface PressableScaleProps extends Omit<PressableProps, 'style'> {\n /** Target scale when pressed. @default 0.96 */\n scaleTo?: number;\n style?: StyleProp<ViewStyle>;\n children: React.ReactNode;\n}\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\n/**\n * PressableScale\n * Feedback táctil de \"achicarse\" al presionar, con Animated.spring de RN\n * (useNativeDriver: true — corre en el hilo nativo, sin coste de JS por frame).\n * No depende de reanimated: la librería entera es cero-dependencias.\n */\nexport const PressableScale = memo(\n ({ scaleTo = 0.96, style, children, onPressIn, onPressOut, disabled, ...props }: PressableScaleProps) => {\n const scale = useRef(new Animated.Value(1)).current;\n\n const animateTo = (value: number) => {\n Animated.spring(scale, {\n toValue: value,\n useNativeDriver: true,\n speed: 50,\n bounciness: 6,\n }).start();\n };\n\n const handlePressIn = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(scaleTo);\n onPressIn?.(e);\n };\n\n const handlePressOut = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(1);\n onPressOut?.(e);\n };\n\n return (\n <AnimatedPressable\n {...props}\n disabled={disabled}\n onPressIn={handlePressIn}\n onPressOut={handlePressOut}\n style={[style, { transform: [{ scale }] }]}\n >\n {children}\n </AnimatedPressable>\n );\n }\n);\nPressableScale.displayName = 'PressableScale';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/ReorderableList.tsx",
12
- "content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Alto fijo de cada fila: necesario para calcular el índice destino sin medir layout async. */\n itemHeight: number;\n renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;\n /** Se llama al soltar, con el arreglo ya reordenado. No muta `data`. */\n onReorder: (data: T[]) => void;\n /**\n * Empieza el arrastre de la fila `index`. Úsalo para apagar el scroll del\n * contenedor (`scrollEnabled={false}`) mientras dura: en iOS el\n * `UIScrollView` padre cancela los toques del contenido apenas su gesto\n * vertical arranca, y sin eso el arrastre muere a los pocos píxeles.\n */\n onDragStart?: (index: number) => void;\n /** Termina el arrastre — soltando o por cancelación. Siempre corre si corrió `onDragStart`. */\n onDragEnd?: () => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\n/** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */\nexport function move<T>(data: T[], from: number, to: number): T[] {\n const next = data.slice();\n const [item] = next.splice(from, 1) as [T];\n next.splice(to, 0, item);\n return next;\n}\n\nconst useStyles = createStyles(() => ({\n row: { width: '100%' },\n}));\n\n/**\n * ReorderableList\n * Ley de destino común (common fate): al arrastrar una fila, las filas que\n * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo\n * que comunica \"van a intercambiar lugar\" antes de soltar el dedo, no un\n * simple resaltado del ítem activo.\n *\n * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):\n * PanResponder + Animated alcanzan porque el cálculo de índice destino\n * necesita el valor de arrastre en JS en cada movimiento (no algo que\n * useNativeDriver pueda resolver solo).\n *\n * Dentro de un scroll vertical hay que cablear `onDragStart`/`onDragEnd` a\n * `scrollEnabled`, o en iOS el arrastre no llega a empezar:\n *\n * ```tsx\n * const [dragging, setDragging] = useState(false);\n * <ScrollView scrollEnabled={!dragging}>\n * <ReorderableList onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)} … />\n * </ScrollView>\n * ```\n */\nexport function ReorderableList<T>({\n data,\n keyExtractor,\n itemHeight,\n renderItem,\n onReorder,\n onDragStart,\n onDragEnd,\n disabled = false,\n style,\n}: ReorderableListProps<T>) {\n const styles = useStyles();\n const [activeIndex, setActiveIndex] = useState<number | null>(null);\n const [targetIndex, setTargetIndex] = useState<number | null>(null);\n const dy = useRef(new Animated.Value(0)).current;\n const activeIndexRef = useRef<number | null>(null);\n // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de\n // abajo) y su closure queda fijo al render de esa primera vez: leer\n // `targetIndex` (state) directamente ahí siempre daría el valor del\n // montaje. Un ref sí refleja la última escritura sin importar cuándo se\n // creó la closure que lo lee.\n const targetIndexRef = useRef<number | null>(null);\n const dataRef = useRef(data);\n dataRef.current = data;\n // Igual razón que targetIndexRef: el responder de cada fila se crea una\n // sola vez, así que leer `disabled` directo del closure congelaría el\n // valor del momento en que esa fila se creó.\n const disabledRef = useRef(disabled);\n disabledRef.current = disabled;\n // Misma razón: los callbacks se leen desde closures creadas una sola vez.\n const onReorderRef = useRef(onReorder);\n onReorderRef.current = onReorder;\n const onDragStartRef = useRef(onDragStart);\n onDragStartRef.current = onDragStart;\n const onDragEndRef = useRef(onDragEnd);\n onDragEndRef.current = onDragEnd;\n\n const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;\n // Si `data` se achica, los responders de índices que ya no existen quedan\n // colgados en el Map para siempre: se podan en cada render.\n for (const index of panResponders.keys()) {\n if (index >= data.length) panResponders.delete(index);\n }\n\n function panResponderFor(index: number) {\n let responder = panResponders.get(index);\n if (responder) return responder;\n\n const endDrag = () => {\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n onDragEndRef.current?.();\n };\n\n responder = PanResponder.create({\n onStartShouldSetPanResponder: () => !disabledRef.current,\n onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,\n // Un scroll padre pide el responder apenas su gesto vertical arranca —\n // el mismo eje que el arrastre. Cederlo cancelaría el drag a mitad de\n // camino, así que la fila se lo queda hasta soltar.\n onPanResponderTerminationRequest: () => false,\n onPanResponderGrant: () => {\n activeIndexRef.current = index;\n targetIndexRef.current = index;\n setActiveIndex(index);\n setTargetIndex(index);\n dy.setValue(0);\n onDragStartRef.current?.(index);\n },\n onPanResponderMove: (_, gesture) => {\n dy.setValue(gesture.dy);\n const from = activeIndexRef.current;\n if (from === null) return;\n const rawTarget = from + Math.round(gesture.dy / itemHeight);\n const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));\n targetIndexRef.current = clamped;\n setTargetIndex((prev) => (prev === clamped ? prev : clamped));\n },\n onPanResponderRelease: () => {\n const from = activeIndexRef.current;\n const to = targetIndexRef.current;\n endDrag();\n if (from === null || to === null || from === to) return;\n onReorderRef.current(move(dataRef.current, from, to));\n },\n onPanResponderTerminate: endDrag,\n });\n panResponders.set(index, responder);\n return responder;\n }\n\n return (\n <View style={style}>\n {data.map((item, index) => {\n const isActive = activeIndex === index;\n // Filas entre el origen y el destino se corren un lugar entero en el\n // sentido del arrastre: es el efecto de \"grupo que se mueve junto\".\n let shift = 0;\n if (activeIndex !== null && targetIndex !== null && !isActive) {\n if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;\n else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;\n }\n\n return (\n <Animated.View\n key={keyExtractor(item, index)}\n style={[\n styles.row,\n isActive\n ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }\n : { transform: [{ translateY: shift }] },\n ]}\n {...panResponderFor(index).panHandlers}\n >\n {renderItem(item, index, isActive)}\n </Animated.View>\n );\n })}\n </View>\n );\n}\n"
12
+ "content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Fixed height of each row: needed to calculate the target index without measuring layout async. */\n itemHeight: number;\n renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;\n /** Called on release, with the array already reordered. Does not mutate `data`. */\n onReorder: (data: T[]) => void;\n /**\n * Starts dragging row `index`. Use it to turn off the container's scroll\n * (`scrollEnabled={false}`) while it lasts: on iOS the parent\n * `UIScrollView` cancels touches on the content as soon as its\n * vertical gesture starts, and without this the drag dies within a few pixels.\n */\n onDragStart?: (index: number) => void;\n /** Ends the drag — on release or cancellation. Always runs if `onDragStart` ran. */\n onDragEnd?: () => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\n/** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */\nexport function move<T>(data: T[], from: number, to: number): T[] {\n const next = data.slice();\n const [item] = next.splice(from, 1) as [T];\n next.splice(to, 0, item);\n return next;\n}\n\nconst useStyles = createStyles(() => ({\n row: { width: '100%' },\n}));\n\n/**\n * ReorderableList\n * Ley de destino común (common fate): al arrastrar una fila, las filas que\n * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo\n * que comunica \"van a intercambiar lugar\" antes de soltar el dedo, no un\n * simple resaltado del ítem activo.\n *\n * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):\n * PanResponder + Animated alcanzan porque el cálculo de índice destino\n * necesita el valor de arrastre en JS en cada movimiento (no algo que\n * useNativeDriver pueda resolver solo).\n *\n * Dentro de un scroll vertical hay que cablear `onDragStart`/`onDragEnd` a\n * `scrollEnabled`, o en iOS el arrastre no llega a empezar:\n *\n * ```tsx\n * const [dragging, setDragging] = useState(false);\n * <ScrollView scrollEnabled={!dragging}>\n * <ReorderableList onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)} … />\n * </ScrollView>\n * ```\n */\nexport function ReorderableList<T>({\n data,\n keyExtractor,\n itemHeight,\n renderItem,\n onReorder,\n onDragStart,\n onDragEnd,\n disabled = false,\n style,\n}: ReorderableListProps<T>) {\n const styles = useStyles();\n const [activeIndex, setActiveIndex] = useState<number | null>(null);\n const [targetIndex, setTargetIndex] = useState<number | null>(null);\n const dy = useRef(new Animated.Value(0)).current;\n const activeIndexRef = useRef<number | null>(null);\n // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de\n // abajo) y su closure queda fijo al render de esa primera vez: leer\n // `targetIndex` (state) directamente ahí siempre daría el valor del\n // montaje. Un ref sí refleja la última escritura sin importar cuándo se\n // creó la closure que lo lee.\n const targetIndexRef = useRef<number | null>(null);\n const dataRef = useRef(data);\n dataRef.current = data;\n // Igual razón que targetIndexRef: el responder de cada fila se crea una\n // sola vez, así que leer `disabled` directo del closure congelaría el\n // valor del momento en que esa fila se creó.\n const disabledRef = useRef(disabled);\n disabledRef.current = disabled;\n // Misma razón: los callbacks se leen desde closures creadas una sola vez.\n const onReorderRef = useRef(onReorder);\n onReorderRef.current = onReorder;\n const onDragStartRef = useRef(onDragStart);\n onDragStartRef.current = onDragStart;\n const onDragEndRef = useRef(onDragEnd);\n onDragEndRef.current = onDragEnd;\n\n const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;\n // Si `data` se achica, los responders de índices que ya no existen quedan\n // colgados en el Map para siempre: se podan en cada render.\n for (const index of panResponders.keys()) {\n if (index >= data.length) panResponders.delete(index);\n }\n\n function panResponderFor(index: number) {\n let responder = panResponders.get(index);\n if (responder) return responder;\n\n const endDrag = () => {\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n onDragEndRef.current?.();\n };\n\n responder = PanResponder.create({\n onStartShouldSetPanResponder: () => !disabledRef.current,\n onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,\n // Un scroll padre pide el responder apenas su gesto vertical arranca —\n // el mismo eje que el arrastre. Cederlo cancelaría el drag a mitad de\n // camino, así que la fila se lo queda hasta soltar.\n onPanResponderTerminationRequest: () => false,\n onPanResponderGrant: () => {\n activeIndexRef.current = index;\n targetIndexRef.current = index;\n setActiveIndex(index);\n setTargetIndex(index);\n dy.setValue(0);\n onDragStartRef.current?.(index);\n },\n onPanResponderMove: (_, gesture) => {\n dy.setValue(gesture.dy);\n const from = activeIndexRef.current;\n if (from === null) return;\n const rawTarget = from + Math.round(gesture.dy / itemHeight);\n const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));\n targetIndexRef.current = clamped;\n setTargetIndex((prev) => (prev === clamped ? prev : clamped));\n },\n onPanResponderRelease: () => {\n const from = activeIndexRef.current;\n const to = targetIndexRef.current;\n endDrag();\n if (from === null || to === null || from === to) return;\n onReorderRef.current(move(dataRef.current, from, to));\n },\n onPanResponderTerminate: endDrag,\n });\n panResponders.set(index, responder);\n return responder;\n }\n\n return (\n <View style={style}>\n {data.map((item, index) => {\n const isActive = activeIndex === index;\n // Filas entre el origen y el destino se corren un lugar entero en el\n // sentido del arrastre: es el efecto de \"grupo que se mueve junto\".\n let shift = 0;\n if (activeIndex !== null && targetIndex !== null && !isActive) {\n if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;\n else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;\n }\n\n return (\n <Animated.View\n key={keyExtractor(item, index)}\n style={[\n styles.row,\n isActive\n ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }\n : { transform: [{ translateY: shift }] },\n ]}\n {...panResponderFor(index).panHandlers}\n >\n {renderItem(item, index, isActive)}\n </Animated.View>\n );\n })}\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Screen.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Qué insets de safe area aplicar. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensa el teclado: en iOS con scroll lo hace el propio ScrollView, en el\n * resto de los casos con un KeyboardAvoidingView. Ponelo en false cuando\n * el contenido ya trae su propio scroller que maneja el teclado (Wizard,\n * List, SearchList): las dos compensaciones se suman y el campo enfocado\n * termina empujado fuera de la pantalla.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
12
+ "content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Which safe area insets to apply. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensates for the keyboard: on iOS with scroll the ScrollView itself does it, in\n * other cases a KeyboardAvoidingView does. Set to false when\n * the content already brings its own scroller that handles the keyboard (Wizard,\n * List, SearchList): the two compensations add up and the focused field\n * ends up pushed off screen.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/SearchList.tsx",
12
- "content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /** Chrome opcional arriba del buscador (ej. back button + título). */\n header?: React.ReactNode;\n\n searchValue: string;\n onSearchChange: (text: string) => void;\n searchPlaceholder?: string;\n searchIsLoading?: boolean;\n onScanPress?: () => void;\n scanAccessibilityLabel?: string;\n extraActions?: React.ReactNode;\n\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Carga inicial: mientras sea true y no haya datos, se muestran placeholders. */\n isLoading?: boolean;\n /** 'replace' cambia toda la lista por un spinner/skeleton; 'overlay' lo muestra encima manteniendo el layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Recarga con datos ya en pantalla (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlado por el consumidor: mientras es false, se muestra un spinner de pantalla completa. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n /** Filas fantasma de la carga inicial. @default 6 */\n skeletonCount?: number;\n /** Placeholder propio, para que calce con la forma real del ítem. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onLoadMore?: () => void;\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: StyleProp<ViewStyle>;\n containerStyle?: ViewStyle;\n\n /** Componente de lista a usar (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1 },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * SearchList\n * Buscador + lista + estados de carga/paginación/vacío. El contenido de\n * cada ítem (renderItem) queda a cargo de quien la usa, ya que difiere por\n * completo entre casos de uso.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel = 'Scan',\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"search\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View style={styles.loadingOverlay}>\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View testID=\"list-skeleton\" style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\n"
12
+ "content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /** Optional chrome above the search bar (e.g. back button + title). */\n header?: React.ReactNode;\n\n searchValue: string;\n onSearchChange: (text: string) => void;\n searchPlaceholder?: string;\n searchIsLoading?: boolean;\n onScanPress?: () => void;\n scanAccessibilityLabel?: string;\n extraActions?: React.ReactNode;\n\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Initial load: while true and there's no data, placeholders are shown. */\n isLoading?: boolean;\n /** 'replace' swaps the whole list for a spinner/skeleton; 'overlay' shows it on top while keeping the layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Reload with data already on screen (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlled by the consumer: while false, a fullscreen spinner is shown. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n /** Ghost rows for the initial load. @default 6 */\n skeletonCount?: number;\n /** Custom placeholder, to match the real shape of the item. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onLoadMore?: () => void;\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: StyleProp<ViewStyle>;\n containerStyle?: ViewStyle;\n\n /** List component to use (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1 },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * SearchList\n * Buscador + lista + estados de carga/paginación/vacío. El contenido de\n * cada ítem (renderItem) queda a cargo de quien la usa, ya que difiere por\n * completo entre casos de uso.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel = 'Scan',\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"search\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View style={styles.loadingOverlay}>\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View testID=\"list-skeleton\" style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [