orn-ui 0.2.6 → 0.2.7

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 (108) hide show
  1. package/AGENTS.md +354 -0
  2. package/README.md +43 -13
  3. package/lib/typescript/atoms/Badge.d.ts +1 -1
  4. package/lib/typescript/atoms/Badge.d.ts.map +1 -1
  5. package/lib/typescript/atoms/Image.d.ts +6 -6
  6. package/lib/typescript/atoms/Image.d.ts.map +1 -1
  7. package/lib/typescript/atoms/Input.d.ts +1 -1
  8. package/lib/typescript/atoms/Input.d.ts.map +1 -1
  9. package/lib/typescript/atoms/PressableScale.d.ts +1 -1
  10. package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
  11. package/lib/typescript/atoms/Skeleton.d.ts +5 -5
  12. package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
  13. package/lib/typescript/atoms/Spinner.d.ts +4 -4
  14. package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
  15. package/lib/typescript/atoms/Transition.d.ts +10 -10
  16. package/lib/typescript/atoms/Transition.d.ts.map +1 -1
  17. package/lib/typescript/molecules/AvatarHeader.d.ts +2 -2
  18. package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
  19. package/lib/typescript/molecules/FormActions.d.ts +1 -1
  20. package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
  21. package/lib/typescript/molecules/OptionCard.d.ts +2 -2
  22. package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
  23. package/lib/typescript/molecules/Stepper.d.ts +8 -8
  24. package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
  25. package/lib/typescript/molecules/Steps.d.ts +3 -3
  26. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  27. package/lib/typescript/molecules/SymmetricGrid.d.ts +4 -4
  28. package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
  29. package/lib/typescript/organisms/BottomSheet.d.ts +8 -8
  30. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  31. package/lib/typescript/organisms/DateField.d.ts +10 -10
  32. package/lib/typescript/organisms/DateField.d.ts.map +1 -1
  33. package/lib/typescript/organisms/DatePicker.d.ts +10 -10
  34. package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
  35. package/lib/typescript/organisms/List.d.ts +6 -6
  36. package/lib/typescript/organisms/List.d.ts.map +1 -1
  37. package/lib/typescript/organisms/NavigationBar.d.ts +2 -2
  38. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
  39. package/lib/typescript/organisms/ReorderableList.d.ts +7 -7
  40. package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
  41. package/lib/typescript/organisms/Screen.d.ts +6 -6
  42. package/lib/typescript/organisms/SearchList.d.ts +8 -8
  43. package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
  44. package/lib/typescript/organisms/Select.d.ts +2 -2
  45. package/lib/typescript/organisms/Select.d.ts.map +1 -1
  46. package/lib/typescript/organisms/Toast.d.ts +2 -2
  47. package/lib/typescript/organisms/Toast.d.ts.map +1 -1
  48. package/lib/typescript/organisms/ToastProvider.d.ts +2 -2
  49. package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
  50. package/lib/typescript/organisms/Wizard.d.ts +7 -7
  51. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  52. package/lib/typescript/theme/UIProvider.d.ts +9 -9
  53. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  54. package/llms.txt +33 -0
  55. package/package.json +24 -6
  56. package/registry/avatar-header.json +1 -1
  57. package/registry/badge.json +1 -1
  58. package/registry/bottom-sheet.json +1 -1
  59. package/registry/core.json +1 -1
  60. package/registry/date-field.json +1 -1
  61. package/registry/date-picker.json +1 -1
  62. package/registry/form-actions.json +1 -1
  63. package/registry/image.json +1 -1
  64. package/registry/index.json +26 -26
  65. package/registry/input.json +1 -1
  66. package/registry/list.json +1 -1
  67. package/registry/navigation-bar.json +1 -1
  68. package/registry/option-card.json +1 -1
  69. package/registry/pressable-scale.json +1 -1
  70. package/registry/reorderable-list.json +1 -1
  71. package/registry/screen.json +1 -1
  72. package/registry/search-list.json +1 -1
  73. package/registry/select.json +1 -1
  74. package/registry/skeleton.json +1 -1
  75. package/registry/spinner.json +1 -1
  76. package/registry/stepper.json +1 -1
  77. package/registry/steps.json +1 -1
  78. package/registry/symmetric-grid.json +1 -1
  79. package/registry/toast-provider.json +1 -1
  80. package/registry/toast.json +1 -1
  81. package/registry/transition.json +1 -1
  82. package/registry/wizard.json +1 -1
  83. package/src/atoms/Badge.tsx +1 -1
  84. package/src/atoms/Image.tsx +6 -6
  85. package/src/atoms/Input.tsx +1 -1
  86. package/src/atoms/PressableScale.tsx +1 -1
  87. package/src/atoms/Skeleton.tsx +5 -5
  88. package/src/atoms/Spinner.tsx +4 -4
  89. package/src/atoms/Transition.tsx +10 -10
  90. package/src/molecules/AvatarHeader.tsx +2 -2
  91. package/src/molecules/FormActions.tsx +1 -1
  92. package/src/molecules/OptionCard.tsx +2 -2
  93. package/src/molecules/Stepper.tsx +8 -8
  94. package/src/molecules/Steps.tsx +3 -3
  95. package/src/molecules/SymmetricGrid.tsx +4 -4
  96. package/src/organisms/BottomSheet.tsx +8 -8
  97. package/src/organisms/DateField.tsx +10 -10
  98. package/src/organisms/DatePicker.tsx +10 -10
  99. package/src/organisms/List.tsx +6 -6
  100. package/src/organisms/NavigationBar.tsx +2 -2
  101. package/src/organisms/ReorderableList.tsx +7 -7
  102. package/src/organisms/Screen.tsx +6 -6
  103. package/src/organisms/SearchList.tsx +8 -8
  104. package/src/organisms/Select.tsx +2 -2
  105. package/src/organisms/Toast.tsx +2 -2
  106. package/src/organisms/ToastProvider.tsx +2 -2
  107. package/src/organisms/Wizard.tsx +7 -7
  108. package/src/theme/UIProvider.tsx +9 -9
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "atoms/Skeleton.tsx",
12
- "content": "import React, { memo, useEffect, useRef } from 'react';\nimport {\n Animated,\n Easing,\n View,\n type DimensionValue,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type SkeletonVariant = 'rect' | 'text' | 'circle';\n\nexport interface SkeletonProps {\n /** @default '100%' ('circle' lo usa también como diámetro) */\n width?: DimensionValue;\n /** @default 16 ('text' usa el alto de línea; 'circle', el ancho) */\n height?: DimensionValue;\n /** @default 'rect' */\n variant?: SkeletonVariant;\n /** Override del radius del theme. */\n radius?: number;\n /** Líneas apiladas, sólo para variant=\"text\". @default 1 */\n lines?: number;\n /** Ancho de la última línea, para que el párrafo no termine en bloque. @default '60%' */\n lastLineWidth?: DimensionValue;\n /** @default true */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n block: { backgroundColor: theme.colors.skeleton },\n lines: { gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * Skeleton\n * Placeholder que late mientras llega el contenido real. Reserva el espacio\n * exacto de lo que reemplaza, así la pantalla no salta cuando aparecen los\n * datos — que es justamente lo que un Spinner centrado no puede hacer.\n */\nexport const Skeleton = memo(\n ({\n width = '100%',\n height = 16,\n variant = 'rect',\n radius,\n lines = 1,\n lastLineWidth = '60%',\n animated = true,\n style,\n testID,\n }: SkeletonProps) => {\n const theme = useTheme();\n const styles = useStyles();\n const pulse = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n if (!animated) return;\n const loop = Animated.loop(\n Animated.sequence([\n Animated.timing(pulse, {\n toValue: 1,\n duration: 700,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n Animated.timing(pulse, {\n toValue: 0,\n duration: 700,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n ])\n );\n loop.start();\n return () => loop.stop();\n }, [animated, pulse]);\n\n const isCircle = variant === 'circle';\n const isText = variant === 'text';\n const opacity = animated ? pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 0.4] }) : 1;\n const resolvedHeight = isCircle ? width : isText ? theme.tokens.fontSize.md : height;\n const resolvedRadius =\n radius ?? (isCircle ? theme.tokens.radius.full : isText ? theme.tokens.radius.xs : theme.tokens.radius.sm);\n\n const barStyle = (barWidth: DimensionValue): StyleProp<ViewStyle> => [\n styles.block,\n { width: barWidth, height: resolvedHeight, borderRadius: resolvedRadius, opacity },\n ];\n\n const a11y = { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' as const };\n\n if (isText && lines > 1) {\n return (\n <View style={[styles.lines, style]} testID={testID} {...a11y}>\n {Array.from({ length: lines }, (_, i) => (\n <Animated.View\n key={i}\n testID={testID && `${testID}-${i}`}\n style={barStyle(i === lines - 1 ? lastLineWidth : width)}\n />\n ))}\n </View>\n );\n }\n\n return <Animated.View style={[barStyle(width), style]} testID={testID} {...a11y} />;\n }\n);\nSkeleton.displayName = 'Skeleton';\n"
12
+ "content": "import React, { memo, useEffect, useRef } from 'react';\nimport {\n Animated,\n Easing,\n View,\n type DimensionValue,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type SkeletonVariant = 'rect' | 'text' | 'circle';\n\nexport interface SkeletonProps {\n /** @default '100%' ('circle' also uses this as the diameter) */\n width?: DimensionValue;\n /** @default 16 ('text' uses the line height; 'circle', the width) */\n height?: DimensionValue;\n /** @default 'rect' */\n variant?: SkeletonVariant;\n /** Override of the theme's radius. */\n radius?: number;\n /** Stacked lines, only for variant=\"text\". @default 1 */\n lines?: number;\n /** Width of the last line, so the paragraph doesn't end as a solid block. @default '60%' */\n lastLineWidth?: DimensionValue;\n /** @default true */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n block: { backgroundColor: theme.colors.skeleton },\n lines: { gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * Skeleton\n * Placeholder que late mientras llega el contenido real. Reserva el espacio\n * exacto de lo que reemplaza, así la pantalla no salta cuando aparecen los\n * datos — que es justamente lo que un Spinner centrado no puede hacer.\n */\nexport const Skeleton = memo(\n ({\n width = '100%',\n height = 16,\n variant = 'rect',\n radius,\n lines = 1,\n lastLineWidth = '60%',\n animated = true,\n style,\n testID,\n }: SkeletonProps) => {\n const theme = useTheme();\n const styles = useStyles();\n const pulse = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n if (!animated) return;\n const loop = Animated.loop(\n Animated.sequence([\n Animated.timing(pulse, {\n toValue: 1,\n duration: 700,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n Animated.timing(pulse, {\n toValue: 0,\n duration: 700,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n ])\n );\n loop.start();\n return () => loop.stop();\n }, [animated, pulse]);\n\n const isCircle = variant === 'circle';\n const isText = variant === 'text';\n const opacity = animated ? pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 0.4] }) : 1;\n const resolvedHeight = isCircle ? width : isText ? theme.tokens.fontSize.md : height;\n const resolvedRadius =\n radius ?? (isCircle ? theme.tokens.radius.full : isText ? theme.tokens.radius.xs : theme.tokens.radius.sm);\n\n const barStyle = (barWidth: DimensionValue): StyleProp<ViewStyle> => [\n styles.block,\n { width: barWidth, height: resolvedHeight, borderRadius: resolvedRadius, opacity },\n ];\n\n const a11y = { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' as const };\n\n if (isText && lines > 1) {\n return (\n <View style={[styles.lines, style]} testID={testID} {...a11y}>\n {Array.from({ length: lines }, (_, i) => (\n <Animated.View\n key={i}\n testID={testID && `${testID}-${i}`}\n style={barStyle(i === lines - 1 ? lastLineWidth : width)}\n />\n ))}\n </View>\n );\n }\n\n return <Animated.View style={[barStyle(width), style]} testID={testID} {...a11y} />;\n }\n);\nSkeleton.displayName = 'Skeleton';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "atoms/Spinner.tsx",
12
- "content": "import React, { memo, useEffect, useRef } from 'react';\nimport { ActivityIndicator, Animated, Easing, Text, View } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\n\nexport type SpinnerVariant = 'native' | 'dots' | 'ring';\n\nexport interface SpinnerProps {\n size?: 'small' | 'large';\n color?: string;\n text?: string;\n /** @default true. En false, no ocupa flex:1 (útil inline, dentro de un botón/fila). */\n fullscreen?: boolean;\n /**\n * 'native' usa el ActivityIndicator del sistema; 'dots' y 'ring' son\n * indicadores propios, iguales en iOS y Android.\n * @default 'native'\n */\n variant?: SpinnerVariant;\n /** Indicador totalmente propio. Gana sobre `variant`. */\n indicator?: React.ReactNode;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xl },\n text: { marginTop: theme.tokens.spacing.md, color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n dots: { flexDirection: 'row', gap: theme.tokens.spacing.sm },\n}));\n\n/** Tres puntos que laten en secuencia. Animated + useNativeDriver: corre en el hilo nativo. */\nconst DotsIndicator = memo(({ color, dotSize }: { color: string; dotSize: number }) => {\n const dots = [useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current];\n\n useEffect(() => {\n const animations = dots.map((dot, i) =>\n Animated.loop(\n Animated.sequence([\n Animated.delay(i * 140),\n Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true, easing: Easing.out(Easing.quad) }),\n Animated.timing(dot, { toValue: 0, duration: 320, useNativeDriver: true, easing: Easing.in(Easing.quad) }),\n Animated.delay((2 - i) * 140),\n ])\n )\n );\n animations.forEach((a) => a.start());\n // Sin stop() los loops siguen corriendo tras desmontar y mantienen vivo\n // el árbol de Animated.\n return () => animations.forEach((a) => a.stop());\n }, []);\n\n return (\n <View style={{ flexDirection: 'row', gap: dotSize * 0.6 }}>\n {dots.map((dot, i) => (\n <Animated.View\n key={i}\n style={{\n width: dotSize,\n height: dotSize,\n borderRadius: dotSize / 2,\n backgroundColor: color,\n opacity: dot.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }),\n transform: [{ scale: dot.interpolate({ inputRange: [0, 1], outputRange: [0.75, 1] }) }],\n }}\n />\n ))}\n </View>\n );\n});\nDotsIndicator.displayName = 'DotsIndicator';\n\n/** Anillo que gira: un borde con un solo lado coloreado. */\nconst RingIndicator = memo(({ color, diameter }: { color: string; diameter: number }) => {\n const spin = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n const animation = Animated.loop(\n Animated.timing(spin, { toValue: 1, duration: 900, useNativeDriver: true, easing: Easing.linear })\n );\n animation.start();\n return () => animation.stop();\n }, []);\n\n return (\n <Animated.View\n style={{\n width: diameter,\n height: diameter,\n borderRadius: diameter / 2,\n borderWidth: Math.max(2, diameter / 10),\n borderColor: color + '33',\n borderTopColor: color,\n transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }],\n }}\n />\n );\n});\nRingIndicator.displayName = 'RingIndicator';\n\nexport const Spinner = memo(\n ({ size = 'large', color, text, fullscreen = true, variant = 'native', indicator, testID }: SpinnerProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const resolvedColor = color || colors.primary;\n const scale = size === 'small' ? 8 : 12;\n\n const renderIndicator = () => {\n if (indicator) return indicator;\n if (variant === 'dots') return <DotsIndicator color={resolvedColor} dotSize={scale} />;\n if (variant === 'ring') return <RingIndicator color={resolvedColor} diameter={scale * 3} />;\n return <ActivityIndicator size={size} color={resolvedColor} />;\n };\n\n return (\n <View style={[styles.container, fullscreen && { flex: 1 }]} testID={testID}>\n {renderIndicator()}\n {text && (\n <Text allowFontScaling={allowFontScaling} style={styles.text}>\n {text}\n </Text>\n )}\n </View>\n );\n }\n);\nSpinner.displayName = 'Spinner';\n"
12
+ "content": "import React, { memo, useEffect, useRef } from 'react';\nimport { ActivityIndicator, Animated, Easing, Text, View } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\n\nexport type SpinnerVariant = 'native' | 'dots' | 'ring';\n\nexport interface SpinnerProps {\n size?: 'small' | 'large';\n color?: string;\n text?: string;\n /** @default true. When false, doesn't take up flex:1 (useful inline, inside a button/row). */\n fullscreen?: boolean;\n /**\n * 'native' uses the system's ActivityIndicator; 'dots' and 'ring' are\n * custom indicators, identical on iOS and Android.\n * @default 'native'\n */\n variant?: SpinnerVariant;\n /** Fully custom indicator. Takes precedence over `variant`. */\n indicator?: React.ReactNode;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xl },\n text: { marginTop: theme.tokens.spacing.md, color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n dots: { flexDirection: 'row', gap: theme.tokens.spacing.sm },\n}));\n\n/** Tres puntos que laten en secuencia. Animated + useNativeDriver: corre en el hilo nativo. */\nconst DotsIndicator = memo(({ color, dotSize }: { color: string; dotSize: number }) => {\n const dots = [useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current];\n\n useEffect(() => {\n const animations = dots.map((dot, i) =>\n Animated.loop(\n Animated.sequence([\n Animated.delay(i * 140),\n Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true, easing: Easing.out(Easing.quad) }),\n Animated.timing(dot, { toValue: 0, duration: 320, useNativeDriver: true, easing: Easing.in(Easing.quad) }),\n Animated.delay((2 - i) * 140),\n ])\n )\n );\n animations.forEach((a) => a.start());\n // Sin stop() los loops siguen corriendo tras desmontar y mantienen vivo\n // el árbol de Animated.\n return () => animations.forEach((a) => a.stop());\n }, []);\n\n return (\n <View style={{ flexDirection: 'row', gap: dotSize * 0.6 }}>\n {dots.map((dot, i) => (\n <Animated.View\n key={i}\n style={{\n width: dotSize,\n height: dotSize,\n borderRadius: dotSize / 2,\n backgroundColor: color,\n opacity: dot.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }),\n transform: [{ scale: dot.interpolate({ inputRange: [0, 1], outputRange: [0.75, 1] }) }],\n }}\n />\n ))}\n </View>\n );\n});\nDotsIndicator.displayName = 'DotsIndicator';\n\n/** Anillo que gira: un borde con un solo lado coloreado. */\nconst RingIndicator = memo(({ color, diameter }: { color: string; diameter: number }) => {\n const spin = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n const animation = Animated.loop(\n Animated.timing(spin, { toValue: 1, duration: 900, useNativeDriver: true, easing: Easing.linear })\n );\n animation.start();\n return () => animation.stop();\n }, []);\n\n return (\n <Animated.View\n style={{\n width: diameter,\n height: diameter,\n borderRadius: diameter / 2,\n borderWidth: Math.max(2, diameter / 10),\n borderColor: color + '33',\n borderTopColor: color,\n transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }],\n }}\n />\n );\n});\nRingIndicator.displayName = 'RingIndicator';\n\nexport const Spinner = memo(\n ({ size = 'large', color, text, fullscreen = true, variant = 'native', indicator, testID }: SpinnerProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const resolvedColor = color || colors.primary;\n const scale = size === 'small' ? 8 : 12;\n\n const renderIndicator = () => {\n if (indicator) return indicator;\n if (variant === 'dots') return <DotsIndicator color={resolvedColor} dotSize={scale} />;\n if (variant === 'ring') return <RingIndicator color={resolvedColor} diameter={scale * 3} />;\n return <ActivityIndicator size={size} color={resolvedColor} />;\n };\n\n return (\n <View style={[styles.container, fullscreen && { flex: 1 }]} testID={testID}>\n {renderIndicator()}\n {text && (\n <Text allowFontScaling={allowFontScaling} style={styles.text}>\n {text}\n </Text>\n )}\n </View>\n );\n }\n);\nSpinner.displayName = 'Spinner';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "molecules/Stepper.tsx",
13
- "content": "import React, { memo, useState } from 'react';\nimport { TextInput, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport type StepperSize = 'sm' | 'md' | 'lg';\n\nexport interface StepperProps {\n value: string;\n onChangeText: (text: string) => void;\n onIncrement: () => void;\n onDecrement: () => void;\n /** Escala del control: alto, botones, ícono y tipografía. @default 'md' */\n size?: StepperSize;\n /** Ocupa todo el ancho disponible y el input se lleva el sobrante. @default false */\n block?: boolean;\n /**\n * En false el valor sólo se cambia con los botones: el teclado no se abre y\n * el campo pierde el fondo que lo anuncia como editable. @default true\n */\n editable?: boolean;\n /**\n * Mínimo permitido. Deshabilita \"-\" al alcanzarlo y acota lo tipeado al\n * salir del campo. Los negativos se aceptan sólo si `min` es negativo.\n */\n min?: number;\n /** Máximo permitido. Deshabilita \"+\" al alcanzarlo y acota lo tipeado. */\n max?: number;\n /** Acepta un separador decimal, normalizando la coma a punto. @default false */\n allowDecimals?: boolean;\n style?: StyleProp<ViewStyle>;\n incrementAccessibilityLabel?: string;\n decrementAccessibilityLabel?: string;\n testID?: string;\n}\n\nconst SIZES: Record<StepperSize, { height: number; icon: number; inputWidth: number }> = {\n sm: { height: 36, icon: 18, inputWidth: 48 },\n md: { height: 44, icon: 20, inputWidth: 56 },\n lg: { height: 52, icon: 22, inputWidth: 64 },\n};\n\nconst useStyles = createStyles((theme) => ({\n control: {\n flexDirection: 'row',\n alignItems: 'center',\n alignSelf: 'flex-start',\n borderWidth: 1,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.md,\n },\n controlBlock: { alignSelf: 'stretch' },\n btn: { height: '100%', justifyContent: 'center', alignItems: 'center' },\n input: {\n textAlign: 'center',\n height: '100%',\n color: theme.colors.text,\n borderLeftWidth: 1,\n borderRightWidth: 1,\n borderColor: theme.colors.border,\n },\n // El fondo distingue el campo de un valor de sólo lectura: es la única\n // señal de que ahí se puede tipear antes de tocarlo.\n editable: { backgroundColor: theme.colors.inputBackground },\n}));\n\n/** Deja sólo lo que puede formar parte del número permitido por el rango. */\nexport function sanitizeNumeric(text: string, { allowDecimals = false, allowNegative = false } = {}): string {\n const normalized = allowDecimals ? text.replace(',', '.') : text;\n const negative = allowNegative && normalized.trimStart().startsWith('-');\n const digits = normalized.replace(allowDecimals ? /[^0-9.]/g : /[^0-9]/g, '');\n const [whole = '', ...decimals] = digits.split('.');\n const body = decimals.length > 0 ? `${whole}.${decimals.join('')}` : whole;\n return negative ? `-${body}` : body;\n}\n\n/**\n * Stepper\n * Botón \"-\" + input numérico editable + botón \"+\". `size` controla sólo la\n * escala y `block` sólo el ancho, así que los dos ejes se combinan libremente.\n *\n * El componente no hace la aritmética de los botones —eso vive en\n * `onIncrement`/`onDecrement`—, pero sí limpia lo que se tipea y avisa cuándo\n * el valor llegó a un extremo del rango deshabilitando el botón que lo pasaría.\n */\nexport const Stepper = memo(\n ({\n value,\n onChangeText,\n onIncrement,\n onDecrement,\n size = 'md',\n block = false,\n editable = true,\n min,\n max,\n allowDecimals = false,\n style,\n incrementAccessibilityLabel = 'Increment',\n decrementAccessibilityLabel = 'Decrement',\n testID,\n }: StepperProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [focused, setFocused] = useState(false);\n\n const { height, icon, inputWidth } = SIZES[size];\n const fontSize =\n size === 'sm' ? theme.tokens.fontSize.md : size === 'md' ? theme.tokens.fontSize.lg : theme.tokens.fontSize.xl;\n\n const allowNegative = min !== undefined && min < 0;\n const numeric = Number(value);\n const isNumber = value.trim() !== '' && Number.isFinite(numeric);\n const atMin = isNumber && min !== undefined && numeric <= min;\n const atMax = isNumber && max !== undefined && numeric >= max;\n\n const handleChangeText = (text: string) => {\n onChangeText(sanitizeNumeric(text, { allowDecimals, allowNegative }));\n };\n\n // El recorte al rango espera al blur: hacerlo mientras se tipea impide\n // llegar a \"12\" cuando el mínimo es 5, porque el \"1\" ya se corregiría.\n const handleBlur = () => {\n setFocused(false);\n if (!isNumber) return;\n const clamped = Math.min(max ?? numeric, Math.max(min ?? numeric, numeric));\n if (clamped !== numeric) onChangeText(String(clamped));\n };\n\n const stepButton = (\n direction: 'decrement' | 'increment',\n onPress: () => void,\n disabled: boolean,\n label: string\n ) => (\n <TouchableOpacity\n onPress={onPress}\n disabled={disabled}\n style={[styles.btn, { width: height }]}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ disabled }}\n testID={testID && `${testID}-${direction}`}\n >\n <Icon\n name={direction === 'increment' ? 'plus' : 'minus'}\n size={icon}\n color={disabled ? colors.textLight : colors.primary}\n />\n </TouchableOpacity>\n );\n\n return (\n <View\n testID={testID}\n style={[\n styles.control,\n { height },\n block && styles.controlBlock,\n focused && { borderColor: colors.primary },\n style,\n ]}\n >\n {stepButton('decrement', onDecrement, atMin, decrementAccessibilityLabel)}\n <TextInput\n allowFontScaling={allowFontScaling}\n style={[\n styles.input,\n { fontSize },\n editable && styles.editable,\n focused && { borderColor: colors.primary },\n block ? { flex: 1 } : { width: inputWidth },\n ]}\n value={value}\n onChangeText={handleChangeText}\n onFocus={() => setFocused(true)}\n onBlur={handleBlur}\n editable={editable}\n keyboardType={allowDecimals ? 'decimal-pad' : 'number-pad'}\n selectTextOnFocus\n accessibilityLabel=\"Quantity\"\n />\n {stepButton('increment', onIncrement, atMax, incrementAccessibilityLabel)}\n </View>\n );\n }\n);\nStepper.displayName = 'Stepper';\n"
13
+ "content": "import React, { memo, useState } from 'react';\nimport { TextInput, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport type StepperSize = 'sm' | 'md' | 'lg';\n\nexport interface StepperProps {\n value: string;\n onChangeText: (text: string) => void;\n onIncrement: () => void;\n onDecrement: () => void;\n /** Scale of the control: height, buttons, icon and typography. @default 'md' */\n size?: StepperSize;\n /** Takes up all available width and the input absorbs the extra space. @default false */\n block?: boolean;\n /**\n * When false the value can only be changed with the buttons: the keyboard doesn't open and\n * the field loses the background that marks it as editable. @default true\n */\n editable?: boolean;\n /**\n * Minimum allowed value. Disables \"-\" when reached and clamps the typed value on\n * blur. Negative values are only accepted if `min` is negative.\n */\n min?: number;\n /** Maximum allowed value. Disables \"+\" when reached and clamps the typed value. */\n max?: number;\n /** Accepts a decimal separator, normalizing comma to dot. @default false */\n allowDecimals?: boolean;\n style?: StyleProp<ViewStyle>;\n incrementAccessibilityLabel?: string;\n decrementAccessibilityLabel?: string;\n testID?: string;\n}\n\nconst SIZES: Record<StepperSize, { height: number; icon: number; inputWidth: number }> = {\n sm: { height: 36, icon: 18, inputWidth: 48 },\n md: { height: 44, icon: 20, inputWidth: 56 },\n lg: { height: 52, icon: 22, inputWidth: 64 },\n};\n\nconst useStyles = createStyles((theme) => ({\n control: {\n flexDirection: 'row',\n alignItems: 'center',\n alignSelf: 'flex-start',\n borderWidth: 1,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.md,\n },\n controlBlock: { alignSelf: 'stretch' },\n btn: { height: '100%', justifyContent: 'center', alignItems: 'center' },\n input: {\n textAlign: 'center',\n height: '100%',\n color: theme.colors.text,\n borderLeftWidth: 1,\n borderRightWidth: 1,\n borderColor: theme.colors.border,\n },\n // El fondo distingue el campo de un valor de sólo lectura: es la única\n // señal de que ahí se puede tipear antes de tocarlo.\n editable: { backgroundColor: theme.colors.inputBackground },\n}));\n\n/** Deja sólo lo que puede formar parte del número permitido por el rango. */\nexport function sanitizeNumeric(text: string, { allowDecimals = false, allowNegative = false } = {}): string {\n const normalized = allowDecimals ? text.replace(',', '.') : text;\n const negative = allowNegative && normalized.trimStart().startsWith('-');\n const digits = normalized.replace(allowDecimals ? /[^0-9.]/g : /[^0-9]/g, '');\n const [whole = '', ...decimals] = digits.split('.');\n const body = decimals.length > 0 ? `${whole}.${decimals.join('')}` : whole;\n return negative ? `-${body}` : body;\n}\n\n/**\n * Stepper\n * Botón \"-\" + input numérico editable + botón \"+\". `size` controla sólo la\n * escala y `block` sólo el ancho, así que los dos ejes se combinan libremente.\n *\n * El componente no hace la aritmética de los botones —eso vive en\n * `onIncrement`/`onDecrement`—, pero sí limpia lo que se tipea y avisa cuándo\n * el valor llegó a un extremo del rango deshabilitando el botón que lo pasaría.\n */\nexport const Stepper = memo(\n ({\n value,\n onChangeText,\n onIncrement,\n onDecrement,\n size = 'md',\n block = false,\n editable = true,\n min,\n max,\n allowDecimals = false,\n style,\n incrementAccessibilityLabel = 'Increment',\n decrementAccessibilityLabel = 'Decrement',\n testID,\n }: StepperProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [focused, setFocused] = useState(false);\n\n const { height, icon, inputWidth } = SIZES[size];\n const fontSize =\n size === 'sm' ? theme.tokens.fontSize.md : size === 'md' ? theme.tokens.fontSize.lg : theme.tokens.fontSize.xl;\n\n const allowNegative = min !== undefined && min < 0;\n const numeric = Number(value);\n const isNumber = value.trim() !== '' && Number.isFinite(numeric);\n const atMin = isNumber && min !== undefined && numeric <= min;\n const atMax = isNumber && max !== undefined && numeric >= max;\n\n const handleChangeText = (text: string) => {\n onChangeText(sanitizeNumeric(text, { allowDecimals, allowNegative }));\n };\n\n // El recorte al rango espera al blur: hacerlo mientras se tipea impide\n // llegar a \"12\" cuando el mínimo es 5, porque el \"1\" ya se corregiría.\n const handleBlur = () => {\n setFocused(false);\n if (!isNumber) return;\n const clamped = Math.min(max ?? numeric, Math.max(min ?? numeric, numeric));\n if (clamped !== numeric) onChangeText(String(clamped));\n };\n\n const stepButton = (\n direction: 'decrement' | 'increment',\n onPress: () => void,\n disabled: boolean,\n label: string\n ) => (\n <TouchableOpacity\n onPress={onPress}\n disabled={disabled}\n style={[styles.btn, { width: height }]}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ disabled }}\n testID={testID && `${testID}-${direction}`}\n >\n <Icon\n name={direction === 'increment' ? 'plus' : 'minus'}\n size={icon}\n color={disabled ? colors.textLight : colors.primary}\n />\n </TouchableOpacity>\n );\n\n return (\n <View\n testID={testID}\n style={[\n styles.control,\n { height },\n block && styles.controlBlock,\n focused && { borderColor: colors.primary },\n style,\n ]}\n >\n {stepButton('decrement', onDecrement, atMin, decrementAccessibilityLabel)}\n <TextInput\n allowFontScaling={allowFontScaling}\n style={[\n styles.input,\n { fontSize },\n editable && styles.editable,\n focused && { borderColor: colors.primary },\n block ? { flex: 1 } : { width: inputWidth },\n ]}\n value={value}\n onChangeText={handleChangeText}\n onFocus={() => setFocused(true)}\n onBlur={handleBlur}\n editable={editable}\n keyboardType={allowDecimals ? 'decimal-pad' : 'number-pad'}\n selectTextOnFocus\n accessibilityLabel=\"Quantity\"\n />\n {stepButton('increment', onIncrement, atMax, incrementAccessibilityLabel)}\n </View>\n );\n }\n);\nStepper.displayName = 'Stepper';\n"
14
14
  }
