orn-ui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +9 -2
  2. package/lib/commonjs/atoms/PressableScale.js +9 -10
  3. package/lib/commonjs/atoms/PressableScale.js.map +1 -1
  4. package/lib/commonjs/atoms/Skeleton.js +96 -0
  5. package/lib/commonjs/atoms/Skeleton.js.map +1 -0
  6. package/lib/commonjs/atoms/Transition.js +171 -0
  7. package/lib/commonjs/atoms/Transition.js.map +1 -0
  8. package/lib/commonjs/atoms/index.js +20 -0
  9. package/lib/commonjs/atoms/index.js.map +1 -1
  10. package/lib/commonjs/molecules/OptionCard.js +55 -22
  11. package/lib/commonjs/molecules/OptionCard.js.map +1 -1
  12. package/lib/commonjs/molecules/{ThemeToggle.js → SegmentedControl.js} +36 -36
  13. package/lib/commonjs/molecules/SegmentedControl.js.map +1 -0
  14. package/lib/commonjs/molecules/Stepper.js +124 -47
  15. package/lib/commonjs/molecules/Stepper.js.map +1 -1
  16. package/lib/commonjs/molecules/index.js +9 -3
  17. package/lib/commonjs/molecules/index.js.map +1 -1
  18. package/lib/commonjs/organisms/AlertProvider.js +26 -0
  19. package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
  20. package/lib/commonjs/organisms/BottomSheet.js +118 -39
  21. package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
  22. package/lib/commonjs/organisms/List.js +66 -44
  23. package/lib/commonjs/organisms/List.js.map +1 -1
  24. package/lib/commonjs/organisms/Modal.js +42 -87
  25. package/lib/commonjs/organisms/Modal.js.map +1 -1
  26. package/lib/commonjs/organisms/NavigationBar.js +205 -0
  27. package/lib/commonjs/organisms/NavigationBar.js.map +1 -0
  28. package/lib/commonjs/organisms/Screen.js +9 -2
  29. package/lib/commonjs/organisms/Screen.js.map +1 -1
  30. package/lib/commonjs/organisms/SearchList.js +97 -28
  31. package/lib/commonjs/organisms/SearchList.js.map +1 -1
  32. package/lib/commonjs/organisms/Select.js +222 -50
  33. package/lib/commonjs/organisms/Select.js.map +1 -1
  34. package/lib/commonjs/organisms/ThemeToggle.js +52 -0
  35. package/lib/commonjs/organisms/ThemeToggle.js.map +1 -0
  36. package/lib/commonjs/organisms/Toast.js +8 -22
  37. package/lib/commonjs/organisms/Toast.js.map +1 -1
  38. package/lib/commonjs/organisms/ToastProvider.js +24 -0
  39. package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
  40. package/lib/commonjs/organisms/Wizard.js +22 -2
  41. package/lib/commonjs/organisms/Wizard.js.map +1 -1
  42. package/lib/commonjs/organisms/imperativeBridge.js +28 -0
  43. package/lib/commonjs/organisms/imperativeBridge.js.map +1 -0
  44. package/lib/commonjs/organisms/index.js +50 -0
  45. package/lib/commonjs/organisms/index.js.map +1 -1
  46. package/lib/commonjs/theme/colors.js +207 -0
  47. package/lib/commonjs/theme/colors.js.map +1 -0
  48. package/lib/commonjs/theme/createTheme.js +25 -2
  49. package/lib/commonjs/theme/createTheme.js.map +1 -1
  50. package/lib/commonjs/theme/index.js +25 -0
  51. package/lib/commonjs/theme/index.js.map +1 -1
  52. package/lib/commonjs/theme/palettes.js +5 -2
  53. package/lib/commonjs/theme/palettes.js.map +1 -1
  54. package/lib/module/atoms/PressableScale.js +9 -10
  55. package/lib/module/atoms/PressableScale.js.map +1 -1
  56. package/lib/module/atoms/Skeleton.js +91 -0
  57. package/lib/module/atoms/Skeleton.js.map +1 -0
  58. package/lib/module/atoms/Transition.js +165 -0
  59. package/lib/module/atoms/Transition.js.map +1 -0
  60. package/lib/module/atoms/index.js +2 -0
  61. package/lib/module/atoms/index.js.map +1 -1
  62. package/lib/module/molecules/OptionCard.js +56 -23
  63. package/lib/module/molecules/OptionCard.js.map +1 -1
  64. package/lib/module/molecules/{ThemeToggle.js → SegmentedControl.js} +36 -36
  65. package/lib/module/molecules/SegmentedControl.js.map +1 -0
  66. package/lib/module/molecules/Stepper.js +125 -49
  67. package/lib/module/molecules/Stepper.js.map +1 -1
  68. package/lib/module/molecules/index.js +2 -2
  69. package/lib/module/molecules/index.js.map +1 -1
  70. package/lib/module/organisms/AlertProvider.js +24 -1
  71. package/lib/module/organisms/AlertProvider.js.map +1 -1
  72. package/lib/module/organisms/BottomSheet.js +120 -41
  73. package/lib/module/organisms/BottomSheet.js.map +1 -1
  74. package/lib/module/organisms/List.js +68 -46
  75. package/lib/module/organisms/List.js.map +1 -1
  76. package/lib/module/organisms/Modal.js +44 -89
  77. package/lib/module/organisms/Modal.js.map +1 -1
  78. package/lib/module/organisms/NavigationBar.js +200 -0
  79. package/lib/module/organisms/NavigationBar.js.map +1 -0
  80. package/lib/module/organisms/Screen.js +9 -2
  81. package/lib/module/organisms/Screen.js.map +1 -1
  82. package/lib/module/organisms/SearchList.js +98 -29
  83. package/lib/module/organisms/SearchList.js.map +1 -1
  84. package/lib/module/organisms/Select.js +224 -52
  85. package/lib/module/organisms/Select.js.map +1 -1
  86. package/lib/module/organisms/ThemeToggle.js +47 -0
  87. package/lib/module/organisms/ThemeToggle.js.map +1 -0
  88. package/lib/module/organisms/Toast.js +10 -24
  89. package/lib/module/organisms/Toast.js.map +1 -1
  90. package/lib/module/organisms/ToastProvider.js +21 -0
  91. package/lib/module/organisms/ToastProvider.js.map +1 -1
  92. package/lib/module/organisms/Wizard.js +23 -3
  93. package/lib/module/organisms/Wizard.js.map +1 -1
  94. package/lib/module/organisms/imperativeBridge.js +24 -0
  95. package/lib/module/organisms/imperativeBridge.js.map +1 -0
  96. package/lib/module/organisms/index.js +4 -2
  97. package/lib/module/organisms/index.js.map +1 -1
  98. package/lib/module/theme/colors.js +194 -0
  99. package/lib/module/theme/colors.js.map +1 -0
  100. package/lib/module/theme/createTheme.js +25 -2
  101. package/lib/module/theme/createTheme.js.map +1 -1
  102. package/lib/module/theme/index.js +1 -0
  103. package/lib/module/theme/index.js.map +1 -1
  104. package/lib/module/theme/palettes.js +5 -2
  105. package/lib/module/theme/palettes.js.map +1 -1
  106. package/lib/typescript/atoms/PressableScale.d.ts +3 -2
  107. package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
  108. package/lib/typescript/atoms/Skeleton.d.ts +29 -0
  109. package/lib/typescript/atoms/Skeleton.d.ts.map +1 -0
  110. package/lib/typescript/atoms/Transition.d.ts +51 -0
  111. package/lib/typescript/atoms/Transition.d.ts.map +1 -0
  112. package/lib/typescript/atoms/index.d.ts +4 -0
  113. package/lib/typescript/atoms/index.d.ts.map +1 -1
  114. package/lib/typescript/molecules/OptionCard.d.ts +11 -6
  115. package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
  116. package/lib/typescript/molecules/SegmentedControl.d.ts +24 -0
  117. package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -0
  118. package/lib/typescript/molecules/Stepper.d.ts +35 -3
  119. package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
  120. package/lib/typescript/molecules/index.d.ts +4 -4
  121. package/lib/typescript/molecules/index.d.ts.map +1 -1
  122. package/lib/typescript/organisms/AlertProvider.d.ts +9 -0
  123. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  124. package/lib/typescript/organisms/BottomSheet.d.ts +26 -4
  125. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  126. package/lib/typescript/organisms/List.d.ts +9 -2
  127. package/lib/typescript/organisms/List.d.ts.map +1 -1
  128. package/lib/typescript/organisms/Modal.d.ts +3 -5
  129. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  130. package/lib/typescript/organisms/NavigationBar.d.ts +32 -0
  131. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -0
  132. package/lib/typescript/organisms/Screen.d.ts +2 -1
  133. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  134. package/lib/typescript/organisms/SearchList.d.ts +21 -5
  135. package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
  136. package/lib/typescript/organisms/Select.d.ts +19 -4
  137. package/lib/typescript/organisms/Select.d.ts.map +1 -1
  138. package/lib/typescript/{molecules → organisms}/ThemeToggle.d.ts +4 -4
  139. package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -0
  140. package/lib/typescript/organisms/Toast.d.ts.map +1 -1
  141. package/lib/typescript/organisms/ToastProvider.d.ts +8 -0
  142. package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
  143. package/lib/typescript/organisms/Wizard.d.ts +7 -1
  144. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  145. package/lib/typescript/organisms/imperativeBridge.d.ts +12 -0
  146. package/lib/typescript/organisms/imperativeBridge.d.ts.map +1 -0
  147. package/lib/typescript/organisms/index.d.ts +7 -3
  148. package/lib/typescript/organisms/index.d.ts.map +1 -1
  149. package/lib/typescript/theme/colors.d.ts +44 -0
  150. package/lib/typescript/theme/colors.d.ts.map +1 -0
  151. package/lib/typescript/theme/createTheme.d.ts +3 -0
  152. package/lib/typescript/theme/createTheme.d.ts.map +1 -1
  153. package/lib/typescript/theme/index.d.ts +3 -1
  154. package/lib/typescript/theme/index.d.ts.map +1 -1
  155. package/lib/typescript/theme/palettes.d.ts.map +1 -1
  156. package/lib/typescript/theme/types.d.ts +9 -0
  157. package/lib/typescript/theme/types.d.ts.map +1 -1
  158. package/package.json +67 -6
  159. package/registry/alert-provider.json +9 -2
  160. package/registry/bottom-sheet.json +6 -2
  161. package/registry/core.json +8 -4
  162. package/registry/date-field.json +1 -0
  163. package/registry/index.json +96 -27
  164. package/registry/list.json +2 -1
  165. package/registry/manifest.json +44 -6
  166. package/registry/modal.json +3 -2
  167. package/registry/navigation-bar.json +18 -0
  168. package/registry/option-card.json +3 -2
  169. package/registry/pressable-scale.json +1 -1
  170. package/registry/screen.json +1 -1
  171. package/registry/search-list.json +2 -1
  172. package/registry/segmented-control.json +18 -0
  173. package/registry/select.json +1 -1
  174. package/registry/skeleton.json +18 -0
  175. package/registry/stepper.json +3 -2
  176. package/registry/theme-toggle.json +5 -4
  177. package/registry/toast-provider.json +11 -3
  178. package/registry/toast.json +3 -2
  179. package/registry/transition.json +19 -0
  180. package/registry/wizard.json +3 -2
  181. package/src/atoms/PressableScale.tsx +16 -6
  182. package/src/atoms/Skeleton.tsx +114 -0
  183. package/src/atoms/Transition.tsx +189 -0
  184. package/src/atoms/index.ts +4 -0
  185. package/src/molecules/OptionCard.tsx +62 -25
  186. package/src/molecules/SegmentedControl.tsx +84 -0
  187. package/src/molecules/Stepper.tsx +147 -32
  188. package/src/molecules/index.ts +4 -4
  189. package/src/organisms/AlertProvider.tsx +26 -1
  190. package/src/organisms/BottomSheet.tsx +214 -62
  191. package/src/organisms/List.tsx +73 -41
  192. package/src/organisms/Modal.tsx +49 -61
  193. package/src/organisms/NavigationBar.tsx +223 -0
  194. package/src/organisms/Screen.tsx +11 -3
  195. package/src/organisms/SearchList.tsx +132 -36
  196. package/src/organisms/Select.tsx +223 -45
  197. package/src/organisms/ThemeToggle.tsx +30 -0
  198. package/src/organisms/Toast.tsx +8 -21
  199. package/src/organisms/ToastProvider.tsx +24 -0
  200. package/src/organisms/Wizard.tsx +36 -3
  201. package/src/organisms/imperativeBridge.ts +28 -0
  202. package/src/organisms/index.ts +7 -3
  203. package/src/theme/colors.ts +175 -0
  204. package/src/theme/createTheme.ts +38 -4
  205. package/src/theme/index.ts +3 -1
  206. package/src/theme/palettes.ts +5 -2
  207. package/src/theme/types.ts +10 -0
  208. package/lib/commonjs/molecules/ThemeToggle.js.map +0 -1
  209. package/lib/module/molecules/ThemeToggle.js.map +0 -1
  210. package/lib/typescript/molecules/ThemeToggle.d.ts.map +0 -1
  211. package/src/molecules/ThemeToggle.tsx +0 -67
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "SegmentedControl",
3
+ "slug": "segmented-control",
4
+ "type": "component",
5
+ "category": "molecules",
6
+ "exportedNames": [
7
+ "SegmentedControl"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "molecules/SegmentedControl.tsx",
12
+ "content": "import React, { memo } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling } from '../theme/UIProvider';\n\nexport interface SegmentedControlOption<T extends string = string> {\n value: T;\n label: string;\n disabled?: boolean;\n}\n\nexport interface SegmentedControlProps<T extends string = string> {\n options: SegmentedControlOption<T>[];\n value: T;\n onChange: (value: T) => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n track: {\n flexDirection: 'row',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.md,\n padding: 3,\n },\n segment: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.sm,\n alignItems: 'center',\n },\n segmentActive: { backgroundColor: theme.colors.surface, ...theme.tokens.shadow.sm },\n segmentDisabled: { opacity: 0.5 },\n label: { fontSize: theme.tokens.fontSize.sm, fontWeight: theme.tokens.fontWeight.medium, color: theme.colors.textLight },\n labelActive: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n}));\n\n/**\n * SegmentedControl\n * Elige una opción entre pocas, todas visibles. No conoce ningún estado\n * global: recibe `value` y avisa por `onChange`.\n */\nfunction SegmentedControlInner<T extends string>({\n options,\n value,\n onChange,\n disabled = false,\n style,\n testID,\n}: SegmentedControlProps<T>) {\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n return (\n <View style={[styles.track, style]} accessibilityRole=\"radiogroup\" testID={testID}>\n {options.map((option) => {\n const active = option.value === value;\n const isDisabled = disabled || !!option.disabled;\n return (\n <TouchableOpacity\n key={option.value}\n style={[styles.segment, active && styles.segmentActive, isDisabled && styles.segmentDisabled]}\n onPress={() => onChange(option.value)}\n disabled={isDisabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: active, checked: active, disabled: isDisabled }}\n accessibilityLabel={option.label}\n testID={testID && `${testID}-${option.value}`}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.label, active && styles.labelActive]}>\n {option.label}\n </Text>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n}\n\n// memo() pierde los genéricos al envolver: el cast los devuelve para que\n// `value` y `onChange` sigan atados al union de `options`.\nexport const SegmentedControl = memo(SegmentedControlInner) as typeof SegmentedControlInner;\n"
13
+ }
14
+ ],
15
+ "registryDependencies": [
16
+ "core"
17
+ ]
18
+ }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Select.tsx",
12
- "content": "import React, { memo, useState } from 'react';\nimport { ActivityIndicator, Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface SelectOption<T> {\n label: string;\n value: T;\n}\n\nexport interface SelectProps<T> {\n label?: string;\n required?: boolean;\n options: SelectOption<T>[];\n selectedValue: T | undefined;\n onSelect: (value: T) => void;\n placeholder?: string;\n error?: string;\n isLoading?: boolean;\n containerStyle?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { gap: theme.tokens.spacing.sm },\n containerOpen: { zIndex: 20, elevation: 20 },\n label: { fontWeight: theme.tokens.fontWeight.bold, fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n required: { color: theme.colors.errorText },\n field: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n borderWidth: 2,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.inputBackground,\n minHeight: 50,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n fieldPlaceholder: { color: theme.colors.textLight },\n optionsList: {\n position: 'absolute',\n top: '100%',\n left: 0,\n right: 0,\n marginTop: theme.tokens.spacing.sm,\n borderWidth: 2,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.surface,\n overflow: 'hidden',\n zIndex: 20,\n elevation: 20,\n ...theme.tokens.shadow.md,\n },\n optionRow: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n paddingHorizontal: theme.tokens.spacing.lg,\n paddingVertical: theme.tokens.spacing.md + 2,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n },\n optionRowLast: { borderBottomWidth: 0 },\n optionRowSelected: { backgroundColor: theme.colors.primarySoft },\n optionText: { fontSize: theme.tokens.fontSize.lg - 1, color: theme.colors.text },\n optionTextSelected: { fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.primaryText },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * Select\n * Dropdown que expande la lista de opciones flotando sobre el resto del\n * contenido (position: absolute), en vez de empujarlo hacia abajo o abrir\n * un picker nativo del sistema.\n */\nexport function Select<T extends string | number>({\n label,\n required = false,\n options,\n selectedValue,\n onSelect,\n placeholder,\n error,\n isLoading = false,\n containerStyle,\n}: SelectProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const [open, setOpen] = useState(false);\n const selectedOption = options.find((o) => o.value === selectedValue);\n\n return (\n <View style={[styles.container, open && styles.containerOpen, containerStyle]}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Pressable\n style={styles.field}\n onPress={() => !isLoading && setOpen((o) => !o)}\n disabled={isLoading}\n accessibilityRole=\"button\"\n accessibilityLabel={label ?? placeholder ?? labels.selectPlaceholder}\n accessibilityState={{ expanded: open, disabled: isLoading }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.fieldText, !selectedOption && styles.fieldPlaceholder]}>\n {selectedOption?.label || placeholder || labels.selectPlaceholder}\n </Text>\n {isLoading ? (\n <ActivityIndicator size=\"small\" color={colors.primary} />\n ) : (\n <Icon name={open ? 'chevron-up' : 'chevron-down'} size={20} color={colors.textLight} />\n )}\n </Pressable>\n\n {open && !isLoading && (\n <View style={styles.optionsList}>\n {options.map((option, index) => {\n const isSelected = option.value === selectedValue;\n return (\n <Pressable\n key={String(option.value)}\n style={[styles.optionRow, index === options.length - 1 && styles.optionRowLast, isSelected && styles.optionRowSelected]}\n onPress={() => {\n onSelect(option.value);\n setOpen(false);\n }}\n accessibilityRole=\"menuitem\"\n accessibilityState={{ selected: isSelected }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.optionText, isSelected && styles.optionTextSelected]}>\n {option.label}\n </Text>\n {isSelected && <Icon name=\"check\" size={18} color={colors.primary} />}\n </Pressable>\n );\n })}\n </View>\n )}\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n}\n"
12
+ "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Keyboard,\n Modal,\n Platform,\n Pressable,\n ScrollView,\n Text,\n TextInput,\n View,\n useWindowDimensions,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface SelectOption<T> {\n label: string;\n value: T;\n /** Glifo del set de la librería, a la izquierda del label. */\n iconName?: IconName;\n /** Emoji a la izquierda del label. Gana sobre `iconName`. */\n emoji?: string;\n disabled?: boolean;\n}\n\nexport interface SelectProps<T> {\n label?: string;\n required?: boolean;\n options: SelectOption<T>[];\n selectedValue: T | undefined;\n onSelect: (value: T) => void;\n placeholder?: string;\n error?: string;\n isLoading?: boolean;\n disabled?: boolean;\n /** Agrega un buscador dentro del desplegable. @default false */\n searchable?: boolean;\n searchPlaceholder?: string;\n /** Alto máximo del desplegable, si el espacio disponible lo permite. @default 280 */\n maxHeight?: number;\n onOpenChange?: (open: boolean) => void;\n containerStyle?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Aire entre el campo y el desplegable. */\nconst GAP = 8;\n/** Por debajo de esto el desplegable no muestra ni dos filas. */\nconst MIN_HEIGHT = 120;\n\nconst useStyles = createStyles((theme) => ({\n container: { gap: theme.tokens.spacing.sm },\n label: { fontWeight: theme.tokens.fontWeight.bold, fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n required: { color: theme.colors.errorText },\n field: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n borderWidth: 2,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.inputBackground,\n minHeight: 50,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldOpen: { borderColor: theme.colors.primary },\n fieldDisabled: { opacity: 0.5 },\n fieldValue: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.sm, flexShrink: 1 },\n fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text, flexShrink: 1 },\n fieldPlaceholder: { color: theme.colors.textLight },\n backdrop: { flex: 1 },\n sheet: {\n position: 'absolute',\n borderWidth: 2,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.surface,\n overflow: 'hidden',\n ...theme.tokens.shadow.md,\n },\n search: {\n fontSize: theme.tokens.fontSize.md,\n color: theme.colors.text,\n paddingHorizontal: theme.tokens.spacing.lg,\n paddingVertical: theme.tokens.spacing.md,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n },\n optionRow: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n paddingHorizontal: theme.tokens.spacing.lg,\n paddingVertical: theme.tokens.spacing.md + 2,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n },\n optionRowLast: { borderBottomWidth: 0 },\n optionRowSelected: { backgroundColor: theme.colors.primarySoft },\n optionRowDisabled: { opacity: 0.4 },\n optionLabel: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.sm, flexShrink: 1 },\n optionText: { fontSize: theme.tokens.fontSize.lg - 1, color: theme.colors.text },\n optionTextSelected: { fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.primaryText },\n emoji: { fontSize: theme.tokens.fontSize.lg },\n empty: {\n padding: theme.tokens.spacing.lg,\n textAlign: 'center',\n color: theme.colors.textLight,\n fontSize: theme.tokens.fontSize.md,\n },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\ninterface Anchor {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/**\n * Select\n * Dropdown de una sola opción. La lista se mide contra la ventana y se dibuja\n * dentro de un Modal, no en el árbol del campo: así ningún `overflow: hidden`\n * la recorta, no depende del zIndex (poco confiable en Android) y el fondo del\n * Modal resuelve cerrar tocando afuera y con el botón atrás.\n */\nexport function Select<T extends string | number>({\n label,\n required = false,\n options,\n selectedValue,\n onSelect,\n placeholder,\n error,\n isLoading = false,\n disabled = false,\n searchable = false,\n searchPlaceholder,\n maxHeight = 280,\n onOpenChange,\n containerStyle,\n testID,\n}: SelectProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const { height: windowHeight } = useWindowDimensions();\n\n const fieldRef = useRef<View>(null);\n const [open, setOpen] = useState(false);\n const [anchor, setAnchor] = useState<Anchor | null>(null);\n const [query, setQuery] = useState('');\n const [keyboardHeight, setKeyboardHeight] = useState(0);\n const isDisabled = disabled || isLoading;\n const selectedOption = options.find((o) => o.value === selectedValue);\n\n const close = useCallback(() => {\n setOpen(false);\n setAnchor(null);\n setQuery('');\n onOpenChange?.(false);\n }, [onOpenChange]);\n\n // Abrir no espera a la medición: si `measureInWindow` no responde, la lista\n // igual aparece (centrada) en vez de que el campo deje de funcionar.\n const openList = () => {\n fieldRef.current?.measureInWindow((x, y, width, height) => setAnchor({ x, y, width, height }));\n setOpen(true);\n onOpenChange?.(true);\n };\n\n // El buscador abre el teclado encima del desplegable: sin descontarlo, la\n // lista se dibuja donde ya no se ve.\n useEffect(() => {\n if (!open) return;\n setKeyboardHeight(Keyboard.metrics()?.height ?? 0);\n const [show, hide] =\n Platform.OS === 'ios' ? ['keyboardWillShow', 'keyboardWillHide'] : ['keyboardDidShow', 'keyboardDidHide'];\n const subs = [\n Keyboard.addListener(show as 'keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)),\n Keyboard.addListener(hide as 'keyboardDidHide', () => setKeyboardHeight(0)),\n ];\n return () => subs.forEach((sub) => sub.remove());\n }, [open]);\n\n const visibleOptions = useMemo(() => {\n if (!searchable || query.trim() === '') return options;\n const needle = query.trim().toLowerCase();\n return options.filter((option) => option.label.toLowerCase().includes(needle));\n }, [options, query, searchable]);\n\n // Se abre hacia donde haya más aire: contra el borde inferior de la pantalla,\n // hacia arriba es la única forma de que entre.\n const placement = useMemo((): ViewStyle => {\n if (!anchor) return { left: GAP * 2, right: GAP * 2, top: windowHeight * 0.25, maxHeight };\n const usableHeight = windowHeight - keyboardHeight;\n const below = usableHeight - (anchor.y + anchor.height) - GAP * 2;\n const above = anchor.y - GAP * 2;\n const downwards = below >= Math.min(maxHeight, above);\n return {\n left: anchor.x,\n width: anchor.width,\n maxHeight: Math.max(Math.min(maxHeight, downwards ? below : above), MIN_HEIGHT),\n ...(downwards ? { top: anchor.y + anchor.height + GAP } : { bottom: windowHeight - anchor.y + GAP }),\n };\n }, [anchor, keyboardHeight, maxHeight, windowHeight]);\n\n const renderLeading = (option: SelectOption<T> | undefined, color: string) => {\n if (!option) return null;\n if (option.emoji) {\n return (\n <Text allowFontScaling={allowFontScaling} style={styles.emoji}>\n {option.emoji}\n </Text>\n );\n }\n return option.iconName ? <Icon name={option.iconName} size={20} color={color} /> : null;\n };\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Pressable\n ref={fieldRef}\n style={[styles.field, open && styles.fieldOpen, isDisabled && styles.fieldDisabled]}\n onPress={openList}\n disabled={isDisabled}\n accessibilityRole=\"combobox\"\n accessibilityLabel={label ?? placeholder ?? labels.selectPlaceholder}\n accessibilityValue={{ text: selectedOption?.label ?? '' }}\n accessibilityState={{ expanded: open, disabled: isDisabled }}\n >\n <View style={styles.fieldValue}>\n {renderLeading(selectedOption, colors.textLight)}\n <Text\n allowFontScaling={allowFontScaling}\n numberOfLines={1}\n style={[styles.fieldText, !selectedOption && styles.fieldPlaceholder]}\n >\n {selectedOption?.label || placeholder || labels.selectPlaceholder}\n </Text>\n </View>\n {isLoading ? (\n <ActivityIndicator size=\"small\" color={colors.primary} />\n ) : (\n <Icon name={open ? 'chevron-up' : 'chevron-down'} size={20} color={colors.textLight} />\n )}\n </Pressable>\n\n {open && (\n <Modal visible transparent animationType=\"fade\" statusBarTranslucent onRequestClose={close}>\n <Pressable style={styles.backdrop} onPress={close} testID=\"select-backdrop\" />\n\n <View style={[styles.sheet, placement]} accessibilityRole=\"radiogroup\" testID=\"select-list\">\n {searchable && (\n <TextInput\n allowFontScaling={allowFontScaling}\n style={styles.search}\n value={query}\n onChangeText={setQuery}\n placeholder={searchPlaceholder ?? labels.search}\n placeholderTextColor={colors.textLight}\n autoFocus\n accessibilityLabel={searchPlaceholder ?? labels.search}\n />\n )}\n\n <ScrollView keyboardShouldPersistTaps=\"handled\" showsVerticalScrollIndicator={false} bounces={false}>\n {visibleOptions.map((option, index) => {\n const isSelected = option.value === selectedValue;\n return (\n <Pressable\n key={String(option.value)}\n style={[\n styles.optionRow,\n index === visibleOptions.length - 1 && styles.optionRowLast,\n isSelected && styles.optionRowSelected,\n option.disabled && styles.optionRowDisabled,\n ]}\n onPress={() => {\n onSelect(option.value);\n close();\n }}\n disabled={option.disabled}\n accessibilityRole=\"radio\"\n accessibilityLabel={option.label}\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled: !!option.disabled }}\n >\n <View style={styles.optionLabel}>\n {renderLeading(option, isSelected ? colors.primary : colors.textLight)}\n <Text\n allowFontScaling={allowFontScaling}\n numberOfLines={1}\n style={[styles.optionText, isSelected && styles.optionTextSelected]}\n >\n {option.label}\n </Text>\n </View>\n {isSelected && <Icon name=\"check\" size={18} color={colors.primary} />}\n </Pressable>\n );\n })}\n\n {visibleOptions.length === 0 && (\n <Text allowFontScaling={allowFontScaling} style={styles.empty}>\n {labels.noResultsTitle}\n </Text>\n )}\n </ScrollView>\n </View>\n </Modal>\n )}\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "Skeleton",
3
+ "slug": "skeleton",
4
+ "type": "component",
5
+ "category": "atoms",
6
+ "exportedNames": [
7
+ "Skeleton"
8
+ ],
9
+ "files": [
10
+ {
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"
13
+ }
14
+ ],
15
+ "registryDependencies": [
16
+ "core"
17
+ ]
18
+ }
@@ -4,12 +4,13 @@
4
4
  "type": "component",
5
5
  "category": "molecules",
6
6
  "exportedNames": [
7
- "Stepper"
7
+ "Stepper",
8
+ "sanitizeNumeric"
8
9
  ],
9
10
  "files": [
10
11
  {
11
12
  "path": "molecules/Stepper.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { TextInput, 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 StepperProps {\n value: string;\n onChangeText: (text: string) => void;\n onIncrement: () => void;\n onDecrement: () => void;\n size?: 'sm' | 'md';\n style?: StyleProp<ViewStyle>;\n incrementAccessibilityLabel?: string;\n decrementAccessibilityLabel?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n control: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.tokens.radius.md },\n btn: { height: '100%', justifyContent: 'center', alignItems: 'center', backgroundColor: theme.colors.inputBackground },\n btnSm: { backgroundColor: 'transparent' },\n input: { flex: 1, textAlign: 'center', fontSize: theme.tokens.fontSize.lg, color: theme.colors.text, height: '100%' },\n inputSm: { flex: 0, width: 50, borderLeftWidth: 1, borderRightWidth: 1, borderColor: theme.colors.border },\n}));\n\n/** Stepper: botón \"-\" + input numérico editable + botón \"+\". */\nexport const Stepper = memo(\n ({\n value,\n onChangeText,\n onIncrement,\n onDecrement,\n size = 'md',\n style,\n incrementAccessibilityLabel = 'Increment',\n decrementAccessibilityLabel = 'Decrement',\n }: StepperProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isSmall = size === 'sm';\n const btnSize = isSmall ? 36 : 48;\n const iconSize = isSmall ? 18 : 20;\n\n return (\n <View style={[styles.control, { height: isSmall ? 36 : 48 }, style]}>\n <TouchableOpacity\n onPress={onDecrement}\n style={[styles.btn, { width: btnSize }, isSmall && styles.btnSm]}\n accessibilityRole=\"button\"\n accessibilityLabel={decrementAccessibilityLabel}\n >\n <Icon name=\"minus\" size={iconSize} color={colors.primary} />\n </TouchableOpacity>\n <TextInput\n allowFontScaling={allowFontScaling}\n style={[styles.input, isSmall && styles.inputSm]}\n value={value}\n onChangeText={onChangeText}\n keyboardType=\"numeric\"\n selectTextOnFocus\n accessibilityLabel=\"Quantity\"\n />\n <TouchableOpacity\n onPress={onIncrement}\n style={[styles.btn, { width: btnSize }, isSmall && styles.btnSm]}\n accessibilityRole=\"button\"\n accessibilityLabel={incrementAccessibilityLabel}\n >\n <Icon name=\"plus\" size={iconSize} color={colors.primary} />\n </TouchableOpacity>\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 /** 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
14
  }
14
15
  ],