15
15
  ],
16
16
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "molecules/Steps.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n label: string;\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n steps: StepItem[];\n /** Índice del paso actual (0-based). Los anteriores quedan 'completed'. */\n current: number;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /**\n * Qué muestra el círculo de un paso ya completado: un check, o su número.\n * @default 'check'\n */\n completedIndicator?: 'check' | 'number';\n /** Si se pasa, cada paso es tocable y reporta su índice. */\n onStepPress?: (index: number) => void;\n style?: StyleProp<ViewStyle>;\n}\n\nconst CIRCLE = 32;\n/** Ancho mínimo de una columna horizontal: por debajo, las etiquetas se parten. */\nconst STEP_MIN_WIDTH = 76;\n\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n // ScrollView trae flexGrow: 1 de fábrica y se comía todo el alto disponible\n // del contenedor, dejando los pasos pegados arriba de una caja enorme.\n hScrollView: { flexGrow: 0, flexShrink: 0 },\n hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },\n // `minWidth` es lo que evita que RN parta palabras a la mitad\n // (\"Almacé / n\"): antes que apretar la columna por debajo de una palabra\n // corriente, la fila se vuelve scrollable.\n hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },\n hLabels: { alignItems: 'center', gap: 2, paddingHorizontal: 4 },\n // El conector toma una fracción chica del ancho: compitiendo de igual a\n // igual con los pasos (flex: 1) cada etiqueta se quedaba con 1/(2n-1) del\n // total.\n hConnector: { height: 2, flex: 0.3, minWidth: 12, marginTop: CIRCLE / 2 - 1 },\n hLabel: { fontSize: theme.tokens.fontSize.sm },\n hDescription: { fontSize: theme.tokens.fontSize.xs },\n\n // vertical\n vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n vIndicatorColumn: { alignItems: 'center' },\n vConnector: { width: 2, flex: 1, minHeight: 20, marginVertical: 4 },\n vLabels: { flex: 1, gap: 2, paddingBottom: theme.tokens.spacing.lg },\n\n circle: {\n width: CIRCLE,\n height: CIRCLE,\n borderRadius: CIRCLE / 2,\n borderWidth: 2,\n alignItems: 'center',\n justifyContent: 'center',\n },\n circleNumber: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n labelCentered: { textAlign: 'center' },\n description: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n descriptionCentered: { textAlign: 'center' },\n}));\n\nfunction statusOf(index: number, current: number): StepStatus {\n if (index < current) return 'completed';\n if (index === current) return 'current';\n return 'pending';\n}\n\n/**\n * Steps\n * Indicador de progreso multi-paso. Base visual de `Wizard`, pero utilizable\n * suelto para mostrar el avance de un flujo que se navega de otra forma.\n */\nexport const Steps = memo(\n ({ steps, current, orientation = 'horizontal', completedIndicator = 'check', onStepPress, style }: StepsProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = orientation === 'vertical';\n\n const colorsFor = (status: StepStatus) => {\n if (status === 'completed') return { bg: colors.primary, border: colors.primary, fg: colors.onPrimary };\n if (status === 'current') return { bg: 'transparent', border: colors.primary, fg: colors.primaryText };\n return { bg: 'transparent', border: colors.border, fg: colors.textLight };\n };\n\n const renderCircle = (index: number, status: StepStatus) => {\n const c = colorsFor(status);\n const showCheck = status === 'completed' && completedIndicator === 'check';\n return (\n <View style={[styles.circle, { backgroundColor: c.bg, borderColor: c.border }]}>\n {showCheck ? (\n <Icon name=\"check\" size={18} color={c.fg} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={[styles.circleNumber, { color: c.fg }]}>\n {index + 1}\n </Text>\n )}\n </View>\n );\n };\n\n const renderLabels = (step: StepItem, status: StepStatus, centered: boolean) => (\n <View style={centered ? styles.hLabels : styles.vLabels}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.label,\n centered && styles.labelCentered,\n centered && styles.hLabel,\n status === 'pending' && { color: colors.textLight },\n ]}\n >\n {step.label}\n </Text>\n {!!step.description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}\n >\n {step.description}\n </Text>\n )}\n </View>\n );\n\n const stepAccessibility = (index: number, step: StepItem, status: StepStatus) => ({\n accessibilityRole: (onStepPress ? 'button' : 'text') as 'button' | 'text',\n accessibilityLabel: `Step ${index + 1}: ${step.label}`,\n accessibilityState: { selected: status === 'current', disabled: false },\n });\n\n if (isVertical) {\n return (\n <View style={[styles.columnContainer, style]}>\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n // Sólo tocable si hay onStepPress: pasarle onPress/activeOpacity\n // a un View plano no crashea, pero los deja como ruido silencioso.\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <Wrapper\n key={`${step.label}-${index}`}\n style={styles.vStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n <View style={styles.vIndicatorColumn}>\n {renderCircle(index, status)}\n {!isLast && (\n <View\n style={[\n styles.vConnector,\n { backgroundColor: index < current ? colors.primary : colors.border },\n ]}\n />\n )}\n </View>\n {renderLabels(step, status, false)}\n </Wrapper>\n );\n })}\n </View>\n );\n }\n\n return (\n <ScrollView\n testID=\"steps-row\"\n horizontal\n showsHorizontalScrollIndicator={false}\n // Con espacio de sobra los pasos se reparten la fila igual que antes\n // (flexGrow); cuando no entran, se desplaza en vez de apretar el texto.\n style={[styles.hScrollView, style]}\n contentContainerStyle={styles.hScroll}\n >\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <React.Fragment key={`${step.label}-${index}`}>\n <Wrapper\n style={styles.hStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n {renderCircle(index, status)}\n {renderLabels(step, status, true)}\n </Wrapper>\n {!isLast && (\n <View\n style={[styles.hConnector, { backgroundColor: index < current ? colors.primary : colors.border }]}\n />\n )}\n </React.Fragment>\n );\n })}\n </ScrollView>\n );\n }\n);\nSteps.displayName = 'Steps';\n"
12
+ "content": "import React, { memo } from 'react';\nimport { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n label: string;\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n steps: StepItem[];\n /** Index of the current step (0-based). Earlier steps become 'completed'. */\n current: number;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /**\n * What the circle of an already-completed step shows: a check, or its number.\n * @default 'check'\n */\n completedIndicator?: 'check' | 'number';\n /** If provided, each step is tappable and reports its index. */\n onStepPress?: (index: number) => void;\n style?: StyleProp<ViewStyle>;\n}\n\nconst CIRCLE = 32;\n/** Ancho mínimo de una columna horizontal: por debajo, las etiquetas se parten. */\nconst STEP_MIN_WIDTH = 76;\n\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n // ScrollView trae flexGrow: 1 de fábrica y se comía todo el alto disponible\n // del contenedor, dejando los pasos pegados arriba de una caja enorme.\n hScrollView: { flexGrow: 0, flexShrink: 0 },\n hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },\n // `minWidth` es lo que evita que RN parta palabras a la mitad\n // (\"Almacé / n\"): antes que apretar la columna por debajo de una palabra\n // corriente, la fila se vuelve scrollable.\n hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },\n hLabels: { alignItems: 'center', gap: 2, paddingHorizontal: 4 },\n // El conector toma una fracción chica del ancho: compitiendo de igual a\n // igual con los pasos (flex: 1) cada etiqueta se quedaba con 1/(2n-1) del\n // total.\n hConnector: { height: 2, flex: 0.3, minWidth: 12, marginTop: CIRCLE / 2 - 1 },\n hLabel: { fontSize: theme.tokens.fontSize.sm },\n hDescription: { fontSize: theme.tokens.fontSize.xs },\n\n // vertical\n vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n vIndicatorColumn: { alignItems: 'center' },\n vConnector: { width: 2, flex: 1, minHeight: 20, marginVertical: 4 },\n vLabels: { flex: 1, gap: 2, paddingBottom: theme.tokens.spacing.lg },\n\n circle: {\n width: CIRCLE,\n height: CIRCLE,\n borderRadius: CIRCLE / 2,\n borderWidth: 2,\n alignItems: 'center',\n justifyContent: 'center',\n },\n circleNumber: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n labelCentered: { textAlign: 'center' },\n description: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n descriptionCentered: { textAlign: 'center' },\n}));\n\nfunction statusOf(index: number, current: number): StepStatus {\n if (index < current) return 'completed';\n if (index === current) return 'current';\n return 'pending';\n}\n\n/**\n * Steps\n * Indicador de progreso multi-paso. Base visual de `Wizard`, pero utilizable\n * suelto para mostrar el avance de un flujo que se navega de otra forma.\n */\nexport const Steps = memo(\n ({ steps, current, orientation = 'horizontal', completedIndicator = 'check', onStepPress, style }: StepsProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = orientation === 'vertical';\n\n const colorsFor = (status: StepStatus) => {\n if (status === 'completed') return { bg: colors.primary, border: colors.primary, fg: colors.onPrimary };\n if (status === 'current') return { bg: 'transparent', border: colors.primary, fg: colors.primaryText };\n return { bg: 'transparent', border: colors.border, fg: colors.textLight };\n };\n\n const renderCircle = (index: number, status: StepStatus) => {\n const c = colorsFor(status);\n const showCheck = status === 'completed' && completedIndicator === 'check';\n return (\n <View style={[styles.circle, { backgroundColor: c.bg, borderColor: c.border }]}>\n {showCheck ? (\n <Icon name=\"check\" size={18} color={c.fg} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={[styles.circleNumber, { color: c.fg }]}>\n {index + 1}\n </Text>\n )}\n </View>\n );\n };\n\n const renderLabels = (step: StepItem, status: StepStatus, centered: boolean) => (\n <View style={centered ? styles.hLabels : styles.vLabels}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.label,\n centered && styles.labelCentered,\n centered && styles.hLabel,\n status === 'pending' && { color: colors.textLight },\n ]}\n >\n {step.label}\n </Text>\n {!!step.description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}\n >\n {step.description}\n </Text>\n )}\n </View>\n );\n\n const stepAccessibility = (index: number, step: StepItem, status: StepStatus) => ({\n accessibilityRole: (onStepPress ? 'button' : 'text') as 'button' | 'text',\n accessibilityLabel: `Step ${index + 1}: ${step.label}`,\n accessibilityState: { selected: status === 'current', disabled: false },\n });\n\n if (isVertical) {\n return (\n <View style={[styles.columnContainer, style]}>\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n // Sólo tocable si hay onStepPress: pasarle onPress/activeOpacity\n // a un View plano no crashea, pero los deja como ruido silencioso.\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <Wrapper\n key={`${step.label}-${index}`}\n style={styles.vStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n <View style={styles.vIndicatorColumn}>\n {renderCircle(index, status)}\n {!isLast && (\n <View\n style={[\n styles.vConnector,\n { backgroundColor: index < current ? colors.primary : colors.border },\n ]}\n />\n )}\n </View>\n {renderLabels(step, status, false)}\n </Wrapper>\n );\n })}\n </View>\n );\n }\n\n return (\n <ScrollView\n testID=\"steps-row\"\n horizontal\n showsHorizontalScrollIndicator={false}\n // Con espacio de sobra los pasos se reparten la fila igual que antes\n // (flexGrow); cuando no entran, se desplaza en vez de apretar el texto.\n style={[styles.hScrollView, style]}\n contentContainerStyle={styles.hScroll}\n >\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <React.Fragment key={`${step.label}-${index}`}>\n <Wrapper\n style={styles.hStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n {renderCircle(index, status)}\n {renderLabels(step, status, true)}\n </Wrapper>\n {!isLast && (\n <View\n style={[styles.hConnector, { backgroundColor: index < current ? colors.primary : colors.border }]}\n />\n )}\n </React.Fragment>\n );\n })}\n </ScrollView>\n );\n }\n);\nSteps.displayName = 'Steps';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "molecules/SymmetricGrid.tsx",
12
- "content": "import React, { useMemo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface SymmetricGridProps<T> {\n data: T[];\n renderItem: (item: T, index: number) => React.ReactElement;\n keyExtractor: (item: T, index: number) => string;\n /** Columnas por fila. @default 2 */\n columns?: number;\n /** Separación entre celdas, en ambos ejes. @default theme.tokens.spacing.md */\n gap?: number;\n /**\n * Última fila incompleta: centrada (simétrica) en vez de pegada a la\n * izquierda con un hueco a la derecha. @default true\n */\n balanceLastRow?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n grid: { gap: theme.tokens.spacing.md },\n row: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n rowBalanced: { justifyContent: 'center' },\n rowFull: { justifyContent: 'flex-start' },\n cell: { flexGrow: 0, flexShrink: 0 },\n}));\n\nfunction chunk<T>(data: T[], size: number): T[][] {\n const rows: T[][] = [];\n for (let i = 0; i < data.length; i += size) rows.push(data.slice(i, i + size));\n return rows;\n}\n\n/**\n * SymmetricGrid\n * Ley de Prägnanz (buena forma): agrupa en filas parejas de N columnas.\n * La última fila, cuando queda incompleta, se centra por defecto en vez de\n * quedar pegada a un borde — eso es lo que la lee como \"fila corta\" y no\n * como un layout roto con un hueco a un lado.\n */\nexport function SymmetricGrid<T>({\n data,\n renderItem,\n keyExtractor,\n columns = 2,\n gap,\n balanceLastRow = true,\n style,\n}: SymmetricGridProps<T>) {\n const styles = useStyles();\n const rows = useMemo(() => chunk(data, columns), [data, columns]);\n const cellWidth = `${100 / columns}%` as const;\n\n return (\n <View style={[styles.grid, gap !== undefined && { gap }, style]}>\n {rows.map((row, rowIndex) => {\n const isLastRow = rowIndex === rows.length - 1;\n const isIncomplete = row.length < columns;\n const centered = isLastRow && isIncomplete && balanceLastRow;\n\n return (\n <View\n // `columns` en la key: si cambia, las filas no reusan nodos DOM\n // de una fila armada con otra cantidad de columnas.\n key={`row-${columns}-${rowIndex}`}\n style={[styles.row, centered ? styles.rowBalanced : styles.rowFull, gap !== undefined && { gap }]}\n >\n {row.map((item, colIndex) => {\n const index = rowIndex * columns + colIndex;\n return (\n <View key={keyExtractor(item, index)} style={[styles.cell, { width: cellWidth }]}>\n {renderItem(item, index)}\n </View>\n );\n })}\n </View>\n );\n })}\n </View>\n );\n}\n"
12
+ "content": "import React, { useMemo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface SymmetricGridProps<T> {\n data: T[];\n renderItem: (item: T, index: number) => React.ReactElement;\n keyExtractor: (item: T, index: number) => string;\n /** Columns per row. @default 2 */\n columns?: number;\n /** Spacing between cells, on both axes. @default theme.tokens.spacing.md */\n gap?: number;\n /**\n * Incomplete last row: centered (symmetric) instead of stuck to the\n * left with a gap on the right. @default true\n */\n balanceLastRow?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n grid: { gap: theme.tokens.spacing.md },\n row: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n rowBalanced: { justifyContent: 'center' },\n rowFull: { justifyContent: 'flex-start' },\n cell: { flexGrow: 0, flexShrink: 0 },\n}));\n\nfunction chunk<T>(data: T[], size: number): T[][] {\n const rows: T[][] = [];\n for (let i = 0; i < data.length; i += size) rows.push(data.slice(i, i + size));\n return rows;\n}\n\n/**\n * SymmetricGrid\n * Ley de Prägnanz (buena forma): agrupa en filas parejas de N columnas.\n * La última fila, cuando queda incompleta, se centra por defecto en vez de\n * quedar pegada a un borde — eso es lo que la lee como \"fila corta\" y no\n * como un layout roto con un hueco a un lado.\n */\nexport function SymmetricGrid<T>({\n data,\n renderItem,\n keyExtractor,\n columns = 2,\n gap,\n balanceLastRow = true,\n style,\n}: SymmetricGridProps<T>) {\n const styles = useStyles();\n const rows = useMemo(() => chunk(data, columns), [data, columns]);\n const cellWidth = `${100 / columns}%` as const;\n\n return (\n <View style={[styles.grid, gap !== undefined && { gap }, style]}>\n {rows.map((row, rowIndex) => {\n const isLastRow = rowIndex === rows.length - 1;\n const isIncomplete = row.length < columns;\n const centered = isLastRow && isIncomplete && balanceLastRow;\n\n return (\n <View\n // `columns` en la key: si cambia, las filas no reusan nodos DOM\n // de una fila armada con otra cantidad de columnas.\n key={`row-${columns}-${rowIndex}`}\n style={[styles.row, centered ? styles.rowBalanced : styles.rowFull, gap !== undefined && { gap }]}\n >\n {row.map((item, colIndex) => {\n const index = rowIndex * columns + colIndex;\n return (\n <View key={keyExtractor(item, index)} style={[styles.cell, { width: cellWidth }]}>\n {renderItem(item, index)}\n </View>\n );\n })}\n </View>\n );\n })}\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -13,7 +13,7 @@
13
13
  "files": [
14
14
  {
15
15
  "path": "organisms/ToastProvider.tsx",
16
- "content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { View } from 'react-native';\nimport { AnimatedToast, Toast, type ToastVariant } from './Toast';\nimport { useInsets } from '../theme/UIProvider';\nimport { createStyles } from '../theme/createStyles';\nimport { createImperativeBridge } from './imperativeBridge';\n\nexport interface ToastOptions {\n title: string;\n message?: string;\n variant?: ToastVariant;\n /** ms antes de auto-descartar. 0 = no se descarta solo. @default 4000 */\n duration?: number;\n onPress?: () => void;\n hideCloseButton?: boolean;\n}\n\ninterface ToastEntry extends ToastOptions {\n id: string;\n}\n\ninterface ToastContextValue {\n /** Muestra un toast y devuelve su id (por si querés cerrarlo antes de tiempo). */\n show: (options: ToastOptions) => string;\n hide: (id: string) => void;\n hideAll: () => void;\n}\n\nconst ToastContext = createContext<ToastContextValue | undefined>(undefined);\nconst bridge = createImperativeBridge<ToastContextValue>('ToastProvider');\n\n/**\n * Muestra un toast desde fuera del árbol de React —un servicio, un\n * interceptor— sin pasar por el hook. Devuelve el id, o '' si no hay\n * <ToastProvider> montado.\n */\nexport function showToast(options: ToastOptions): string {\n return bridge.get()?.show(options) ?? '';\n}\n\nexport function hideToast(id: string): void {\n bridge.get()?.hide(id);\n}\n\nexport function hideAllToasts(): void {\n bridge.get()?.hideAll();\n}\n\nconst useStyles = createStyles((theme) => ({\n host: { position: 'absolute', left: 0, right: 0, paddingHorizontal: theme.tokens.spacing.lg, gap: theme.tokens.spacing.sm },\n}));\n\nexport interface ToastProviderProps {\n children: React.ReactNode;\n /** @default 'top' */\n position?: 'top' | 'bottom';\n /** Máximo de toasts simultáneos; los más viejos se descartan. @default 3 */\n maxVisible?: number;\n /** Duración por defecto en ms. @default 4000 */\n defaultDuration?: number;\n}\n\n/**\n * ToastProvider\n * Monta la capa de toasts sobre el resto de la app. Colocar una vez cerca de\n * la raíz, dentro de UIProvider. Los timers de auto-dismiss se limpian al\n * desmontar y al cerrar cada toast, así que no quedan callbacks colgados.\n */\nexport function ToastProvider({\n children,\n position = 'top',\n maxVisible = 3,\n defaultDuration = 4000,\n}: ToastProviderProps) {\n const [toasts, setToasts] = useState<ToastEntry[]>([]);\n const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n const counter = useRef(0);\n const insets = useInsets();\n const styles = useStyles();\n\n const clearTimer = useCallback((id: string) => {\n const t = timers.current.get(id);\n if (t) {\n clearTimeout(t);\n timers.current.delete(id);\n }\n }, []);\n\n const hide = useCallback(\n (id: string) => {\n clearTimer(id);\n setToasts((prev) => prev.filter((t) => t.id !== id));\n },\n [clearTimer]\n );\n\n const hideAll = useCallback(() => {\n timers.current.forEach((t) => clearTimeout(t));\n timers.current.clear();\n setToasts([]);\n }, []);\n\n const show = useCallback(\n (options: ToastOptions) => {\n counter.current += 1;\n const id = `toast-${counter.current}`;\n const duration = options.duration ?? defaultDuration;\n\n setToasts((prev) => {\n const next = [...prev, { ...options, id }];\n // Se descartan los más viejos, no los recién llegados: un toast nuevo\n // siempre se ve.\n const overflow = next.length - maxVisible;\n if (overflow > 0) {\n next.slice(0, overflow).forEach((t) => clearTimer(t.id));\n return next.slice(overflow);\n }\n return next;\n });\n\n if (duration > 0) {\n timers.current.set(\n id,\n setTimeout(() => hide(id), duration)\n );\n }\n return id;\n },\n [defaultDuration, maxVisible, hide, clearTimer]\n );\n\n // Al desmontar el provider no debe quedar ningún setTimeout apuntando a un\n // setState de un árbol ya desmontado.\n useEffect(() => {\n const pending = timers.current;\n return () => {\n pending.forEach((t) => clearTimeout(t));\n pending.clear();\n };\n }, []);\n\n const value = useMemo<ToastContextValue>(() => ({ show, hide, hideAll }), [show, hide, hideAll]);\n\n useEffect(() => {\n bridge.set(value);\n return () => bridge.set(null);\n }, [value]);\n\n const hostPosition =\n position === 'top'\n ? { top: insets.top + 8 }\n : { bottom: insets.bottom + 8, flexDirection: 'column-reverse' as const };\n\n return (\n <ToastContext.Provider value={value}>\n {children}\n {toasts.length > 0 && (\n <View style={[styles.host, hostPosition]} pointerEvents=\"box-none\" testID=\"toast-host\">\n {toasts.map((t) => (\n <AnimatedToast key={t.id}>\n <Toast\n title={t.title}\n message={t.message}\n variant={t.variant}\n hideCloseButton={t.hideCloseButton}\n onDismiss={() => hide(t.id)}\n onPress={\n t.onPress\n ? () => {\n t.onPress?.();\n hide(t.id);\n }\n : undefined\n }\n />\n </AnimatedToast>\n ))}\n </View>\n )}\n </ToastContext.Provider>\n );\n}\n\nexport function useToast(): ToastContextValue {\n const ctx = useContext(ToastContext);\n if (!ctx) {\n throw new Error('orn-ui: useToast must be used within a <ToastProvider>.');\n }\n return ctx;\n}\n"
16
+ "content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { View } from 'react-native';\nimport { AnimatedToast, Toast, type ToastVariant } from './Toast';\nimport { useInsets } from '../theme/UIProvider';\nimport { createStyles } from '../theme/createStyles';\nimport { createImperativeBridge } from './imperativeBridge';\n\nexport interface ToastOptions {\n title: string;\n message?: string;\n variant?: ToastVariant;\n /** ms antes de auto-descartar. 0 = no se descarta solo. @default 4000 */\n duration?: number;\n onPress?: () => void;\n hideCloseButton?: boolean;\n}\n\ninterface ToastEntry extends ToastOptions {\n id: string;\n}\n\ninterface ToastContextValue {\n /** Muestra un toast y devuelve su id (por si querés cerrarlo antes de tiempo). */\n show: (options: ToastOptions) => string;\n hide: (id: string) => void;\n hideAll: () => void;\n}\n\nconst ToastContext = createContext<ToastContextValue | undefined>(undefined);\nconst bridge = createImperativeBridge<ToastContextValue>('ToastProvider');\n\n/**\n * Muestra un toast desde fuera del árbol de React —un servicio, un\n * interceptor— sin pasar por el hook. Devuelve el id, o '' si no hay\n * <ToastProvider> montado.\n */\nexport function showToast(options: ToastOptions): string {\n return bridge.get()?.show(options) ?? '';\n}\n\nexport function hideToast(id: string): void {\n bridge.get()?.hide(id);\n}\n\nexport function hideAllToasts(): void {\n bridge.get()?.hideAll();\n}\n\nconst useStyles = createStyles((theme) => ({\n host: { position: 'absolute', left: 0, right: 0, paddingHorizontal: theme.tokens.spacing.lg, gap: theme.tokens.spacing.sm },\n}));\n\nexport interface ToastProviderProps {\n children: React.ReactNode;\n /** @default 'top' */\n position?: 'top' | 'bottom';\n /** Maximum simultaneous toasts; the oldest ones are dropped. @default 3 */\n maxVisible?: number;\n /** Default duration in ms. @default 4000 */\n defaultDuration?: number;\n}\n\n/**\n * ToastProvider\n * Monta la capa de toasts sobre el resto de la app. Colocar una vez cerca de\n * la raíz, dentro de UIProvider. Los timers de auto-dismiss se limpian al\n * desmontar y al cerrar cada toast, así que no quedan callbacks colgados.\n */\nexport function ToastProvider({\n children,\n position = 'top',\n maxVisible = 3,\n defaultDuration = 4000,\n}: ToastProviderProps) {\n const [toasts, setToasts] = useState<ToastEntry[]>([]);\n const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n const counter = useRef(0);\n const insets = useInsets();\n const styles = useStyles();\n\n const clearTimer = useCallback((id: string) => {\n const t = timers.current.get(id);\n if (t) {\n clearTimeout(t);\n timers.current.delete(id);\n }\n }, []);\n\n const hide = useCallback(\n (id: string) => {\n clearTimer(id);\n setToasts((prev) => prev.filter((t) => t.id !== id));\n },\n [clearTimer]\n );\n\n const hideAll = useCallback(() => {\n timers.current.forEach((t) => clearTimeout(t));\n timers.current.clear();\n setToasts([]);\n }, []);\n\n const show = useCallback(\n (options: ToastOptions) => {\n counter.current += 1;\n const id = `toast-${counter.current}`;\n const duration = options.duration ?? defaultDuration;\n\n setToasts((prev) => {\n const next = [...prev, { ...options, id }];\n // Se descartan los más viejos, no los recién llegados: un toast nuevo\n // siempre se ve.\n const overflow = next.length - maxVisible;\n if (overflow > 0) {\n next.slice(0, overflow).forEach((t) => clearTimer(t.id));\n return next.slice(overflow);\n }\n return next;\n });\n\n if (duration > 0) {\n timers.current.set(\n id,\n setTimeout(() => hide(id), duration)\n );\n }\n return id;\n },\n [defaultDuration, maxVisible, hide, clearTimer]\n );\n\n // Al desmontar el provider no debe quedar ningún setTimeout apuntando a un\n // setState de un árbol ya desmontado.\n useEffect(() => {\n const pending = timers.current;\n return () => {\n pending.forEach((t) => clearTimeout(t));\n pending.clear();\n };\n }, []);\n\n const value = useMemo<ToastContextValue>(() => ({ show, hide, hideAll }), [show, hide, hideAll]);\n\n useEffect(() => {\n bridge.set(value);\n return () => bridge.set(null);\n }, [value]);\n\n const hostPosition =\n position === 'top'\n ? { top: insets.top + 8 }\n : { bottom: insets.bottom + 8, flexDirection: 'column-reverse' as const };\n\n return (\n <ToastContext.Provider value={value}>\n {children}\n {toasts.length > 0 && (\n <View style={[styles.host, hostPosition]} pointerEvents=\"box-none\" testID=\"toast-host\">\n {toasts.map((t) => (\n <AnimatedToast key={t.id}>\n <Toast\n title={t.title}\n message={t.message}\n variant={t.variant}\n hideCloseButton={t.hideCloseButton}\n onDismiss={() => hide(t.id)}\n onPress={\n t.onPress\n ? () => {\n t.onPress?.();\n hide(t.id);\n }\n : undefined\n }\n />\n </AnimatedToast>\n ))}\n </View>\n )}\n </ToastContext.Provider>\n );\n}\n\nexport function useToast(): ToastContextValue {\n const ctx = useContext(ToastContext);\n if (!ctx) {\n throw new Error('orn-ui: useToast must be used within a <ToastProvider>.');\n }\n return ctx;\n}\n"
17
17
  },
18
18
  {
19
19
  "path": "organisms/imperativeBridge.ts",
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Toast.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Transition } from '../atoms/Transition';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport type ToastVariant = 'success' | 'error' | 'warning' | 'info';\n\nexport interface ToastProps {\n title: string;\n message?: string;\n variant?: ToastVariant;\n /** Se dispara al tocar el toast (además de cerrarlo). */\n onPress?: () => void;\n onDismiss?: () => void;\n /** Oculta la X de cerrar. El toast igual se auto-descarta por tiempo. */\n hideCloseButton?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst ICON_BY_VARIANT: Record<ToastVariant, IconName> = {\n success: 'check',\n error: 'alert',\n warning: 'warning',\n info: 'info',\n};\n\nconst useStyles = createStyles((theme) => ({\n container: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n ...theme.tokens.shadow.lg,\n },\n accent: {\n position: 'absolute',\n left: 0,\n top: 0,\n bottom: 0,\n width: 4,\n borderTopLeftRadius: theme.tokens.radius.lg,\n borderBottomLeftRadius: theme.tokens.radius.lg,\n },\n text: { flex: 1, gap: 2 },\n title: {\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n message: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight, lineHeight: theme.tokens.lineHeight.sm },\n}));\n\n/**\n * Toast\n * Notificación efímera. Normalmente no se usa directo: `ToastProvider` +\n * `useToast()` la montan y la descartan por vos. Se expone igual por si\n * querés colocarla vos mismo dentro de una pantalla concreta.\n */\nexport const Toast = memo(\n ({ title, message, variant = 'info', onPress, onDismiss, hideCloseButton, style, testID }: ToastProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n // La barra lateral es relleno (acento pleno); el ícono es contenido\n // sobre la superficie del toast y necesita el rol `*Text`.\n const barColor: Record<ToastVariant, string> = {\n success: colors.success,\n error: colors.error,\n warning: colors.warning,\n info: colors.secondary,\n };\n const iconColor: Record<ToastVariant, string> = {\n success: colors.successText,\n error: colors.errorText,\n warning: colors.warningText,\n info: colors.secondaryText,\n };\n\n const body = (\n <View style={[styles.container, style]} testID={testID} accessibilityRole=\"alert\">\n <View style={[styles.accent, { backgroundColor: barColor[variant] }]} />\n <Icon name={ICON_BY_VARIANT[variant]} size={22} color={iconColor[variant]} />\n <View style={styles.text}>\n <Text allowFontScaling={allowFontScaling} style={styles.title}>\n {title}\n </Text>\n {!!message && (\n <Text allowFontScaling={allowFontScaling} style={styles.message}>\n {message}\n </Text>\n )}\n </View>\n {!hideCloseButton && !!onDismiss && (\n <Pressable onPress={onDismiss} accessibilityRole=\"button\" accessibilityLabel=\"Dismiss\" hitSlop={8}>\n <Icon name=\"close\" size={18} color={colors.textLight} />\n </Pressable>\n )}\n </View>\n );\n\n if (!onPress) return body;\n\n return (\n <Pressable onPress={onPress} accessibilityRole=\"button\" accessibilityLabel={title}>\n {body}\n </Pressable>\n );\n }\n);\nToast.displayName = 'Toast';\n\n/** Envuelve un Toast con su animación de entrada/salida. Uso interno del provider. */\nexport const AnimatedToast = memo(\n ({ children, onHeight }: { children: React.ReactNode; onHeight?: (h: number) => void }) => (\n <Transition preset={['fade', 'slide-down']} distance={16} spring>\n <View onLayout={(e) => onHeight?.(e.nativeEvent.layout.height)}>{children}</View>\n </Transition>\n )\n);\nAnimatedToast.displayName = 'AnimatedToast';\n"
12
+ "content": "import React, { memo } from 'react';\nimport { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Transition } from '../atoms/Transition';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport type ToastVariant = 'success' | 'error' | 'warning' | 'info';\n\nexport interface ToastProps {\n title: string;\n message?: string;\n variant?: ToastVariant;\n /** Fires when the toast is tapped (in addition to closing it). */\n onPress?: () => void;\n onDismiss?: () => void;\n /** Hides the close X. The toast still auto-dismisses by timeout. */\n hideCloseButton?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst ICON_BY_VARIANT: Record<ToastVariant, IconName> = {\n success: 'check',\n error: 'alert',\n warning: 'warning',\n info: 'info',\n};\n\nconst useStyles = createStyles((theme) => ({\n container: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n ...theme.tokens.shadow.lg,\n },\n accent: {\n position: 'absolute',\n left: 0,\n top: 0,\n bottom: 0,\n width: 4,\n borderTopLeftRadius: theme.tokens.radius.lg,\n borderBottomLeftRadius: theme.tokens.radius.lg,\n },\n text: { flex: 1, gap: 2 },\n title: {\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n message: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight, lineHeight: theme.tokens.lineHeight.sm },\n}));\n\n/**\n * Toast\n * Notificación efímera. Normalmente no se usa directo: `ToastProvider` +\n * `useToast()` la montan y la descartan por vos. Se expone igual por si\n * querés colocarla vos mismo dentro de una pantalla concreta.\n */\nexport const Toast = memo(\n ({ title, message, variant = 'info', onPress, onDismiss, hideCloseButton, style, testID }: ToastProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n // La barra lateral es relleno (acento pleno); el ícono es contenido\n // sobre la superficie del toast y necesita el rol `*Text`.\n const barColor: Record<ToastVariant, string> = {\n success: colors.success,\n error: colors.error,\n warning: colors.warning,\n info: colors.secondary,\n };\n const iconColor: Record<ToastVariant, string> = {\n success: colors.successText,\n error: colors.errorText,\n warning: colors.warningText,\n info: colors.secondaryText,\n };\n\n const body = (\n <View style={[styles.container, style]} testID={testID} accessibilityRole=\"alert\">\n <View style={[styles.accent, { backgroundColor: barColor[variant] }]} />\n <Icon name={ICON_BY_VARIANT[variant]} size={22} color={iconColor[variant]} />\n <View style={styles.text}>\n <Text allowFontScaling={allowFontScaling} style={styles.title}>\n {title}\n </Text>\n {!!message && (\n <Text allowFontScaling={allowFontScaling} style={styles.message}>\n {message}\n </Text>\n )}\n </View>\n {!hideCloseButton && !!onDismiss && (\n <Pressable onPress={onDismiss} accessibilityRole=\"button\" accessibilityLabel=\"Dismiss\" hitSlop={8}>\n <Icon name=\"close\" size={18} color={colors.textLight} />\n </Pressable>\n )}\n </View>\n );\n\n if (!onPress) return body;\n\n return (\n <Pressable onPress={onPress} accessibilityRole=\"button\" accessibilityLabel={title}>\n {body}\n </Pressable>\n );\n }\n);\nToast.displayName = 'Toast';\n\n/** Envuelve un Toast con su animación de entrada/salida. Uso interno del provider. */\nexport const AnimatedToast = memo(\n ({ children, onHeight }: { children: React.ReactNode; onHeight?: (h: number) => void }) => (\n <Transition preset={['fade', 'slide-down']} distance={16} spring>\n <View onLayout={(e) => onHeight?.(e.nativeEvent.layout.height)}>{children}</View>\n </Transition>\n )\n);\nAnimatedToast.displayName = 'AnimatedToast';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "path": "atoms/Transition.tsx",
13
- "content": "import React, { memo, useEffect, useMemo, useRef, useState } from 'react';\nimport { AccessibilityInfo, Animated, Easing, type StyleProp, type ViewStyle } from 'react-native';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type TransitionPreset =\n | 'fade'\n | 'scale'\n | 'pop'\n | 'slide-up'\n | 'slide-down'\n | 'slide-left'\n | 'slide-right';\n\nexport interface TransitionProps {\n children: React.ReactNode;\n /** Entra en true, sale (y desmonta) en false. @default true */\n visible?: boolean;\n /** Uno o varios: 'fade scale' combina opacidad y escala. @default 'fade' */\n preset?: TransitionPreset | TransitionPreset[];\n /** ms del timing. @default tokens.duration.base */\n duration?: number;\n /** ms antes de arrancar. Un índice por delay escalona una lista. @default 0 */\n delay?: number;\n /** Píxeles que recorren los presets de slide. @default 24 */\n distance?: number;\n /** Spring en vez de timing (implícito en 'pop'). */\n spring?: boolean;\n /** Anima también el primer render. En false arranca ya presente. @default true */\n appear?: boolean;\n /** Deja el nodo montado al salir, sólo lo esconde. @default false */\n keepMounted?: boolean;\n /**\n * Progreso 0→1 manejado desde afuera. Con esto el Transition sólo interpola:\n * quien lo pasa decide cuándo y cómo avanza (un PanResponder, por ejemplo).\n */\n value?: Animated.Value;\n onEntered?: () => void;\n onExited?: () => void;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst SCALE_FROM: Record<'scale' | 'pop', number> = { scale: 0.92, pop: 0.8 };\n\n/**\n * Sigue el ajuste de \"reducir movimiento\" del sistema: con él activo las\n * animaciones saltan al estado final en vez de desactivarse, así que los\n * callbacks y el desmontaje siguen ocurriendo igual.\n */\nexport function useReduceMotion(): boolean {\n const [reduceMotion, setReduceMotion] = useState(false);\n\n useEffect(() => {\n let alive = true;\n // Sólo se actualiza el estado si el ajuste está activo: el valor inicial ya\n // es `false` y un setState de false a false costaría un render por montaje.\n AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {\n if (alive && enabled) setReduceMotion(true);\n });\n const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);\n return () => {\n alive = false;\n sub.remove();\n };\n }, []);\n\n return reduceMotion;\n}\n\n/**\n * Transition\n * Envuelve cualquier árbol y lo anima al entrar y al salir. Todos los presets\n * se reducen a `opacity` y `transform` sobre un único Animated.Value, que es\n * lo que `useNativeDriver` sabe mandar al hilo nativo: la animación corre ahí\n * y el hilo de JS no participa de ningún frame — por eso no hay presets de\n * alto, ancho o color, que obligarían a volver a JS en cada frame.\n *\n * El nodo se mantiene montado hasta que termina la animación de salida, y el\n * comportamiento es idéntico en iOS y Android: no hay ninguna rama por\n * plataforma.\n */\nexport const Transition = memo(\n ({\n children,\n visible = true,\n preset = 'fade',\n duration,\n delay = 0,\n distance = 24,\n spring,\n appear = true,\n keepMounted = false,\n value,\n onEntered,\n onExited,\n style,\n testID,\n }: TransitionProps) => {\n const theme = useTheme();\n const reduceMotion = useReduceMotion();\n const internal = useRef(new Animated.Value(visible && !appear ? 1 : 0)).current;\n const progress = value ?? internal;\n const driven = value === undefined;\n\n const [mounted, setMounted] = useState(visible || keepMounted);\n\n // Los callbacks viven en un ref: pasados inline cambian de identidad en\n // cada render y, como dependencias del efecto, reiniciarían la animación.\n const callbacks = useRef({ onEntered, onExited });\n useEffect(() => {\n callbacks.current = { onEntered, onExited };\n });\n\n const presetKey = Array.isArray(preset) ? preset.join(' ') : preset;\n const presets = useMemo(() => presetKey.split(' ') as TransitionPreset[], [presetKey]);\n\n useEffect(() => {\n if (visible) setMounted(true);\n }, [visible]);\n\n useEffect(() => {\n if (!driven) return;\n const toValue = visible ? 1 : 0;\n const useSpring = spring ?? presets.includes('pop');\n\n const animation = reduceMotion\n ? Animated.timing(progress, { toValue, duration: 0, delay, useNativeDriver: true })\n : useSpring\n ? Animated.spring(progress, { toValue, delay, friction: 9, tension: 90, useNativeDriver: true })\n : Animated.timing(progress, {\n toValue,\n delay,\n duration: duration ?? theme.tokens.duration.base,\n easing: visible ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic),\n useNativeDriver: true,\n });\n\n animation.start(({ finished }) => {\n if (!finished) return;\n if (visible) {\n callbacks.current.onEntered?.();\n return;\n }\n callbacks.current.onExited?.();\n if (!keepMounted) setMounted(false);\n });\n\n return () => animation.stop();\n }, [visible, driven, reduceMotion, spring, delay, duration, keepMounted, presets, progress, theme]);\n\n const animatedStyle = useMemo(() => {\n type Interpolated = Animated.AnimatedInterpolation<number>;\n const transform: Array<{ scale: Interpolated } | { translateX: Interpolated } | { translateY: Interpolated }> = [];\n let opacity: Animated.Value | undefined;\n\n for (const item of presets) {\n if (item === 'fade') {\n opacity = progress;\n continue;\n }\n if (item === 'scale' || item === 'pop') {\n transform.push({\n scale: progress.interpolate({ inputRange: [0, 1], outputRange: [SCALE_FROM[item], 1] }),\n });\n continue;\n }\n const vertical = item === 'slide-up' || item === 'slide-down';\n const from = item === 'slide-up' || item === 'slide-left' ? distance : -distance;\n const offset = progress.interpolate({ inputRange: [0, 1], outputRange: [from, 0] });\n transform.push(vertical ? { translateY: offset } : { translateX: offset });\n }\n\n return { opacity, transform };\n }, [presets, progress, distance]);\n\n if (!mounted) return null;\n\n return (\n <Animated.View\n testID={testID}\n pointerEvents={visible ? undefined : 'none'}\n style={[style, animatedStyle]}\n >\n {children}\n </Animated.View>\n );\n }\n);\nTransition.displayName = 'Transition';\n"
13
+ "content": "import React, { memo, useEffect, useMemo, useRef, useState } from 'react';\nimport { AccessibilityInfo, Animated, Easing, type StyleProp, type ViewStyle } from 'react-native';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type TransitionPreset =\n | 'fade'\n | 'scale'\n | 'pop'\n | 'slide-up'\n | 'slide-down'\n | 'slide-left'\n | 'slide-right';\n\nexport interface TransitionProps {\n children: React.ReactNode;\n /** Enters when true, exits (and unmounts) when false. @default true */\n visible?: boolean;\n /** One or more: 'fade scale' combines opacity and scale. @default 'fade' */\n preset?: TransitionPreset | TransitionPreset[];\n /** Timing duration in ms. @default tokens.duration.base */\n duration?: number;\n /** ms before starting. A per-index delay staggers a list. @default 0 */\n delay?: number;\n /** Pixels traveled by the slide presets. @default 24 */\n distance?: number;\n /** Spring instead of timing (implicit in 'pop'). */\n spring?: boolean;\n /** Also animates the first render. When false it starts already present. @default true */\n appear?: boolean;\n /** Keeps the node mounted on exit, only hides it. @default false */\n keepMounted?: boolean;\n /**\n * 0→1 progress driven from outside. With this, Transition only interpolates:\n * whoever passes it decides when and how it advances (a PanResponder, for example).\n */\n value?: Animated.Value;\n onEntered?: () => void;\n onExited?: () => void;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst SCALE_FROM: Record<'scale' | 'pop', number> = { scale: 0.92, pop: 0.8 };\n\n/**\n * Sigue el ajuste de \"reducir movimiento\" del sistema: con él activo las\n * animaciones saltan al estado final en vez de desactivarse, así que los\n * callbacks y el desmontaje siguen ocurriendo igual.\n */\nexport function useReduceMotion(): boolean {\n const [reduceMotion, setReduceMotion] = useState(false);\n\n useEffect(() => {\n let alive = true;\n // Sólo se actualiza el estado si el ajuste está activo: el valor inicial ya\n // es `false` y un setState de false a false costaría un render por montaje.\n AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {\n if (alive && enabled) setReduceMotion(true);\n });\n const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);\n return () => {\n alive = false;\n sub.remove();\n };\n }, []);\n\n return reduceMotion;\n}\n\n/**\n * Transition\n * Envuelve cualquier árbol y lo anima al entrar y al salir. Todos los presets\n * se reducen a `opacity` y `transform` sobre un único Animated.Value, que es\n * lo que `useNativeDriver` sabe mandar al hilo nativo: la animación corre ahí\n * y el hilo de JS no participa de ningún frame — por eso no hay presets de\n * alto, ancho o color, que obligarían a volver a JS en cada frame.\n *\n * El nodo se mantiene montado hasta que termina la animación de salida, y el\n * comportamiento es idéntico en iOS y Android: no hay ninguna rama por\n * plataforma.\n */\nexport const Transition = memo(\n ({\n children,\n visible = true,\n preset = 'fade',\n duration,\n delay = 0,\n distance = 24,\n spring,\n appear = true,\n keepMounted = false,\n value,\n onEntered,\n onExited,\n style,\n testID,\n }: TransitionProps) => {\n const theme = useTheme();\n const reduceMotion = useReduceMotion();\n const internal = useRef(new Animated.Value(visible && !appear ? 1 : 0)).current;\n const progress = value ?? internal;\n const driven = value === undefined;\n\n const [mounted, setMounted] = useState(visible || keepMounted);\n\n // Los callbacks viven en un ref: pasados inline cambian de identidad en\n // cada render y, como dependencias del efecto, reiniciarían la animación.\n const callbacks = useRef({ onEntered, onExited });\n useEffect(() => {\n callbacks.current = { onEntered, onExited };\n });\n\n const presetKey = Array.isArray(preset) ? preset.join(' ') : preset;\n const presets = useMemo(() => presetKey.split(' ') as TransitionPreset[], [presetKey]);\n\n useEffect(() => {\n if (visible) setMounted(true);\n }, [visible]);\n\n useEffect(() => {\n if (!driven) return;\n const toValue = visible ? 1 : 0;\n const useSpring = spring ?? presets.includes('pop');\n\n const animation = reduceMotion\n ? Animated.timing(progress, { toValue, duration: 0, delay, useNativeDriver: true })\n : useSpring\n ? Animated.spring(progress, { toValue, delay, friction: 9, tension: 90, useNativeDriver: true })\n : Animated.timing(progress, {\n toValue,\n delay,\n duration: duration ?? theme.tokens.duration.base,\n easing: visible ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic),\n useNativeDriver: true,\n });\n\n animation.start(({ finished }) => {\n if (!finished) return;\n if (visible) {\n callbacks.current.onEntered?.();\n return;\n }\n callbacks.current.onExited?.();\n if (!keepMounted) setMounted(false);\n });\n\n return () => animation.stop();\n }, [visible, driven, reduceMotion, spring, delay, duration, keepMounted, presets, progress, theme]);\n\n const animatedStyle = useMemo(() => {\n type Interpolated = Animated.AnimatedInterpolation<number>;\n const transform: Array<{ scale: Interpolated } | { translateX: Interpolated } | { translateY: Interpolated }> = [];\n let opacity: Animated.Value | undefined;\n\n for (const item of presets) {\n if (item === 'fade') {\n opacity = progress;\n continue;\n }\n if (item === 'scale' || item === 'pop') {\n transform.push({\n scale: progress.interpolate({ inputRange: [0, 1], outputRange: [SCALE_FROM[item], 1] }),\n });\n continue;\n }\n const vertical = item === 'slide-up' || item === 'slide-down';\n const from = item === 'slide-up' || item === 'slide-left' ? distance : -distance;\n const offset = progress.interpolate({ inputRange: [0, 1], outputRange: [from, 0] });\n transform.push(vertical ? { translateY: offset } : { translateX: offset });\n }\n\n return { opacity, transform };\n }, [presets, progress, distance]);\n\n if (!mounted) return null;\n\n return (\n <Animated.View\n testID={testID}\n pointerEvents={visible ? undefined : 'none'}\n style={[style, animatedStyle]}\n >\n {children}\n </Animated.View>\n );\n }\n);\nTransition.displayName = 'Transition';\n"
14
14
  }
15
15
  ],
16
16
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Wizard.tsx",
12
- "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { ScrollView, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Steps, type StepItem } from '../molecules/Steps';\nimport { Button } from '../atoms/Button';\nimport { Transition } from '../atoms/Transition';\n\nexport interface WizardStep extends StepItem {\n content: React.ReactNode;\n /** false bloquea el avance desde este paso (validación). @default true */\n canGoNext?: boolean;\n}\n\nexport interface WizardProps {\n steps: WizardStep[];\n /** Paso actual (controlado). Si se omite, el Wizard maneja su propio estado. */\n current?: number;\n onStepChange?: (index: number) => void;\n onFinish?: () => void;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n completedIndicator?: 'check' | 'number';\n /** Permite volver a un paso ya completado tocándolo en el indicador. @default true */\n allowStepNavigation?: boolean;\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n /**\n * El contenido del paso scrollea cuando no entra. Ponelo en false sólo si\n * un paso renderiza su propia lista virtualizada (anidar una\n * VirtualizedList dentro de un ScrollView rompe la virtualización).\n * @default true\n */\n scrollableContent?: boolean;\n /**\n * El contenido del paso entra deslizándose en el sentido de la navegación.\n * Sólo opacity y transform, así que la animación corre en el hilo nativo.\n * @default true\n */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, gap: theme.tokens.spacing.xl },\n content: { flex: 1 },\n contentInner: { flexGrow: 1, paddingBottom: theme.tokens.spacing.sm },\n footer: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n footerButton: { flex: 1 },\n}));\n\n/**\n * Wizard\n * Flujo multi-paso: indicador (Steps) + contenido del paso actual + barra de\n * navegación. Funciona controlado (`current` + `onStepChange`) o por su\n * cuenta. `canGoNext: false` en un paso deshabilita el avance, que es donde\n * se engancha la validación del formulario de ese paso.\n */\nexport function Wizard({\n steps,\n current: controlledCurrent,\n onStepChange,\n onFinish,\n orientation = 'horizontal',\n completedIndicator = 'check',\n allowStepNavigation = true,\n backLabel = 'Back',\n nextLabel = 'Next',\n finishLabel = 'Finish',\n scrollableContent = true,\n animated = true,\n style,\n}: WizardProps) {\n const styles = useStyles();\n const [uncontrolled, setUncontrolled] = useState(0);\n const isControlled = controlledCurrent !== undefined;\n const current = isControlled ? controlledCurrent : uncontrolled;\n\n const goTo = useCallback(\n (index: number) => {\n const clamped = Math.max(0, Math.min(index, steps.length - 1));\n if (!isControlled) setUncontrolled(clamped);\n onStepChange?.(clamped);\n },\n [isControlled, onStepChange, steps.length]\n );\n\n const stepItems = useMemo(\n () => steps.map(({ label, description }) => ({ label, description })),\n [steps]\n );\n\n // El ref todavía guarda el paso anterior durante el render que trae el nuevo.\n const previous = useRef(current);\n const forward = current >= previous.current;\n const entered = useRef(false);\n useEffect(() => {\n previous.current = current;\n entered.current = true;\n }, [current]);\n\n const activeStep = steps[current];\n const isFirst = current === 0;\n const isLast = current === steps.length - 1;\n const canGoNext = activeStep?.canGoNext !== false;\n\n const handleNext = () => {\n if (isLast) onFinish?.();\n else goTo(current + 1);\n };\n\n // El paso saliente no se anima: mantener los dos montados a la vez duplicaría\n // los formularios y su estado.\n const renderContent = () =>\n animated ? (\n <Transition\n key={current}\n testID=\"wizard-step\"\n preset={['fade', forward ? 'slide-left' : 'slide-right']}\n appear={entered.current}\n >\n {activeStep?.content}\n </Transition>\n ) : (\n activeStep?.content\n );\n\n return (\n <View style={[styles.container, style]}>\n <Steps\n steps={stepItems}\n current={current}\n orientation={orientation}\n completedIndicator={completedIndicator}\n // Sólo se puede volver atrás tocando el indicador; saltar hacia\n // adelante se saltearía la validación de los pasos intermedios.\n onStepPress={allowStepNavigation ? (index) => index < current && goTo(index) : undefined}\n />\n\n {/* El contenido scrollea en vez de desbordar: con el teclado abierto un\n form largo se comía el footer y los botones quedaban pintados encima\n de los campos. */}\n {scrollableContent ? (\n <ScrollView\n testID=\"wizard-content\"\n style={styles.content}\n contentContainerStyle={styles.contentInner}\n keyboardShouldPersistTaps=\"handled\"\n automaticallyAdjustKeyboardInsets\n showsVerticalScrollIndicator={false}\n >\n {renderContent()}\n </ScrollView>\n ) : (\n <View style={styles.content}>{renderContent()}</View>\n )}\n\n <View style={styles.footer}>\n {!isFirst && (\n <Button title={backLabel} variant=\"outline\" onPress={() => goTo(current - 1)} style={styles.footerButton} />\n )}\n <Button\n title={isLast ? finishLabel : nextLabel}\n onPress={handleNext}\n disabled={!canGoNext}\n style={styles.footerButton}\n />\n </View>\n </View>\n );\n}\n"
12
+ "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { ScrollView, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Steps, type StepItem } from '../molecules/Steps';\nimport { Button } from '../atoms/Button';\nimport { Transition } from '../atoms/Transition';\n\nexport interface WizardStep extends StepItem {\n content: React.ReactNode;\n /** false bloquea el avance desde este paso (validación). @default true */\n canGoNext?: boolean;\n}\n\nexport interface WizardProps {\n steps: WizardStep[];\n /** Current step (controlled). If omitted, the Wizard manages its own state. */\n current?: number;\n onStepChange?: (index: number) => void;\n onFinish?: () => void;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n completedIndicator?: 'check' | 'number';\n /** Allows returning to an already-completed step by tapping it in the indicator. @default true */\n allowStepNavigation?: boolean;\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n /**\n * The step content scrolls when it doesn't fit. Set to false only if\n * a step renders its own virtualized list (nesting a\n * VirtualizedList inside a ScrollView breaks virtualization).\n * @default true\n */\n scrollableContent?: boolean;\n /**\n * The step content slides in following the navigation direction.\n * Only opacity and transform, so the animation runs on the native thread.\n * @default true\n */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, gap: theme.tokens.spacing.xl },\n content: { flex: 1 },\n contentInner: { flexGrow: 1, paddingBottom: theme.tokens.spacing.sm },\n footer: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n footerButton: { flex: 1 },\n}));\n\n/**\n * Wizard\n * Flujo multi-paso: indicador (Steps) + contenido del paso actual + barra de\n * navegación. Funciona controlado (`current` + `onStepChange`) o por su\n * cuenta. `canGoNext: false` en un paso deshabilita el avance, que es donde\n * se engancha la validación del formulario de ese paso.\n */\nexport function Wizard({\n steps,\n current: controlledCurrent,\n onStepChange,\n onFinish,\n orientation = 'horizontal',\n completedIndicator = 'check',\n allowStepNavigation = true,\n backLabel = 'Back',\n nextLabel = 'Next',\n finishLabel = 'Finish',\n scrollableContent = true,\n animated = true,\n style,\n}: WizardProps) {\n const styles = useStyles();\n const [uncontrolled, setUncontrolled] = useState(0);\n const isControlled = controlledCurrent !== undefined;\n const current = isControlled ? controlledCurrent : uncontrolled;\n\n const goTo = useCallback(\n (index: number) => {\n const clamped = Math.max(0, Math.min(index, steps.length - 1));\n if (!isControlled) setUncontrolled(clamped);\n onStepChange?.(clamped);\n },\n [isControlled, onStepChange, steps.length]\n );\n\n const stepItems = useMemo(\n () => steps.map(({ label, description }) => ({ label, description })),\n [steps]\n );\n\n // El ref todavía guarda el paso anterior durante el render que trae el nuevo.\n const previous = useRef(current);\n const forward = current >= previous.current;\n const entered = useRef(false);\n useEffect(() => {\n previous.current = current;\n entered.current = true;\n }, [current]);\n\n const activeStep = steps[current];\n const isFirst = current === 0;\n const isLast = current === steps.length - 1;\n const canGoNext = activeStep?.canGoNext !== false;\n\n const handleNext = () => {\n if (isLast) onFinish?.();\n else goTo(current + 1);\n };\n\n // El paso saliente no se anima: mantener los dos montados a la vez duplicaría\n // los formularios y su estado.\n const renderContent = () =>\n animated ? (\n <Transition\n key={current}\n testID=\"wizard-step\"\n preset={['fade', forward ? 'slide-left' : 'slide-right']}\n appear={entered.current}\n >\n {activeStep?.content}\n </Transition>\n ) : (\n activeStep?.content\n );\n\n return (\n <View style={[styles.container, style]}>\n <Steps\n steps={stepItems}\n current={current}\n orientation={orientation}\n completedIndicator={completedIndicator}\n // Sólo se puede volver atrás tocando el indicador; saltar hacia\n // adelante se saltearía la validación de los pasos intermedios.\n onStepPress={allowStepNavigation ? (index) => index < current && goTo(index) : undefined}\n />\n\n {/* El contenido scrollea en vez de desbordar: con el teclado abierto un\n form largo se comía el footer y los botones quedaban pintados encima\n de los campos. */}\n {scrollableContent ? (\n <ScrollView\n testID=\"wizard-content\"\n style={styles.content}\n contentContainerStyle={styles.contentInner}\n keyboardShouldPersistTaps=\"handled\"\n automaticallyAdjustKeyboardInsets\n showsVerticalScrollIndicator={false}\n >\n {renderContent()}\n </ScrollView>\n ) : (\n <View style={styles.content}>{renderContent()}</View>\n )}\n\n <View style={styles.footer}>\n {!isFirst && (\n <Button title={backLabel} variant=\"outline\" onPress={() => goTo(current - 1)} style={styles.footerButton} />\n )}\n <Button\n title={isLast ? finishLabel : nextLabel}\n onPress={handleNext}\n disabled={!canGoNext}\n style={styles.footerButton}\n />\n </View>\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -8,7 +8,7 @@ export type BadgeVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral';
8
8
  export interface BadgeProps {
9
9
  label: string;
10
10
  variant?: BadgeVariant;
11
- /** Override manual, gana sobre `variant`. */
11
+ /** Manual override, takes precedence over `variant`. */
12
12
  backgroundColor?: string;
13
13
  textColor?: string;
14
14
  style?: StyleProp<ViewStyle>;
@@ -20,16 +20,16 @@ export interface ImageProps {
20
20
  height?: number;
21
21
  /** @default 'cover' */
22
22
  resizeMode?: ImageResizeMode;
23
- /** Radio de las esquinas, mismo patrón que Avatar/Card. @default 0 */
23
+ /** Corner radius, same pattern as Avatar/Card. @default 0 */
24
24
  radius?: number;
25
- /** Qué mostrar mientras carga. `false` para no mostrar nada. @default true (spinner chico) */
25
+ /** What to show while loading. `false` to show nothing. @default true (small spinner) */
26
26
  loading?: React.ReactNode | boolean;
27
- /** Qué mostrar si la carga falla. @default un placeholder gris, sin contenido */
27
+ /** What to show if loading fails. @default a gray placeholder, no content */
28
28
  fallback?: React.ReactNode;
29
29
  /**
30
- * 'high' dispara Image.prefetch() al montar, antes de que <Image> arranque
31
- * su propio ciclo de carga — pensado para la imagen above-the-fold de una
32
- * pantalla (la primera que el usuario ve). Sin costo si ya está en caché.
30
+ * 'high' triggers Image.prefetch() on mount, before <Image> starts
31
+ * its own load cycle — meant for the above-the-fold image of a
32
+ * screen (the first one the user sees). No cost if it's already cached.
33
33
  * @default 'normal'
34
34
  */
35
35
  priority?: 'low' | 'normal' | 'high';
@@ -17,7 +17,7 @@ import type { IconName } from '../icons/types';
17
17
 
18
18
  export interface InputProps extends Omit<TextInputProps, 'style'> {
19
19
  label?: string;
20
- /** Muestra un asterisco junto al label. Puramente visual, no valida nada. */
20
+ /** Shows an asterisk next to the label. Purely visual, doesn't validate anything. */
21
21
  required?: boolean;
22
22
  error?: string;
23
23
  containerStyle?: StyleProp<ViewStyle>;
@@ -9,7 +9,7 @@ import {
9
9
  } from 'react-native';
10
10
 
11
11
  export interface PressableScaleProps extends Omit<PressableProps, 'style'> {
12
- /** Escala objetivo al presionar. @default 0.96 */
12
+ /** Target scale when pressed. @default 0.96 */
13
13
  scaleTo?: number;
14
14
  style?: StyleProp<ViewStyle>;
15
15
  children: React.ReactNode;
@@ -13,17 +13,17 @@ import { useTheme } from '../theme/UIProvider';
13
13
  export type SkeletonVariant = 'rect' | 'text' | 'circle';
14
14
 
15
15
  export interface SkeletonProps {
16
- /** @default '100%' ('circle' lo usa también como diámetro) */
16
+ /** @default '100%' ('circle' also uses this as the diameter) */
17
17
  width?: DimensionValue;
18
- /** @default 16 ('text' usa el alto de línea; 'circle', el ancho) */
18
+ /** @default 16 ('text' uses the line height; 'circle', the width) */
19
19
  height?: DimensionValue;
20
20
  /** @default 'rect' */
21
21
  variant?: SkeletonVariant;
22
- /** Override del radius del theme. */
22
+ /** Override of the theme's radius. */
23
23
  radius?: number;
24
- /** Líneas apiladas, sólo para variant="text". @default 1 */
24
+ /** Stacked lines, only for variant="text". @default 1 */
25
25
  lines?: number;
26
- /** Ancho de la última línea, para que el párrafo no termine en bloque. @default '60%' */
26
+ /** Width of the last line, so the paragraph doesn't end as a solid block. @default '60%' */
27
27
  lastLineWidth?: DimensionValue;
28
28
  /** @default true */
29
29
  animated?: boolean;
@@ -9,15 +9,15 @@ export interface SpinnerProps {
9
9
  size?: 'small' | 'large';
10
10
  color?: string;
11
11
  text?: string;
12
- /** @default true. En false, no ocupa flex:1 (útil inline, dentro de un botón/fila). */
12
+ /** @default true. When false, doesn't take up flex:1 (useful inline, inside a button/row). */
13
13
  fullscreen?: boolean;
14
14
  /**
15
- * 'native' usa el ActivityIndicator del sistema; 'dots' y 'ring' son
16
- * indicadores propios, iguales en iOS y Android.
15
+ * 'native' uses the system's ActivityIndicator; 'dots' and 'ring' are
16
+ * custom indicators, identical on iOS and Android.
17
17
  * @default 'native'
18
18
  */
19
19
  variant?: SpinnerVariant;
20
- /** Indicador totalmente propio. Gana sobre `variant`. */
20
+ /** Fully custom indicator. Takes precedence over `variant`. */
21
21
  indicator?: React.ReactNode;
22
22
  testID?: string;
23
23
  }
@@ -13,25 +13,25 @@ export type TransitionPreset =
13
13
 
14
14
  export interface TransitionProps {
15
15
  children: React.ReactNode;
16
- /** Entra en true, sale (y desmonta) en false. @default true */
16
+ /** Enters when true, exits (and unmounts) when false. @default true */
17
17
  visible?: boolean;
18
- /** Uno o varios: 'fade scale' combina opacidad y escala. @default 'fade' */
18
+ /** One or more: 'fade scale' combines opacity and scale. @default 'fade' */
19
19
  preset?: TransitionPreset | TransitionPreset[];
20
- /** ms del timing. @default tokens.duration.base */
20
+ /** Timing duration in ms. @default tokens.duration.base */
21
21
  duration?: number;
22
- /** ms antes de arrancar. Un índice por delay escalona una lista. @default 0 */
22
+ /** ms before starting. A per-index delay staggers a list. @default 0 */
23
23
  delay?: number;
24
- /** Píxeles que recorren los presets de slide. @default 24 */
24
+ /** Pixels traveled by the slide presets. @default 24 */
25
25
  distance?: number;
26
- /** Spring en vez de timing (implícito en 'pop'). */
26
+ /** Spring instead of timing (implicit in 'pop'). */
27
27
  spring?: boolean;
28
- /** Anima también el primer render. En false arranca ya presente. @default true */
28
+ /** Also animates the first render. When false it starts already present. @default true */
29
29
  appear?: boolean;
30
- /** Deja el nodo montado al salir, sólo lo esconde. @default false */
30
+ /** Keeps the node mounted on exit, only hides it. @default false */
31
31
  keepMounted?: boolean;
32
32
  /**
33
- * Progreso 0→1 manejado desde afuera. Con esto el Transition sólo interpola:
34
- * quien lo pasa decide cuándo y cómo avanza (un PanResponder, por ejemplo).
33
+ * 0→1 progress driven from outside. With this, Transition only interpolates:
34
+ * whoever passes it decides when and how it advances (a PanResponder, for example).
35
35
  */
36
36
  value?: Animated.Value;
37
37
  onEntered?: () => void;
@@ -6,10 +6,10 @@ import { Icon } from '../icons/Icon';
6
6
  import type { IconName } from '../icons/types';
7
7
 
8
8
  export interface AvatarHeaderProps {
9
- /** Ícono a mostrar dentro del círculo. Si se omite, se usan `initials`. */
9
+ /** Icon to show inside the circle. If omitted, `initials` is used instead. */
10
10
  iconName?: IconName;
11
11
  iconColor?: string;
12
- /** Texto corto (ej. 2 letras) mostrado si no se pasa `iconName`. */
12
+ /** Short text (e.g. 2 letters) shown when `iconName` isn't provided. */
13
13
  initials?: string;
14
14
  title: string;
15
15
  subtitle?: React.ReactNode;
@@ -5,7 +5,7 @@ import type { IconName } from '../icons/types';
5
5
  import { useLabels } from '../theme/UIProvider';
6
6
 
7
7
  export interface FormActionsProps {
8
- /** Si se omite, solo se muestra el botón secundario. */
8
+ /** If omitted, only the secondary button is shown. */
9
9
  primaryLabel?: string;
10
10
  onPrimaryPress?: () => void;
11
11
  primaryDisabled?: boolean;
@@ -8,13 +8,13 @@ import type { IconName } from '../icons/types';
8
8
 
9
9
  export interface OptionCardProps {
10
10
  label: string;
11
- /** Línea secundaria: el detalle que decide la elección ("llega en 3 días"). */
11
+ /** Secondary line: the detail that decides the choice ("arrives in 3 days"). */
12
12
  description?: string;
13
13
  iconName?: IconName;
14
14
  isSelected: boolean;
15
15
  onPress: () => void;
16
16
  disabled?: boolean;
17
- /** 'vertical': ícono arriba, texto abajo centrado. 'horizontal': en fila. @default 'horizontal' */
17
+ /** 'vertical': icon on top, text centered below. 'horizontal': in a row. @default 'horizontal' */
18
18
  layout?: 'vertical' | 'horizontal';
19
19
  style?: StyleProp<ViewStyle>;
20
20
  testID?: string;
@@ -11,23 +11,23 @@ export interface StepperProps {
11
11
  onChangeText: (text: string) => void;
12
12
  onIncrement: () => void;
13
13
  onDecrement: () => void;
14
- /** Escala del control: alto, botones, ícono y tipografía. @default 'md' */
14
+ /** Scale of the control: height, buttons, icon and typography. @default 'md' */
15
15
  size?: StepperSize;
16
- /** Ocupa todo el ancho disponible y el input se lleva el sobrante. @default false */
16
+ /** Takes up all available width and the input absorbs the extra space. @default false */
17
17
  block?: boolean;
18
18
  /**
19
- * En false el valor sólo se cambia con los botones: el teclado no se abre y
20
- * el campo pierde el fondo que lo anuncia como editable. @default true
19
+ * When false the value can only be changed with the buttons: the keyboard doesn't open and
20
+ * the field loses the background that marks it as editable. @default true
21
21
  */
22
22
  editable?: boolean;
23
23
  /**
24
- * Mínimo permitido. Deshabilita "-" al alcanzarlo y acota lo tipeado al
25
- * salir del campo. Los negativos se aceptan sólo si `min` es negativo.
24
+ * Minimum allowed value. Disables "-" when reached and clamps the typed value on
25
+ * blur. Negative values are only accepted if `min` is negative.
26
26
  */
27
27
  min?: number;
28
- /** Máximo permitido. Deshabilita "+" al alcanzarlo y acota lo tipeado. */
28
+ /** Maximum allowed value. Disables "+" when reached and clamps the typed value. */
29
29
  max?: number;
30
- /** Acepta un separador decimal, normalizando la coma a punto. @default false */
30
+ /** Accepts a decimal separator, normalizing comma to dot. @default false */
31
31
  allowDecimals?: boolean;
32
32
  style?: StyleProp<ViewStyle>;
33
33
  incrementAccessibilityLabel?: string;
@@ -13,16 +13,16 @@ export type StepStatus = 'completed' | 'current' | 'pending';
13
13
 
14
14
  export interface StepsProps {
15
15
  steps: StepItem[];
16
- /** Índice del paso actual (0-based). Los anteriores quedan 'completed'. */
16
+ /** Index of the current step (0-based). Earlier steps become 'completed'. */
17
17
  current: number;
18
18
  /** @default 'horizontal' */
19
19
  orientation?: 'horizontal' | 'vertical';
20
20
  /**
21
- * Qué muestra el círculo de un paso ya completado: un check, o su número.
21
+ * What the circle of an already-completed step shows: a check, or its number.
22
22
  * @default 'check'
23
23
  */
24
24
  completedIndicator?: 'check' | 'number';
25
- /** Si se pasa, cada paso es tocable y reporta su índice. */
25
+ /** If provided, each step is tappable and reports its index. */
26
26
  onStepPress?: (index: number) => void;
27
27
  style?: StyleProp<ViewStyle>;
28
28
  }