15
16
  "registryDependencies": [
@@ -2,17 +2,18 @@
2
2
  "name": "ThemeToggle",
3
3
  "slug": "theme-toggle",
4
4
  "type": "component",
5
- "category": "molecules",
5
+ "category": "organisms",
6
6
  "exportedNames": [
7
7
  "ThemeToggle"
8
8
  ],
9
9
  "files": [
10
10
  {
11
- "path": "molecules/ThemeToggle.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useThemeMode, type ThemeMode } from '../theme/UIProvider';\n\nexport interface ThemeToggleProps {\n style?: StyleProp<ViewStyle>;\n labels?: Partial<Record<ThemeMode, string>>;\n}\n\nconst MODES: ThemeMode[] = ['system', 'light', 'dark'];\nconst DEFAULT_LABELS: Record<ThemeMode, string> = { system: 'Auto', light: 'Light', dark: 'Dark' };\n\nconst useStyles = createStyles((theme) => ({\n track: {\n flexDirection: 'row',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.md,\n padding: 3,\n },\n segment: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.sm,\n alignItems: 'center',\n },\n segmentActive: { backgroundColor: theme.colors.surface, ...theme.tokens.shadow.sm },\n label: { fontSize: theme.tokens.fontSize.sm, fontWeight: theme.tokens.fontWeight.medium, color: theme.colors.textLight },\n labelActive: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n}));\n\n/**\n * ThemeToggle\n * Segmented control de 3 estados (system/light/dark) conectado a\n * UIProvider.setMode. Sin persistencia — el consumidor la resuelve vía\n * onModeChange en <UIProvider>.\n */\nexport const ThemeToggle = memo(({ style, labels: labelOverrides }: ThemeToggleProps) => {\n const { mode, setMode } = useThemeMode();\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const labels = { ...DEFAULT_LABELS, ...labelOverrides };\n\n return (\n <View style={[styles.track, style]} accessibilityRole=\"tablist\">\n {MODES.map((m) => {\n const active = mode === m;\n return (\n <TouchableOpacity\n key={m}\n style={[styles.segment, active && styles.segmentActive]}\n onPress={() => setMode(m)}\n accessibilityRole=\"tab\"\n accessibilityState={{ selected: active }}\n accessibilityLabel={labels[m]}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.label, active && styles.labelActive]}>\n {labels[m]}\n </Text>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n});\nThemeToggle.displayName = 'ThemeToggle';\n"
11
+ "path": "organisms/ThemeToggle.tsx",
12
+ "content": "import React, { memo, useMemo } from 'react';\nimport { type StyleProp, type ViewStyle } from 'react-native';\nimport { useThemeMode, type ThemeMode } from '../theme/UIProvider';\nimport { SegmentedControl } from '../molecules/SegmentedControl';\n\nexport interface ThemeToggleProps {\n style?: StyleProp<ViewStyle>;\n labels?: Partial<Record<ThemeMode, string>>;\n testID?: string;\n}\n\nconst MODES: ThemeMode[] = ['system', 'light', 'dark'];\nconst DEFAULT_LABELS: Record<ThemeMode, string> = { system: 'Auto', light: 'Light', dark: 'Dark' };\n\n/**\n * ThemeToggle\n * Conecta un SegmentedControl con UIProvider.setMode. Sin persistencia — el\n * consumidor la resuelve vía onModeChange en <UIProvider>.\n */\nexport const ThemeToggle = memo(({ style, labels: labelOverrides, testID }: ThemeToggleProps) => {\n const { mode, setMode } = useThemeMode();\n\n const options = useMemo(() => {\n const labels = { ...DEFAULT_LABELS, ...labelOverrides };\n return MODES.map((value) => ({ value, label: labels[value] }));\n }, [labelOverrides]);\n\n return <SegmentedControl options={options} value={mode} onChange={setMode} style={style} testID={testID} />;\n});\nThemeToggle.displayName = 'ThemeToggle';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
16
- "core"
16
+ "core",
17
+ "segmented-control"
17
18
  ]
18
19
  }
@@ -5,16 +5,24 @@
5
5
  "category": "organisms",
6
6
  "exportedNames": [
7
7
  "ToastProvider",
8
- "useToast"
8
+ "useToast",
9
+ "showToast",
10
+ "hideToast",
11
+ "hideAllToasts"
9
12
  ],
10
13
  "files": [
11
14
  {
12
15
  "path": "organisms/ToastProvider.tsx",
13
- "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';\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);\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 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 /** 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"
17
+ },
18
+ {
19
+ "path": "organisms/imperativeBridge.ts",
20
+ "content": "/**\n * Puente entre el proveedor montado y el código que no es un componente\n * (servicios, capa de API, interceptores). El proveedor se registra al montar y\n * se borra al desmontar, así que fuera del árbol nunca queda una referencia\n * viva a un contexto muerto.\n */\nexport interface ImperativeBridge<T> {\n set: (value: T | null) => void;\n get: () => T | null;\n}\n\nexport function createImperativeBridge<T>(providerName: string): ImperativeBridge<T> {\n let current: T | null = null;\n\n return {\n set: (value) => {\n current = value;\n },\n get: () => {\n if (!current && __DEV__) {\n console.warn(\n `orn-ui: se llamó a la API imperativa sin un <${providerName}> montado. La llamada se ignoró.`\n );\n }\n return current;\n },\n };\n}\n"
14
21
  }
15
22
  ],
16
23
  "registryDependencies": [
17
24
  "core",
18
- "toast"
25
+ "toast",
26
+ "transition"
19
27
  ]
20
28
  }
@@ -9,10 +9,11 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Toast.tsx",
12
- "content": "import React, { memo, useEffect, useRef } from 'react';\nimport { Animated, Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport 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 const progress = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n Animated.spring(progress, { toValue: 1, useNativeDriver: true, speed: 14, bounciness: 4 }).start();\n }, [progress]);\n\n return (\n <Animated.View\n onLayout={(e) => onHeight?.(e.nativeEvent.layout.height)}\n style={{\n opacity: progress,\n transform: [{ translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [-16, 0] }) }],\n }}\n >\n {children}\n </Animated.View>\n );\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 /** 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"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
16
- "core"
16
+ "core",
17
+ "transition"
17
18
  ]
18
19
  }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "Transition",
3
+ "slug": "transition",
4
+ "type": "component",
5
+ "category": "atoms",
6
+ "exportedNames": [
7
+ "Transition",
8
+ "useReduceMotion"
9
+ ],
10
+ "files": [
11
+ {
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"
14
+ }
15
+ ],
16
+ "registryDependencies": [
17
+ "core"
18
+ ]
19
+ }
@@ -9,12 +9,13 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Wizard.tsx",
12
- "content": "import React, { useCallback, useMemo, 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';\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 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 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 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 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 style={styles.content}\n contentContainerStyle={styles.contentInner}\n keyboardShouldPersistTaps=\"handled\"\n automaticallyAdjustKeyboardInsets\n showsVerticalScrollIndicator={false}\n >\n {activeStep?.content}\n </ScrollView>\n ) : (\n <View style={styles.content}>{activeStep?.content}</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 /** 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 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": [
16
16
  "core",
17
17
  "steps",
18
- "button"
18
+ "button",
19
+ "transition"
19
20
  ]
20
21
  }
@@ -1,12 +1,22 @@
1
1
  import React, { memo, useRef } from 'react';
2
- import { Animated, Pressable, type PressableProps, type GestureResponderEvent } from 'react-native';
2
+ import {
3
+ Animated,
4
+ Pressable,
5
+ type GestureResponderEvent,
6
+ type PressableProps,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ } from 'react-native';
3
10
 
4
- export interface PressableScaleProps extends PressableProps {
11
+ export interface PressableScaleProps extends Omit<PressableProps, 'style'> {
5
12
  /** Escala objetivo al presionar. @default 0.96 */
6
13
  scaleTo?: number;
14
+ style?: StyleProp<ViewStyle>;
7
15
  children: React.ReactNode;
8
16
  }
9
17
 
18
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
19
+
10
20
  /**
11
21
  * PressableScale
12
22
  * Feedback táctil de "achicarse" al presionar, con Animated.spring de RN
@@ -37,15 +47,15 @@ export const PressableScale = memo(
37
47
  };
38
48
 
39
49
  return (
40
- <Pressable
50
+ <AnimatedPressable
41
51
  {...props}
42
52
  disabled={disabled}
43
53
  onPressIn={handlePressIn}
44
54
  onPressOut={handlePressOut}
45
- style={style}
55
+ style={[style, { transform: [{ scale }] }]}
46
56
  >
47
- <Animated.View style={{ transform: [{ scale }] }}>{children}</Animated.View>
48
- </Pressable>
57
+ {children}
58
+ </AnimatedPressable>
49
59
  );
50
60
  }
51
61
  );
@@ -0,0 +1,114 @@
1
+ import React, { memo, useEffect, useRef } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ View,
6
+ type DimensionValue,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+ import { createStyles } from '../theme/createStyles';
11
+ import { useTheme } from '../theme/UIProvider';
12
+
13
+ export type SkeletonVariant = 'rect' | 'text' | 'circle';
14
+
15
+ export interface SkeletonProps {
16
+ /** @default '100%' ('circle' lo usa también como diámetro) */
17
+ width?: DimensionValue;
18
+ /** @default 16 ('text' usa el alto de línea; 'circle', el ancho) */
19
+ height?: DimensionValue;
20
+ /** @default 'rect' */
21
+ variant?: SkeletonVariant;
22
+ /** Override del radius del theme. */
23
+ radius?: number;
24
+ /** Líneas apiladas, sólo para variant="text". @default 1 */
25
+ lines?: number;
26
+ /** Ancho de la última línea, para que el párrafo no termine en bloque. @default '60%' */
27
+ lastLineWidth?: DimensionValue;
28
+ /** @default true */
29
+ animated?: boolean;
30
+ style?: StyleProp<ViewStyle>;
31
+ testID?: string;
32
+ }
33
+
34
+ const useStyles = createStyles((theme) => ({
35
+ block: { backgroundColor: theme.colors.skeleton },
36
+ lines: { gap: theme.tokens.spacing.sm },
37
+ }));
38
+
39
+ /**
40
+ * Skeleton
41
+ * Placeholder que late mientras llega el contenido real. Reserva el espacio
42
+ * exacto de lo que reemplaza, así la pantalla no salta cuando aparecen los
43
+ * datos — que es justamente lo que un Spinner centrado no puede hacer.
44
+ */
45
+ export const Skeleton = memo(
46
+ ({
47
+ width = '100%',
48
+ height = 16,
49
+ variant = 'rect',
50
+ radius,
51
+ lines = 1,
52
+ lastLineWidth = '60%',
53
+ animated = true,
54
+ style,
55
+ testID,
56
+ }: SkeletonProps) => {
57
+ const theme = useTheme();
58
+ const styles = useStyles();
59
+ const pulse = useRef(new Animated.Value(0)).current;
60
+
61
+ useEffect(() => {
62
+ if (!animated) return;
63
+ const loop = Animated.loop(
64
+ Animated.sequence([
65
+ Animated.timing(pulse, {
66
+ toValue: 1,
67
+ duration: 700,
68
+ useNativeDriver: true,
69
+ easing: Easing.inOut(Easing.quad),
70
+ }),
71
+ Animated.timing(pulse, {
72
+ toValue: 0,
73
+ duration: 700,
74
+ useNativeDriver: true,
75
+ easing: Easing.inOut(Easing.quad),
76
+ }),
77
+ ])
78
+ );
79
+ loop.start();
80
+ return () => loop.stop();
81
+ }, [animated, pulse]);
82
+
83
+ const isCircle = variant === 'circle';
84
+ const isText = variant === 'text';
85
+ const opacity = animated ? pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 0.4] }) : 1;
86
+ const resolvedHeight = isCircle ? width : isText ? theme.tokens.fontSize.md : height;
87
+ const resolvedRadius =
88
+ radius ?? (isCircle ? theme.tokens.radius.full : isText ? theme.tokens.radius.xs : theme.tokens.radius.sm);
89
+
90
+ const barStyle = (barWidth: DimensionValue): StyleProp<ViewStyle> => [
91
+ styles.block,
92
+ { width: barWidth, height: resolvedHeight, borderRadius: resolvedRadius, opacity },
93
+ ];
94
+
95
+ const a11y = { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' as const };
96
+
97
+ if (isText && lines > 1) {
98
+ return (
99
+ <View style={[styles.lines, style]} testID={testID} {...a11y}>
100
+ {Array.from({ length: lines }, (_, i) => (
101
+ <Animated.View
102
+ key={i}
103
+ testID={testID && `${testID}-${i}`}
104
+ style={barStyle(i === lines - 1 ? lastLineWidth : width)}
105
+ />
106
+ ))}
107
+ </View>
108
+ );
109
+ }
110
+
111
+ return <Animated.View style={[barStyle(width), style]} testID={testID} {...a11y} />;
112
+ }
113
+ );
114
+ Skeleton.displayName = 'Skeleton';