orn-ui 0.2.9 → 0.3.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 (552) hide show
  1. package/AGENTS.md +67 -52
  2. package/README.md +5 -9
  3. package/lib/commonjs/atoms/Avatar.js +77 -5
  4. package/lib/commonjs/atoms/Avatar.js.map +1 -1
  5. package/lib/commonjs/atoms/Badge.js +17 -8
  6. package/lib/commonjs/atoms/Badge.js.map +1 -1
  7. package/lib/commonjs/atoms/Button.js +108 -39
  8. package/lib/commonjs/atoms/Button.js.map +1 -1
  9. package/lib/commonjs/atoms/Card.js +24 -2
  10. package/lib/commonjs/atoms/Card.js.map +1 -1
  11. package/lib/commonjs/atoms/Checkbox.js +41 -10
  12. package/lib/commonjs/atoms/Checkbox.js.map +1 -1
  13. package/lib/commonjs/atoms/Divider.js +30 -5
  14. package/lib/commonjs/atoms/Divider.js.map +1 -1
  15. package/lib/commonjs/atoms/EmptyState.js +58 -20
  16. package/lib/commonjs/atoms/EmptyState.js.map +1 -1
  17. package/lib/commonjs/atoms/Fab.js +32 -12
  18. package/lib/commonjs/atoms/Fab.js.map +1 -1
  19. package/lib/commonjs/atoms/Gradient.js +102 -36
  20. package/lib/commonjs/atoms/Gradient.js.map +1 -1
  21. package/lib/commonjs/atoms/IconButton.js +25 -4
  22. package/lib/commonjs/atoms/IconButton.js.map +1 -1
  23. package/lib/commonjs/atoms/Image.js +61 -25
  24. package/lib/commonjs/atoms/Image.js.map +1 -1
  25. package/lib/commonjs/atoms/Input.js +105 -17
  26. package/lib/commonjs/atoms/Input.js.map +1 -1
  27. package/lib/commonjs/atoms/KeyValueRow.js +37 -9
  28. package/lib/commonjs/atoms/KeyValueRow.js.map +1 -1
  29. package/lib/commonjs/atoms/PressableScale.js +25 -5
  30. package/lib/commonjs/atoms/PressableScale.js.map +1 -1
  31. package/lib/commonjs/atoms/ShinyText.js +29 -29
  32. package/lib/commonjs/atoms/Skeleton.js +28 -13
  33. package/lib/commonjs/atoms/Skeleton.js.map +1 -1
  34. package/lib/commonjs/atoms/Spinner.js +81 -22
  35. package/lib/commonjs/atoms/Spinner.js.map +1 -1
  36. package/lib/commonjs/atoms/Text.js +56 -5
  37. package/lib/commonjs/atoms/Text.js.map +1 -1
  38. package/lib/commonjs/atoms/Transition.js +46 -43
  39. package/lib/commonjs/atoms/Transition.js.map +1 -1
  40. package/lib/commonjs/atoms/index.js +6 -0
  41. package/lib/commonjs/atoms/index.js.map +1 -1
  42. package/lib/commonjs/atoms/inputValidation.js +56 -0
  43. package/lib/commonjs/atoms/inputValidation.js.map +1 -0
  44. package/lib/commonjs/icons/Icon.js +4 -4
  45. package/lib/commonjs/icons/defaultIcons.js +576 -24
  46. package/lib/commonjs/icons/defaultIcons.js.map +1 -1
  47. package/lib/commonjs/icons/index.js +6 -0
  48. package/lib/commonjs/icons/index.js.map +1 -1
  49. package/lib/commonjs/molecules/AvatarHeader.js +47 -14
  50. package/lib/commonjs/molecules/AvatarHeader.js.map +1 -1
  51. package/lib/commonjs/molecules/FormActions.js +25 -9
  52. package/lib/commonjs/molecules/FormActions.js.map +1 -1
  53. package/lib/commonjs/molecules/InfoRow.js +30 -14
  54. package/lib/commonjs/molecules/InfoRow.js.map +1 -1
  55. package/lib/commonjs/molecules/OptionCard.js +37 -22
  56. package/lib/commonjs/molecules/OptionCard.js.map +1 -1
  57. package/lib/commonjs/molecules/SegmentedControl.js +103 -14
  58. package/lib/commonjs/molecules/SegmentedControl.js.map +1 -1
  59. package/lib/commonjs/molecules/Stepper.js +61 -16
  60. package/lib/commonjs/molecules/Stepper.js.map +1 -1
  61. package/lib/commonjs/molecules/Steps.js +105 -41
  62. package/lib/commonjs/molecules/Steps.js.map +1 -1
  63. package/lib/commonjs/molecules/SymmetricGrid.js +26 -18
  64. package/lib/commonjs/molecules/SymmetricGrid.js.map +1 -1
  65. package/lib/commonjs/organisms/Alert.js +49 -35
  66. package/lib/commonjs/organisms/Alert.js.map +1 -1
  67. package/lib/commonjs/organisms/AlertProvider.js +10 -10
  68. package/lib/commonjs/organisms/BottomSheet.js +137 -85
  69. package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
  70. package/lib/commonjs/organisms/Chart.js +197 -156
  71. package/lib/commonjs/organisms/Chart.js.map +1 -1
  72. package/lib/commonjs/organisms/DateField.js +36 -20
  73. package/lib/commonjs/organisms/DateField.js.map +1 -1
  74. package/lib/commonjs/organisms/DatePicker.js +56 -33
  75. package/lib/commonjs/organisms/DatePicker.js.map +1 -1
  76. package/lib/commonjs/organisms/List.js +62 -34
  77. package/lib/commonjs/organisms/List.js.map +1 -1
  78. package/lib/commonjs/organisms/Modal.js +120 -82
  79. package/lib/commonjs/organisms/Modal.js.map +1 -1
  80. package/lib/commonjs/organisms/NavigationBar.js +63 -24
  81. package/lib/commonjs/organisms/NavigationBar.js.map +1 -1
  82. package/lib/commonjs/organisms/OptionWheel.js +64 -64
  83. package/lib/commonjs/organisms/OptionWheel.js.map +1 -1
  84. package/lib/commonjs/organisms/ReorderableList.js +48 -27
  85. package/lib/commonjs/organisms/ReorderableList.js.map +1 -1
  86. package/lib/commonjs/organisms/Screen.js +68 -35
  87. package/lib/commonjs/organisms/Screen.js.map +1 -1
  88. package/lib/commonjs/organisms/SearchList.js +56 -26
  89. package/lib/commonjs/organisms/SearchList.js.map +1 -1
  90. package/lib/commonjs/organisms/Select.js +45 -24
  91. package/lib/commonjs/organisms/Select.js.map +1 -1
  92. package/lib/commonjs/organisms/Slides.js +36 -23
  93. package/lib/commonjs/organisms/Slides.js.map +1 -1
  94. package/lib/commonjs/organisms/ThemeToggle.js +10 -9
  95. package/lib/commonjs/organisms/ThemeToggle.js.map +1 -1
  96. package/lib/commonjs/organisms/Timeline.js +73 -56
  97. package/lib/commonjs/organisms/Timeline.js.map +1 -1
  98. package/lib/commonjs/organisms/Toast.js +46 -15
  99. package/lib/commonjs/organisms/Toast.js.map +1 -1
  100. package/lib/commonjs/organisms/ToastProvider.js +14 -10
  101. package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
  102. package/lib/commonjs/organisms/Wizard.js +40 -15
  103. package/lib/commonjs/organisms/Wizard.js.map +1 -1
  104. package/lib/commonjs/organisms/chart/frame.js +25 -25
  105. package/lib/commonjs/organisms/chart/frame.js.map +1 -1
  106. package/lib/commonjs/organisms/chart/marks.js +42 -42
  107. package/lib/commonjs/organisms/chart/math.js +84 -85
  108. package/lib/commonjs/organisms/chart/math.js.map +1 -1
  109. package/lib/commonjs/organisms/chart/palette.js +28 -28
  110. package/lib/commonjs/organisms/imperativeBridge.js +5 -5
  111. package/lib/commonjs/organisms/imperativeBridge.js.map +1 -1
  112. package/lib/commonjs/organisms/useSlides.js +53 -21
  113. package/lib/commonjs/organisms/useSlides.js.map +1 -1
  114. package/lib/commonjs/safe-area/SafeAreaUIProvider.js +9 -66
  115. package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -1
  116. package/lib/commonjs/safe-area/index.js.map +1 -1
  117. package/lib/commonjs/theme/KeyboardAvoidance.js +206 -0
  118. package/lib/commonjs/theme/KeyboardAvoidance.js.map +1 -0
  119. package/lib/commonjs/theme/NativeOverlay.js +263 -0
  120. package/lib/commonjs/theme/NativeOverlay.js.map +1 -0
  121. package/lib/commonjs/theme/UIProvider.js +86 -33
  122. package/lib/commonjs/theme/UIProvider.js.map +1 -1
  123. package/lib/commonjs/theme/colors.js +17 -17
  124. package/lib/commonjs/theme/colors.js.map +1 -1
  125. package/lib/commonjs/theme/createStyles.js +19 -16
  126. package/lib/commonjs/theme/createStyles.js.map +1 -1
  127. package/lib/commonjs/theme/createTheme.js +6 -6
  128. package/lib/commonjs/theme/index.js +6 -0
  129. package/lib/commonjs/theme/index.js.map +1 -1
  130. package/lib/commonjs/theme/insets.js +1 -1
  131. package/lib/commonjs/theme/labels.js +29 -1
  132. package/lib/commonjs/theme/labels.js.map +1 -1
  133. package/lib/commonjs/theme/palettes.js +25 -23
  134. package/lib/commonjs/theme/palettes.js.map +1 -1
  135. package/lib/commonjs/theme/tokens.js +28 -14
  136. package/lib/commonjs/theme/tokens.js.map +1 -1
  137. package/lib/commonjs/theme/useKeyboardField.js +36 -0
  138. package/lib/commonjs/theme/useKeyboardField.js.map +1 -0
  139. package/lib/commonjs/theme/useReduceMotion.js +31 -0
  140. package/lib/commonjs/theme/useReduceMotion.js.map +1 -0
  141. package/lib/module/atoms/Avatar.js +79 -8
  142. package/lib/module/atoms/Avatar.js.map +1 -1
  143. package/lib/module/atoms/Badge.js +17 -8
  144. package/lib/module/atoms/Badge.js.map +1 -1
  145. package/lib/module/atoms/Button.js +110 -41
  146. package/lib/module/atoms/Button.js.map +1 -1
  147. package/lib/module/atoms/Card.js +24 -2
  148. package/lib/module/atoms/Card.js.map +1 -1
  149. package/lib/module/atoms/Checkbox.js +42 -11
  150. package/lib/module/atoms/Checkbox.js.map +1 -1
  151. package/lib/module/atoms/Divider.js +31 -6
  152. package/lib/module/atoms/Divider.js.map +1 -1
  153. package/lib/module/atoms/EmptyState.js +58 -20
  154. package/lib/module/atoms/EmptyState.js.map +1 -1
  155. package/lib/module/atoms/Fab.js +33 -13
  156. package/lib/module/atoms/Fab.js.map +1 -1
  157. package/lib/module/atoms/Gradient.js +103 -37
  158. package/lib/module/atoms/Gradient.js.map +1 -1
  159. package/lib/module/atoms/IconButton.js +25 -4
  160. package/lib/module/atoms/IconButton.js.map +1 -1
  161. package/lib/module/atoms/Image.js +61 -25
  162. package/lib/module/atoms/Image.js.map +1 -1
  163. package/lib/module/atoms/Input.js +108 -20
  164. package/lib/module/atoms/Input.js.map +1 -1
  165. package/lib/module/atoms/KeyValueRow.js +37 -9
  166. package/lib/module/atoms/KeyValueRow.js.map +1 -1
  167. package/lib/module/atoms/PressableScale.js +25 -5
  168. package/lib/module/atoms/PressableScale.js.map +1 -1
  169. package/lib/module/atoms/ShinyText.js +29 -29
  170. package/lib/module/atoms/Skeleton.js +30 -13
  171. package/lib/module/atoms/Skeleton.js.map +1 -1
  172. package/lib/module/atoms/Spinner.js +82 -23
  173. package/lib/module/atoms/Spinner.js.map +1 -1
  174. package/lib/module/atoms/Text.js +56 -5
  175. package/lib/module/atoms/Text.js.map +1 -1
  176. package/lib/module/atoms/Transition.js +41 -42
  177. package/lib/module/atoms/Transition.js.map +1 -1
  178. package/lib/module/atoms/index.js +1 -1
  179. package/lib/module/atoms/index.js.map +1 -1
  180. package/lib/module/atoms/inputValidation.js +52 -0
  181. package/lib/module/atoms/inputValidation.js.map +1 -0
  182. package/lib/module/icons/Icon.js +4 -4
  183. package/lib/module/icons/defaultIcons.js +574 -24
  184. package/lib/module/icons/defaultIcons.js.map +1 -1
  185. package/lib/module/icons/index.js +1 -1
  186. package/lib/module/icons/index.js.map +1 -1
  187. package/lib/module/molecules/AvatarHeader.js +47 -14
  188. package/lib/module/molecules/AvatarHeader.js.map +1 -1
  189. package/lib/module/molecules/FormActions.js +25 -9
  190. package/lib/module/molecules/FormActions.js.map +1 -1
  191. package/lib/module/molecules/InfoRow.js +30 -14
  192. package/lib/module/molecules/InfoRow.js.map +1 -1
  193. package/lib/module/molecules/OptionCard.js +37 -22
  194. package/lib/module/molecules/OptionCard.js.map +1 -1
  195. package/lib/module/molecules/SegmentedControl.js +107 -18
  196. package/lib/module/molecules/SegmentedControl.js.map +1 -1
  197. package/lib/module/molecules/Stepper.js +63 -18
  198. package/lib/module/molecules/Stepper.js.map +1 -1
  199. package/lib/module/molecules/Steps.js +107 -43
  200. package/lib/module/molecules/Steps.js.map +1 -1
  201. package/lib/module/molecules/SymmetricGrid.js +26 -18
  202. package/lib/module/molecules/SymmetricGrid.js.map +1 -1
  203. package/lib/module/organisms/Alert.js +51 -37
  204. package/lib/module/organisms/Alert.js.map +1 -1
  205. package/lib/module/organisms/AlertProvider.js +10 -10
  206. package/lib/module/organisms/BottomSheet.js +142 -88
  207. package/lib/module/organisms/BottomSheet.js.map +1 -1
  208. package/lib/module/organisms/Chart.js +199 -158
  209. package/lib/module/organisms/Chart.js.map +1 -1
  210. package/lib/module/organisms/DateField.js +36 -20
  211. package/lib/module/organisms/DateField.js.map +1 -1
  212. package/lib/module/organisms/DatePicker.js +56 -33
  213. package/lib/module/organisms/DatePicker.js.map +1 -1
  214. package/lib/module/organisms/List.js +62 -34
  215. package/lib/module/organisms/List.js.map +1 -1
  216. package/lib/module/organisms/Modal.js +122 -84
  217. package/lib/module/organisms/Modal.js.map +1 -1
  218. package/lib/module/organisms/NavigationBar.js +64 -25
  219. package/lib/module/organisms/NavigationBar.js.map +1 -1
  220. package/lib/module/organisms/OptionWheel.js +64 -64
  221. package/lib/module/organisms/OptionWheel.js.map +1 -1
  222. package/lib/module/organisms/ReorderableList.js +48 -27
  223. package/lib/module/organisms/ReorderableList.js.map +1 -1
  224. package/lib/module/organisms/Screen.js +70 -37
  225. package/lib/module/organisms/Screen.js.map +1 -1
  226. package/lib/module/organisms/SearchList.js +56 -26
  227. package/lib/module/organisms/SearchList.js.map +1 -1
  228. package/lib/module/organisms/Select.js +46 -25
  229. package/lib/module/organisms/Select.js.map +1 -1
  230. package/lib/module/organisms/Slides.js +37 -24
  231. package/lib/module/organisms/Slides.js.map +1 -1
  232. package/lib/module/organisms/ThemeToggle.js +11 -10
  233. package/lib/module/organisms/ThemeToggle.js.map +1 -1
  234. package/lib/module/organisms/Timeline.js +73 -56
  235. package/lib/module/organisms/Timeline.js.map +1 -1
  236. package/lib/module/organisms/Toast.js +47 -16
  237. package/lib/module/organisms/Toast.js.map +1 -1
  238. package/lib/module/organisms/ToastProvider.js +15 -11
  239. package/lib/module/organisms/ToastProvider.js.map +1 -1
  240. package/lib/module/organisms/Wizard.js +41 -16
  241. package/lib/module/organisms/Wizard.js.map +1 -1
  242. package/lib/module/organisms/chart/frame.js +25 -25
  243. package/lib/module/organisms/chart/frame.js.map +1 -1
  244. package/lib/module/organisms/chart/marks.js +42 -42
  245. package/lib/module/organisms/chart/math.js +84 -85
  246. package/lib/module/organisms/chart/math.js.map +1 -1
  247. package/lib/module/organisms/chart/palette.js +28 -28
  248. package/lib/module/organisms/imperativeBridge.js +5 -5
  249. package/lib/module/organisms/imperativeBridge.js.map +1 -1
  250. package/lib/module/organisms/useSlides.js +53 -21
  251. package/lib/module/organisms/useSlides.js.map +1 -1
  252. package/lib/module/safe-area/SafeAreaUIProvider.js +10 -65
  253. package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -1
  254. package/lib/module/safe-area/index.js +1 -0
  255. package/lib/module/safe-area/index.js.map +1 -1
  256. package/lib/module/theme/KeyboardAvoidance.js +198 -0
  257. package/lib/module/theme/KeyboardAvoidance.js.map +1 -0
  258. package/lib/module/theme/NativeOverlay.js +257 -0
  259. package/lib/module/theme/NativeOverlay.js.map +1 -0
  260. package/lib/module/theme/UIProvider.js +88 -35
  261. package/lib/module/theme/UIProvider.js.map +1 -1
  262. package/lib/module/theme/colors.js +17 -17
  263. package/lib/module/theme/colors.js.map +1 -1
  264. package/lib/module/theme/createStyles.js +20 -16
  265. package/lib/module/theme/createStyles.js.map +1 -1
  266. package/lib/module/theme/createTheme.js +6 -6
  267. package/lib/module/theme/index.js +1 -1
  268. package/lib/module/theme/index.js.map +1 -1
  269. package/lib/module/theme/insets.js +1 -1
  270. package/lib/module/theme/labels.js +29 -1
  271. package/lib/module/theme/labels.js.map +1 -1
  272. package/lib/module/theme/palettes.js +25 -23
  273. package/lib/module/theme/palettes.js.map +1 -1
  274. package/lib/module/theme/tokens.js +27 -13
  275. package/lib/module/theme/tokens.js.map +1 -1
  276. package/lib/module/theme/useKeyboardField.js +33 -0
  277. package/lib/module/theme/useKeyboardField.js.map +1 -0
  278. package/lib/module/theme/useReduceMotion.js +28 -0
  279. package/lib/module/theme/useReduceMotion.js.map +1 -0
  280. package/lib/typescript/atoms/Avatar.d.ts +35 -5
  281. package/lib/typescript/atoms/Avatar.d.ts.map +1 -1
  282. package/lib/typescript/atoms/Badge.d.ts +14 -3
  283. package/lib/typescript/atoms/Badge.d.ts.map +1 -1
  284. package/lib/typescript/atoms/Button.d.ts +34 -3
  285. package/lib/typescript/atoms/Button.d.ts.map +1 -1
  286. package/lib/typescript/atoms/Card.d.ts +13 -2
  287. package/lib/typescript/atoms/Card.d.ts.map +1 -1
  288. package/lib/typescript/atoms/Checkbox.d.ts +26 -1
  289. package/lib/typescript/atoms/Checkbox.d.ts.map +1 -1
  290. package/lib/typescript/atoms/Divider.d.ts +18 -1
  291. package/lib/typescript/atoms/Divider.d.ts.map +1 -1
  292. package/lib/typescript/atoms/EmptyState.d.ts +30 -3
  293. package/lib/typescript/atoms/EmptyState.d.ts.map +1 -1
  294. package/lib/typescript/atoms/Fab.d.ts +24 -4
  295. package/lib/typescript/atoms/Fab.d.ts.map +1 -1
  296. package/lib/typescript/atoms/Gradient.d.ts +21 -15
  297. package/lib/typescript/atoms/Gradient.d.ts.map +1 -1
  298. package/lib/typescript/atoms/IconButton.d.ts +18 -4
  299. package/lib/typescript/atoms/IconButton.d.ts.map +1 -1
  300. package/lib/typescript/atoms/Image.d.ts +39 -17
  301. package/lib/typescript/atoms/Image.d.ts.map +1 -1
  302. package/lib/typescript/atoms/Input.d.ts +57 -7
  303. package/lib/typescript/atoms/Input.d.ts.map +1 -1
  304. package/lib/typescript/atoms/KeyValueRow.d.ts +16 -4
  305. package/lib/typescript/atoms/KeyValueRow.d.ts.map +1 -1
  306. package/lib/typescript/atoms/PressableScale.d.ts +12 -5
  307. package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
  308. package/lib/typescript/atoms/ShinyText.d.ts +17 -17
  309. package/lib/typescript/atoms/ShinyText.d.ts.map +1 -1
  310. package/lib/typescript/atoms/Skeleton.d.ts +12 -7
  311. package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
  312. package/lib/typescript/atoms/Spinner.d.ts +29 -1
  313. package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
  314. package/lib/typescript/atoms/Text.d.ts +46 -0
  315. package/lib/typescript/atoms/Text.d.ts.map +1 -1
  316. package/lib/typescript/atoms/Transition.d.ts +19 -15
  317. package/lib/typescript/atoms/Transition.d.ts.map +1 -1
  318. package/lib/typescript/atoms/index.d.ts +3 -3
  319. package/lib/typescript/atoms/index.d.ts.map +1 -1
  320. package/lib/typescript/atoms/inputValidation.d.ts +29 -0
  321. package/lib/typescript/atoms/inputValidation.d.ts.map +1 -0
  322. package/lib/typescript/icons/Icon.d.ts +4 -4
  323. package/lib/typescript/icons/defaultIcons.d.ts +19 -2
  324. package/lib/typescript/icons/defaultIcons.d.ts.map +1 -1
  325. package/lib/typescript/icons/index.d.ts +2 -2
  326. package/lib/typescript/icons/index.d.ts.map +1 -1
  327. package/lib/typescript/icons/types.d.ts +13 -3
  328. package/lib/typescript/icons/types.d.ts.map +1 -1
  329. package/lib/typescript/molecules/AvatarHeader.d.ts +23 -4
  330. package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
  331. package/lib/typescript/molecules/FormActions.d.ts +21 -4
  332. package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
  333. package/lib/typescript/molecules/InfoRow.d.ts +17 -3
  334. package/lib/typescript/molecules/InfoRow.d.ts.map +1 -1
  335. package/lib/typescript/molecules/OptionCard.d.ts +20 -5
  336. package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
  337. package/lib/typescript/molecules/SegmentedControl.d.ts +18 -2
  338. package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -1
  339. package/lib/typescript/molecules/Stepper.d.ts +23 -7
  340. package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
  341. package/lib/typescript/molecules/Steps.d.ts +18 -4
  342. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  343. package/lib/typescript/molecules/SymmetricGrid.d.ts +15 -5
  344. package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
  345. package/lib/typescript/organisms/Alert.d.ts +28 -2
  346. package/lib/typescript/organisms/Alert.d.ts.map +1 -1
  347. package/lib/typescript/organisms/AlertProvider.d.ts +10 -10
  348. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  349. package/lib/typescript/organisms/BottomSheet.d.ts +20 -5
  350. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  351. package/lib/typescript/organisms/Chart.d.ts +86 -75
  352. package/lib/typescript/organisms/Chart.d.ts.map +1 -1
  353. package/lib/typescript/organisms/DateField.d.ts +17 -3
  354. package/lib/typescript/organisms/DateField.d.ts.map +1 -1
  355. package/lib/typescript/organisms/DatePicker.d.ts +7 -11
  356. package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
  357. package/lib/typescript/organisms/List.d.ts +30 -3
  358. package/lib/typescript/organisms/List.d.ts.map +1 -1
  359. package/lib/typescript/organisms/Modal.d.ts +30 -3
  360. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  361. package/lib/typescript/organisms/NavigationBar.d.ts +26 -7
  362. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
  363. package/lib/typescript/organisms/OptionWheel.d.ts +20 -16
  364. package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -1
  365. package/lib/typescript/organisms/ReorderableList.d.ts +20 -11
  366. package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
  367. package/lib/typescript/organisms/Screen.d.ts +27 -8
  368. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  369. package/lib/typescript/organisms/SearchList.d.ts +46 -3
  370. package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
  371. package/lib/typescript/organisms/Select.d.ts +26 -6
  372. package/lib/typescript/organisms/Select.d.ts.map +1 -1
  373. package/lib/typescript/organisms/Slides.d.ts +44 -26
  374. package/lib/typescript/organisms/Slides.d.ts.map +1 -1
  375. package/lib/typescript/organisms/ThemeToggle.d.ts +6 -2
  376. package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -1
  377. package/lib/typescript/organisms/Timeline.d.ts +26 -9
  378. package/lib/typescript/organisms/Timeline.d.ts.map +1 -1
  379. package/lib/typescript/organisms/Toast.d.ts +19 -4
  380. package/lib/typescript/organisms/Toast.d.ts.map +1 -1
  381. package/lib/typescript/organisms/ToastProvider.d.ts +8 -8
  382. package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
  383. package/lib/typescript/organisms/Wizard.d.ts +24 -6
  384. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  385. package/lib/typescript/organisms/chart/frame.d.ts +21 -21
  386. package/lib/typescript/organisms/chart/frame.d.ts.map +1 -1
  387. package/lib/typescript/organisms/chart/marks.d.ts +28 -28
  388. package/lib/typescript/organisms/chart/marks.d.ts.map +1 -1
  389. package/lib/typescript/organisms/chart/math.d.ts +69 -70
  390. package/lib/typescript/organisms/chart/math.d.ts.map +1 -1
  391. package/lib/typescript/organisms/chart/palette.d.ts +2 -2
  392. package/lib/typescript/organisms/chart/palette.d.ts.map +1 -1
  393. package/lib/typescript/organisms/imperativeBridge.d.ts +4 -4
  394. package/lib/typescript/organisms/useSlides.d.ts +8 -16
  395. package/lib/typescript/organisms/useSlides.d.ts.map +1 -1
  396. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +9 -7
  397. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -1
  398. package/lib/typescript/safe-area/index.d.ts.map +1 -1
  399. package/lib/typescript/theme/KeyboardAvoidance.d.ts +62 -0
  400. package/lib/typescript/theme/KeyboardAvoidance.d.ts.map +1 -0
  401. package/lib/typescript/theme/NativeOverlay.d.ts +43 -0
  402. package/lib/typescript/theme/NativeOverlay.d.ts.map +1 -0
  403. package/lib/typescript/theme/UIProvider.d.ts +38 -20
  404. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  405. package/lib/typescript/theme/colors.d.ts +13 -13
  406. package/lib/typescript/theme/colors.d.ts.map +1 -1
  407. package/lib/typescript/theme/createStyles.d.ts +20 -14
  408. package/lib/typescript/theme/createStyles.d.ts.map +1 -1
  409. package/lib/typescript/theme/createTheme.d.ts +5 -5
  410. package/lib/typescript/theme/index.d.ts +1 -1
  411. package/lib/typescript/theme/index.d.ts.map +1 -1
  412. package/lib/typescript/theme/insets.d.ts +1 -1
  413. package/lib/typescript/theme/insets.d.ts.map +1 -1
  414. package/lib/typescript/theme/labels.d.ts +57 -2
  415. package/lib/typescript/theme/labels.d.ts.map +1 -1
  416. package/lib/typescript/theme/palettes.d.ts +25 -19
  417. package/lib/typescript/theme/palettes.d.ts.map +1 -1
  418. package/lib/typescript/theme/tokens.d.ts +15 -8
  419. package/lib/typescript/theme/tokens.d.ts.map +1 -1
  420. package/lib/typescript/theme/types.d.ts +7 -7
  421. package/lib/typescript/theme/types.d.ts.map +1 -1
  422. package/lib/typescript/theme/useKeyboardField.d.ts +17 -0
  423. package/lib/typescript/theme/useKeyboardField.d.ts.map +1 -0
  424. package/lib/typescript/theme/useReduceMotion.d.ts +7 -0
  425. package/lib/typescript/theme/useReduceMotion.d.ts.map +1 -0
  426. package/llms.txt +4 -4
  427. package/package.json +15 -14
  428. package/registry/alert-provider.json +4 -3
  429. package/registry/alert.json +3 -2
  430. package/registry/avatar-header.json +1 -1
  431. package/registry/avatar.json +3 -2
  432. package/registry/badge.json +1 -1
  433. package/registry/bottom-sheet.json +1 -1
  434. package/registry/button.json +3 -2
  435. package/registry/card.json +1 -1
  436. package/registry/chart.json +5 -5
  437. package/registry/checkbox.json +3 -2
  438. package/registry/core.json +31 -15
  439. package/registry/date-field.json +1 -1
  440. package/registry/date-picker.json +3 -2
  441. package/registry/divider.json +1 -1
  442. package/registry/empty-state.json +4 -2
  443. package/registry/fab.json +3 -2
  444. package/registry/form-actions.json +3 -2
  445. package/registry/gradient.json +1 -1
  446. package/registry/icon-button.json +3 -2
  447. package/registry/image.json +1 -1
  448. package/registry/index.json +79 -61
  449. package/registry/info-row.json +1 -1
  450. package/registry/input.json +5 -1
  451. package/registry/key-value-row.json +1 -1
  452. package/registry/list.json +3 -1
  453. package/registry/manifest.json +3 -1
  454. package/registry/modal.json +1 -1
  455. package/registry/navigation-bar.json +1 -1
  456. package/registry/option-card.json +3 -2
  457. package/registry/option-wheel.json +1 -1
  458. package/registry/pressable-scale.json +3 -2
  459. package/registry/reorderable-list.json +1 -1
  460. package/registry/screen.json +1 -1
  461. package/registry/search-list.json +7 -1
  462. package/registry/segmented-control.json +1 -1
  463. package/registry/select.json +1 -1
  464. package/registry/shiny-text.json +1 -1
  465. package/registry/skeleton.json +3 -2
  466. package/registry/slides.json +2 -2
  467. package/registry/spinner.json +1 -1
  468. package/registry/stepper.json +1 -1
  469. package/registry/steps.json +1 -1
  470. package/registry/symmetric-grid.json +1 -1
  471. package/registry/theme-toggle.json +1 -1
  472. package/registry/timeline.json +1 -1
  473. package/registry/title.json +1 -1
  474. package/registry/toast-provider.json +2 -2
  475. package/registry/toast.json +1 -1
  476. package/registry/transition.json +1 -1
  477. package/registry/wizard.json +1 -1
  478. package/src/atoms/Avatar.tsx +117 -26
  479. package/src/atoms/Badge.tsx +20 -9
  480. package/src/atoms/Button.tsx +102 -30
  481. package/src/atoms/Card.tsx +23 -3
  482. package/src/atoms/Checkbox.tsx +49 -12
  483. package/src/atoms/Divider.tsx +34 -4
  484. package/src/atoms/EmptyState.tsx +63 -16
  485. package/src/atoms/Fab.tsx +56 -9
  486. package/src/atoms/Gradient.tsx +92 -41
  487. package/src/atoms/IconButton.tsx +27 -4
  488. package/src/atoms/Image.tsx +74 -28
  489. package/src/atoms/Input.tsx +129 -19
  490. package/src/atoms/KeyValueRow.tsx +72 -28
  491. package/src/atoms/PressableScale.tsx +31 -7
  492. package/src/atoms/ShinyText.tsx +31 -31
  493. package/src/atoms/Skeleton.tsx +37 -17
  494. package/src/atoms/Spinner.tsx +87 -24
  495. package/src/atoms/Text.tsx +66 -6
  496. package/src/atoms/Transition.tsx +40 -44
  497. package/src/atoms/index.ts +3 -3
  498. package/src/atoms/inputValidation.ts +59 -0
  499. package/src/icons/Icon.tsx +4 -4
  500. package/src/icons/defaultIcons.tsx +445 -22
  501. package/src/icons/index.ts +2 -2
  502. package/src/icons/types.ts +26 -4
  503. package/src/molecules/AvatarHeader.tsx +88 -34
  504. package/src/molecules/FormActions.tsx +36 -6
  505. package/src/molecules/InfoRow.tsx +39 -12
  506. package/src/molecules/OptionCard.tsx +55 -20
  507. package/src/molecules/SegmentedControl.tsx +135 -15
  508. package/src/molecules/Stepper.tsx +73 -19
  509. package/src/molecules/Steps.tsx +121 -39
  510. package/src/molecules/SymmetricGrid.tsx +28 -12
  511. package/src/organisms/Alert.tsx +97 -26
  512. package/src/organisms/AlertProvider.tsx +13 -13
  513. package/src/organisms/BottomSheet.tsx +115 -43
  514. package/src/organisms/Chart.tsx +223 -165
  515. package/src/organisms/DateField.tsx +40 -15
  516. package/src/organisms/DatePicker.tsx +53 -32
  517. package/src/organisms/List.tsx +64 -15
  518. package/src/organisms/Modal.tsx +122 -63
  519. package/src/organisms/NavigationBar.tsx +73 -23
  520. package/src/organisms/OptionWheel.tsx +70 -66
  521. package/src/organisms/ReorderableList.tsx +49 -27
  522. package/src/organisms/Screen.tsx +79 -42
  523. package/src/organisms/SearchList.tsx +84 -20
  524. package/src/organisms/Select.tsx +63 -29
  525. package/src/organisms/Slides.tsx +63 -44
  526. package/src/organisms/ThemeToggle.tsx +15 -6
  527. package/src/organisms/Timeline.tsx +79 -62
  528. package/src/organisms/Toast.tsx +50 -15
  529. package/src/organisms/ToastProvider.tsx +17 -13
  530. package/src/organisms/Wizard.tsx +58 -21
  531. package/src/organisms/chart/frame.tsx +35 -35
  532. package/src/organisms/chart/marks.tsx +53 -53
  533. package/src/organisms/chart/math.ts +93 -94
  534. package/src/organisms/chart/palette.ts +28 -28
  535. package/src/organisms/imperativeBridge.ts +5 -5
  536. package/src/organisms/useSlides.ts +66 -31
  537. package/src/safe-area/SafeAreaUIProvider.tsx +9 -70
  538. package/src/safe-area/index.ts +1 -0
  539. package/src/theme/KeyboardAvoidance.tsx +216 -0
  540. package/src/theme/NativeOverlay.tsx +269 -0
  541. package/src/theme/UIProvider.tsx +109 -46
  542. package/src/theme/colors.ts +21 -21
  543. package/src/theme/createStyles.ts +23 -19
  544. package/src/theme/createTheme.ts +6 -6
  545. package/src/theme/index.ts +1 -1
  546. package/src/theme/insets.ts +1 -1
  547. package/src/theme/labels.ts +85 -2
  548. package/src/theme/palettes.ts +33 -25
  549. package/src/theme/tokens.ts +27 -13
  550. package/src/theme/types.ts +7 -7
  551. package/src/theme/useKeyboardField.ts +33 -0
  552. package/src/theme/useReduceMotion.ts +27 -0
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Modal.tsx",
12
- "content": "import React, { memo, useEffect, useState } from 'react';\nimport {\n Dimensions,\n KeyboardAvoidingView,\n Modal as RNModal,\n Platform,\n Pressable,\n ScrollView,\n View,\n type ModalProps as RNModalProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useModalSafeAreaBoundary } from '../theme/UIProvider';\nimport type { EdgeInsets } from '../theme/insets';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition, type TransitionPreset } from '../atoms/Transition';\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get('window');\n\nexport type ModalVariant = 'full' | 'overlay' | 'fullScreen';\n\nexport interface ModalProps\n extends Pick<RNModalProps, 'statusBarTranslucent' | 'onRequestClose' | 'hardwareAccelerated'> {\n visible: boolean;\n onClose?: () => void;\n title?: string;\n closeAccessibilityLabel?: string;\n children: React.ReactNode;\n footer?: React.ReactNode;\n containerStyle?: ViewStyle;\n contentStyle?: ViewStyle;\n variant?: ModalVariant;\n scrollable?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n flex1: { flex: 1 },\n fullWrapper: { flex: 1, backgroundColor: theme.colors.background },\n overlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xxl - 4 },\n backdrop: { backgroundColor: theme.colors.overlay },\n overlayCard: {\n width: '100%',\n maxHeight: '85%',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.xxl,\n overflow: 'hidden',\n ...theme.tokens.shadow.lg,\n },\n header: {\n flexDirection: 'row',\n justifyContent: 'space-between',\n alignItems: 'center',\n padding: theme.tokens.spacing.xl,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n title: { fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold, flex: 1 },\n closeButton: { marginLeft: theme.tokens.spacing.md },\n contentWrapper: { flexShrink: 1, backgroundColor: theme.colors.surface },\n scrollWrapper: { flexShrink: 1 },\n scrollContent: { padding: theme.tokens.spacing.xl, flexGrow: 1 },\n scrollContentFull: { padding: 0, flexGrow: 1 },\n footer: {\n padding: theme.tokens.spacing.xl,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n}));\n\n/**\n * Modal\n * Un solo componente para todos los modales de la app: maneja scroll,\n * teclado, insets y layout según `variant`. Para una hoja desde abajo está\n * BottomSheet, que además trae arrastre para cerrar.\n */\nexport const Modal = memo(\n ({\n visible,\n onClose,\n title,\n closeAccessibilityLabel = 'Close',\n children,\n footer,\n containerStyle,\n contentStyle,\n variant = 'full',\n scrollable = true,\n statusBarTranslucent = true,\n onRequestClose,\n hardwareAccelerated,\n }: ModalProps) => {\n const isFull = variant === 'full';\n const isOverlay = variant === 'overlay';\n const isFullScreen = variant === 'fullScreen';\n const isAnimatedVariant = isOverlay || isFull;\n const ModalSafeAreaBoundary = useModalSafeAreaBoundary();\n const colors = useColors();\n const styles = useStyles();\n\n const [mounted, setMounted] = useState(!isAnimatedVariant && visible);\n\n useEffect(() => {\n if (!isAnimatedVariant) setMounted(visible);\n else if (visible) setMounted(true);\n }, [visible, isAnimatedVariant]);\n\n // La tarjeta nunca anima opacity: hacerlo dispara un bug de Android donde\n // los hijos con elevation parpadean. El fondo semitransparente sí.\n const cardPreset: TransitionPreset[] = isFull ? ['slide-up'] : ['slide-down', 'scale'];\n const cardDistance = isFull ? SCREEN_HEIGHT : 40;\n\n const behavior = Platform.OS === 'ios' ? (isFull ? undefined : 'padding') : 'height';\n const keyboardOffset = Platform.OS === 'ios' ? 0 : 20;\n\n const renderHeader = () => {\n if (!title && !onClose) return null;\n return (\n <View style={styles.header}>\n {!!title && (\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n )}\n {!title && <View style={styles.flex1} />}\n {!!onClose && (\n <IconButton iconName=\"close\" size={24} color={colors.text} onPress={onClose} accessibilityLabel={closeAccessibilityLabel} style={styles.closeButton} />\n )}\n </View>\n );\n };\n\n const renderMainContent = () => (\n <View style={[styles.contentWrapper, isFull && styles.flex1]}>\n {scrollable ? (\n <ScrollView\n style={[styles.scrollWrapper, isFull && styles.flex1]}\n contentContainerStyle={[!isFull ? styles.scrollContent : styles.scrollContentFull, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n bounces={Platform.OS === 'ios'}\n automaticallyAdjustKeyboardInsets\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[!isFull ? styles.scrollContent : styles.scrollContentFull, isFull && styles.flex1, contentStyle]}>\n {children}\n </View>\n )}\n </View>\n );\n\n const renderFooter = () => (footer ? <View style={styles.footer}>{footer}</View> : null);\n\n if (!mounted) return null;\n\n return (\n <RNModal\n visible\n onRequestClose={onRequestClose ?? onClose}\n animationType={isAnimatedVariant ? 'none' : 'slide'}\n // `full` anima su propia entrada (translateY) con animationType=\"none\"\n // y necesita `transparent` para que no se vea un fondo negro detrás\n // mientras desliza. RN sólo admite `transparent` junto a\n // 'overFullScreen' — con 'pageSheet' avisa que la combinación no está\n // soportada. Por eso `full` usa overFullScreen: la presentación nativa\n // de pageSheet sería redundante con nuestra animación de todos modos.\n presentationStyle={\n Platform.OS === 'ios' ? (isFullScreen ? 'fullScreen' : 'overFullScreen') : undefined\n }\n transparent={isOverlay || isFull}\n statusBarTranslucent={statusBarTranslucent}\n hardwareAccelerated={hardwareAccelerated}\n >\n <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset} style={styles.flex1}>\n {/* Insets medidos dentro de esta ventana nativa del Modal, no los de\n la raíz de la app — pueden diferir (Android siempre abre una\n ventana propia; iOS según `presentationStyle`). */}\n <ModalSafeAreaBoundary>\n {(insets: EdgeInsets) =>\n isOverlay ? (\n <View style={styles.overlayContainer}>\n {/* Sin accessibilityLabel propio: el header ya expone un botón \"Close\"\n accesible; duplicar el label aquí generaría dos elementos con el\n mismo nombre para lectores de pantalla. Tocar afuera es un atajo\n puramente táctil. */}\n <Transition\n visible={visible}\n preset=\"fade\"\n duration={200}\n keepMounted\n style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}\n >\n <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID=\"modal-backdrop\" />\n </Transition>\n\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.overlayCard, containerStyle]}\n >\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </Transition>\n </View>\n ) : isFull ? (\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.fullWrapper, containerStyle]}\n >\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </Transition>\n ) : (\n <View style={[styles.fullWrapper, containerStyle]}>\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </View>\n )\n }\n </ModalSafeAreaBoundary>\n </KeyboardAvoidingView>\n </RNModal>\n );\n }\n);\nModal.displayName = 'Modal';\n\nconst StyleSheetAbsoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n"
12
+ "content": "import React, { memo, useEffect, useState } from 'react';\nimport {\n KeyboardAvoidingView,\n Platform,\n Pressable,\n ScrollView,\n View,\n useWindowDimensions,\n type ModalProps as RNModalProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useLabels, useModalSafeAreaBoundary, useTheme } from '../theme/UIProvider';\nimport { NativeOverlay } from '../theme/NativeOverlay';\nimport { KeyboardHandled } from '../theme/KeyboardAvoidance';\nimport type { EdgeInsets } from '../theme/insets';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition, type TransitionPreset } from '../atoms/Transition';\n\nexport type ModalVariant = 'full' | 'overlay' | 'fullScreen';\n\nexport interface ModalProps\n extends Pick<RNModalProps, 'statusBarTranslucent' | 'onRequestClose' | 'hardwareAccelerated'> {\n /** Shows or hides the modal. The animated variants play their exit before unmounting. */\n visible: boolean;\n /**\n * Shows the close button in the header and is called by it, by a tap on the backdrop (`overlay`) and\n * by Android's back button.\n */\n onClose?: () => void;\n /** Header title, on a single line. */\n title?: string;\n /** Accessible name of the close button. @default labels.close */\n closeAccessibilityLabel?: string;\n /** Body of the modal. */\n children: React.ReactNode;\n /** Fixed area below the content (e.g. actions); it doesn't scroll. */\n footer?: React.ReactNode;\n /** Style of the card (`overlay`) or the full-screen wrapper. */\n containerStyle?: ViewStyle;\n /** Style of the content area (the scroll container when `scrollable`). */\n contentStyle?: ViewStyle;\n /**\n * 'full' slides up a full page, 'overlay' a centered card over a dimmed backdrop, 'fullScreen' uses\n * the native full-screen presentation. @default 'full'\n */\n variant?: ModalVariant;\n /** Wraps the content in a ScrollView. @default true */\n scrollable?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n flex1: { flex: 1 },\n fullWrapper: { flex: 1, backgroundColor: theme.colors.surface },\n overlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xl },\n backdrop: { backgroundColor: theme.colors.overlay },\n overlayCard: {\n width: '100%',\n maxHeight: '85%',\n // Raised: in dark mode the plain surface sank into the dimmed backdrop.\n backgroundColor: theme.colors.surfaceRaised,\n borderRadius: theme.tokens.radius.xxl,\n overflow: 'hidden',\n ...theme.tokens.shadow.lg,\n },\n header: {\n flexDirection: 'row',\n justifyContent: 'space-between',\n alignItems: 'center',\n padding: theme.tokens.spacing.xl,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n },\n title: { fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold, flex: 1 },\n closeButton: { marginLeft: theme.tokens.spacing.md },\n cardInner: { flexShrink: 1 },\n contentWrapper: { flexShrink: 1 },\n scrollWrapper: { flexShrink: 1 },\n scrollContent: { padding: theme.tokens.spacing.xl, flexGrow: 1 },\n scrollContentFull: { padding: 0, flexGrow: 1 },\n footer: {\n padding: theme.tokens.spacing.xl,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n },\n}));\n\n/** Close glyph: the same size BottomSheet uses; IconButton pads it to 44pt. */\nconst CLOSE_ICON_SIZE = 24;\n/** How far the `overlay` card drops in while it scales up. */\nconst OVERLAY_ENTER_DISTANCE = 40;\n\n\n/**\n * Modal\n * A single component for all of the app's modals: handles scroll, keyboard,\n * insets and layout according to `variant`. For a sheet from the bottom there\n * is BottomSheet, which also comes with drag to close.\n *\n * Design:\n * - Figure and ground (Gestalt): `overlay` dims the screen behind a raised\n * card, so the task in front is the only figure.\n * - Common region: header, content and footer share one surface, split by\n * hairlines; the footer stays fixed so its actions are always in reach.\n * - Jakob's law: title on the left, close at the top right, Android back and\n * the VoiceOver escape gesture close it, like the system's own sheets.\n * - Fitts's law: the close button is a 44pt target.\n * - Screen readers stay inside the modal (`accessibilityViewIsModal`), which\n * matters on iOS when it is drawn inside another open overlay.\n */\nexport const Modal = memo(\n ({\n visible,\n onClose,\n title,\n closeAccessibilityLabel,\n children,\n footer,\n containerStyle,\n contentStyle,\n variant = 'full',\n scrollable = true,\n statusBarTranslucent = true,\n onRequestClose,\n hardwareAccelerated,\n }: ModalProps) => {\n const isFull = variant === 'full';\n const isOverlay = variant === 'overlay';\n const isFullScreen = variant === 'fullScreen';\n const isAnimatedVariant = isOverlay || isFull;\n const ModalSafeAreaBoundary = useModalSafeAreaBoundary();\n const theme = useTheme();\n const { colors } = theme;\n const styles = useStyles();\n const labels = useLabels();\n const closeLabel = closeAccessibilityLabel ?? labels.close;\n const { height: windowHeight } = useWindowDimensions();\n\n const [mounted, setMounted] = useState(!isAnimatedVariant && visible);\n\n useEffect(() => {\n if (!isAnimatedVariant) setMounted(visible);\n else if (visible) setMounted(true);\n }, [visible, isAnimatedVariant]);\n\n // The card never animates opacity: doing so triggers an Android bug where\n // children with elevation flicker. The semi-transparent backdrop does.\n const cardPreset: TransitionPreset[] = isFull ? ['slide-up'] : ['slide-down', 'scale'];\n const cardDistance = isFull ? windowHeight : OVERLAY_ENTER_DISTANCE;\n\n const behavior = Platform.OS === 'ios' ? (isFull ? undefined : 'padding') : 'height';\n const keyboardOffset = Platform.OS === 'ios' ? 0 : 20;\n\n const renderHeader = () => {\n if (!title && !onClose) return null;\n return (\n <View style={styles.header}>\n {!!title && (\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n )}\n {!title && <View style={styles.flex1} />}\n {!!onClose && (\n <IconButton\n iconName=\"close\"\n size={CLOSE_ICON_SIZE}\n color={colors.text}\n onPress={onClose}\n accessibilityLabel={closeLabel}\n style={styles.closeButton}\n />\n )}\n </View>\n );\n };\n\n const renderMainContent = () => (\n <View style={[styles.contentWrapper, isFull && styles.flex1]}>\n {scrollable ? (\n <ScrollView\n style={[styles.scrollWrapper, isFull && styles.flex1]}\n contentContainerStyle={[!isFull ? styles.scrollContent : styles.scrollContentFull, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n bounces={Platform.OS === 'ios'}\n automaticallyAdjustKeyboardInsets\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[!isFull ? styles.scrollContent : styles.scrollContentFull, isFull && styles.flex1, contentStyle]}>\n {children}\n </View>\n )}\n </View>\n );\n\n const renderFooter = () => (footer ? <View style={styles.footer}>{footer}</View> : null);\n\n const renderBody = (insets: EdgeInsets) =>\n isOverlay ? (\n <View style={styles.overlayContainer}>\n {/* No accessibilityLabel of its own: the header already exposes an\n accessible \"Close\" button; duplicating the label here would create\n two elements with the same name for screen readers. Tapping\n outside is a purely tactile shortcut. */}\n <Transition\n visible={visible}\n preset=\"fade\"\n duration={theme.tokens.duration.base}\n keepMounted\n style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}\n >\n <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID=\"modal-backdrop\" />\n </Transition>\n\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={theme.tokens.duration.slow}\n onExited={() => setMounted(false)}\n style={[styles.overlayCard, containerStyle]}\n >\n <View style={styles.cardInner} accessibilityViewIsModal onAccessibilityEscape={onClose}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </Transition>\n </View>\n ) : isFull ? (\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={theme.tokens.duration.slow}\n onExited={() => setMounted(false)}\n style={[styles.fullWrapper, containerStyle]}\n >\n <View\n style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}\n accessibilityViewIsModal\n onAccessibilityEscape={onClose}\n >\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </Transition>\n ) : (\n <View\n style={[styles.fullWrapper, containerStyle]}\n >\n <View\n style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}\n accessibilityViewIsModal\n onAccessibilityEscape={onClose}\n >\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </View>\n );\n\n if (!mounted) return null;\n\n\n return (\n <NativeOverlay\n // closing: while the exit animation plays, no other overlay layers into it.\n closing={!visible}\n onRequestClose={onRequestClose ?? onClose}\n animationType={isAnimatedVariant ? 'none' : 'slide'}\n // `full` animates its own entrance (translateY) with animationType=\"none\"\n // and needs `transparent` so no black background shows behind it\n // while sliding. RN only allows `transparent` together with\n // 'overFullScreen' — with 'pageSheet' it warns that the combination\n // isn't supported. That's why `full` uses overFullScreen: pageSheet's\n // native presentation would be redundant with our animation anyway.\n presentationStyle={\n Platform.OS === 'ios' ? (isFullScreen ? 'fullScreen' : 'overFullScreen') : undefined\n }\n transparent={isOverlay || isFull}\n statusBarTranslucent={statusBarTranslucent}\n hardwareAccelerated={hardwareAccelerated}\n >\n {/* Its own native window: its KeyboardAvoidingView compensates for it, not the root host. */}\n <KeyboardHandled>\n <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset} style={styles.flex1}>\n {/* Insets measured inside this native Modal window, not the app\n root's — they may differ (Android always opens its own window;\n iOS depending on `presentationStyle`). */}\n <ModalSafeAreaBoundary>{renderBody}</ModalSafeAreaBoundary>\n </KeyboardAvoidingView>\n </KeyboardHandled>\n </NativeOverlay>\n );\n }\n);\nModal.displayName = 'Modal';\n\nconst StyleSheetAbsoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/NavigationBar.tsx",
12
- "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /** Screen edge where the bar lives. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Adds the safe area inset for the edge where the bar lives. @default true */\n safeArea?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n // El indicador se posiciona contra el borde de la barra, no contra su caja\n // interna: al `left` hay que sumarle el padding horizontal para que quede\n // centrado sobre la pestaña y no corrido hacia la izquierda.\n left: theme.tokens.spacing.xs + theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n testID=\"navigation-bar-indicator\"\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },\n safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
12
+ "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n Keyboard,\n Platform,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../theme/useReduceMotion';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Counter over the icon, also read by screen readers. `true` shows only the dot. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n /** The destinations: key, label, and optionally an icon and a badge. */\n items: NavigationBarItem[];\n /** Key of the selected item (controlled). */\n activeKey: string;\n /** Called with the key of the pressed item. */\n onChange: (key: string) => void;\n /** Screen edge where the bar lives. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** Shows the label under each icon. @default true */\n showLabels?: boolean;\n /** Adds the safe area inset for the edge where the bar lives. @default true */\n safeArea?: boolean;\n /**\n * Hides a bottom bar while the keyboard is open, like native tab bars, so\n * it doesn't sit between the field and the keyboard. Only Android needs it:\n * on iOS the keyboard already covers the bar. @default true\n */\n hideOnKeyboard?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Counter badge: a pill that still fits two digits over a 22pt icon. */\nconst BADGE_SIZE = 16;\nconst BADGE_DOT_SIZE = 10;\nconst BADGE_FONT_SIZE = 10;\n/** Indicator spring: settles fast without bouncing past the tab. */\nconst INDICATOR_SPRING = { friction: 9, tension: 90 };\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n // The indicator is positioned against the bar's edge, not its inner box:\n // the horizontal padding has to be added to `left` so it sits centered\n // over the tab and not shifted to the left.\n left: theme.tokens.spacing.xs + theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: theme.tokens.spacing.xxs },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -theme.tokens.spacing.xs,\n left: '50%',\n marginLeft: theme.tokens.spacing.xs,\n minWidth: BADGE_SIZE,\n height: BADGE_SIZE,\n paddingHorizontal: theme.tokens.spacing.xs,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: BADGE_DOT_SIZE, height: BADGE_DOT_SIZE, top: -theme.tokens.spacing.xxs, paddingHorizontal: 0 },\n badgeText: {\n fontSize: BADGE_FONT_SIZE,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Tab navigation bar with the same look and the same transitions on iOS and\n * Android: the indicator slides to the active tab and its content grows and\n * takes the accent color with a cross-fade.\n * It doesn't navigate on its own — it reports the tapped tab and the consumer decides.\n *\n * Design:\n * - Jakob's law: the tab bar people know from every app, at the bottom by\n * default, icon over label.\n * - Similarity + Von Restorff: every tab looks the same except the active\n * one, which alone gets the accent and the indicator.\n * - Continuity: the indicator slides from tab to tab instead of jumping\n * (in place with Reduce Motion).\n * - Fitts's law: the whole column is the target, not just the icon.\n * - Hick's law: meant for 3–5 destinations.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n hideOnKeyboard = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const [barWidth, setBarWidth] = useState(0);\n const [keyboardOpen, setKeyboardOpen] = useState(false);\n\n // On Android the window shrinks with the keyboard and the bar would end up\n // floating above it, between the field and the keys. On iOS the keyboard\n // covers it: no need (and hiding it would move the layout midway through\n // the keyboard animation).\n useEffect(() => {\n if (!hideOnKeyboard || position !== 'bottom' || Platform.OS !== 'android') return;\n const subs = [\n Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)),\n Keyboard.addListener('keyboardDidHide', () => setKeyboardOpen(false)),\n ];\n return () => subs.forEach((sub) => sub.remove());\n }, [hideOnKeyboard, position]);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // On the first layout the indicator appears in place; animating it from 0\n // would be a slide the user didn't ask for.\n // With Reduce Motion it moves without the slide, too.\n if (!placed.current || reduceMotion) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, ...INDICATOR_SPRING }).start();\n }, [activeIndex, itemWidth, slide, reduceMotion]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n if (keyboardOpen) return null;\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n testID=\"navigation-bar-indicator\"\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },\n safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n // The badge is drawn, not text the reader reaches: its count goes in the value.\n accessibilityValue={typeof item.badge === 'number' || typeof item.badge === 'string' ? { text: String(item.badge) } : undefined}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Decorative copy: if it weren't hidden, the reader would read the label twice. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,11 +9,12 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "molecules/OptionCard.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { PressableScale } from '../atoms/PressableScale';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n /** Secondary line: the detail that decides the choice (\"arrives in 3 days\"). */\n description?: string;\n iconName?: IconName;\n isSelected: boolean;\n onPress: () => void;\n disabled?: boolean;\n /** 'vertical': icon on top, text centered below. 'horizontal': in a row. @default 'horizontal' */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n card: {\n // Las tarjetas se reparten la fila que las contenga, sean dos o cinco.\n flex: 1,\n alignItems: 'center',\n justifyContent: 'center',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.md,\n borderWidth: 1,\n borderColor: theme.colors.border,\n ...theme.tokens.shadow.sm,\n },\n cardHorizontal: { flexDirection: 'row', padding: theme.tokens.spacing.md - 2, gap: theme.tokens.spacing.sm },\n cardVertical: { padding: theme.tokens.spacing.md - 2, gap: 6, minHeight: 100 },\n cardSelected: {\n borderColor: theme.colors.primary,\n backgroundColor: theme.colors.primarySoft,\n borderWidth: 1.5,\n elevation: 0,\n shadowOpacity: 0,\n },\n cardDisabled: { opacity: 0.5 },\n text: { flexShrink: 1 },\n textVertical: { alignItems: 'center' },\n label: { color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },\n labelVertical: { fontSize: theme.tokens.fontSize.xs, textAlign: 'center' },\n labelSelected: { color: theme.colors.primaryText },\n description: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.textLight, marginTop: 2 },\n descriptionVertical: { textAlign: 'center' },\n}));\n\n/**\n * OptionCard\n * Opción excluyente con ícono y label que resalta cuando `isSelected`. Es un\n * radio con cara de tarjeta, así que se anuncia como tal: envolver el grupo en\n * una View con `accessibilityRole=\"radiogroup\"` para que el lector de pantalla\n * lea \"1 de 3\".\n */\nexport const OptionCard = memo(\n ({\n label,\n description,\n iconName,\n isSelected,\n onPress,\n disabled = false,\n layout = 'horizontal',\n style,\n testID,\n }: OptionCardProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = layout === 'vertical';\n\n // Feedback por transform y no por opacity: con elevation, animar la opacity\n // dispara un bug de Android que redibuja la sombra como un borde oscuro.\n return (\n <PressableScale\n style={[\n styles.card,\n isVertical ? styles.cardVertical : styles.cardHorizontal,\n isSelected && styles.cardSelected,\n disabled && styles.cardDisabled,\n style,\n ]}\n onPress={onPress}\n disabled={disabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}\n accessibilityLabel={label}\n accessibilityHint={description}\n testID={testID}\n >\n {!!iconName && <Icon name={iconName} size={24} color={isSelected ? colors.primary : colors.textLight} />}\n\n <View style={[styles.text, isVertical && styles.textVertical]}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.label, isVertical && styles.labelVertical, isSelected && styles.labelSelected]}\n >\n {label}\n </Text>\n {!!description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, isVertical && styles.descriptionVertical]}\n >\n {description}\n </Text>\n )}\n </View>\n </PressableScale>\n );\n }\n);\nOptionCard.displayName = 'OptionCard';\n"
12
+ "content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { PressableScale } from '../atoms/PressableScale';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n /** Name of the option. It is also the start of its accessible name. */\n label: string;\n /**\n * Secondary line: the detail that decides the choice (\"arrives in 3 days\").\n * Screen readers read it right after the label, not as a hint, because\n * hints can be turned off and this detail is part of the choice.\n */\n description?: string;\n /** Icon that helps recognize the option. Avoid `check`: it reads as \"selected\". */\n iconName?: IconName;\n /** Whether this is the chosen option of its group. */\n isSelected: boolean;\n /** Called on press; the group decides which option is selected. */\n onPress: () => void;\n /** Dims the card and ignores presses. @default false */\n disabled?: boolean;\n /** 'vertical': icon on top, text centered below. 'horizontal': in a row. @default 'horizontal' */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst ICON_SIZE = 24;\n/** Same height for every vertical card in a row, even with a short label. */\nconst VERTICAL_MIN_HEIGHT = 100;\nconst DISABLED_OPACITY = 0.5;\n/** The selected border is thicker; its padding gives back the difference so content doesn't shift. */\nconst BORDER = 1;\nconst BORDER_SELECTED = 1.5;\n\nconst useStyles = createStyles((theme) => ({\n card: {\n // The cards share whatever row contains them, whether two or five.\n flex: 1,\n alignItems: 'center',\n justifyContent: 'center',\n // Raised: in dark it stays a visible tile on a surface (Card does the same).\n backgroundColor: theme.colors.surfaceRaised,\n borderRadius: theme.tokens.radius.md,\n borderWidth: BORDER,\n borderColor: theme.colors.border,\n padding: theme.tokens.spacing.md,\n ...theme.tokens.shadow.sm,\n },\n cardHorizontal: { flexDirection: 'row', gap: theme.tokens.spacing.sm },\n cardVertical: { gap: theme.tokens.spacing.sm, minHeight: VERTICAL_MIN_HEIGHT },\n cardSelected: {\n borderColor: theme.colors.primary,\n backgroundColor: theme.colors.primarySoft,\n borderWidth: BORDER_SELECTED,\n padding: theme.tokens.spacing.md - (BORDER_SELECTED - BORDER),\n elevation: 0,\n shadowOpacity: 0,\n },\n cardDisabled: { opacity: DISABLED_OPACITY },\n text: { flexShrink: 1 },\n textVertical: { alignItems: 'center' },\n label: {\n color: theme.colors.text,\n fontSize: theme.tokens.fontSize.md,\n lineHeight: theme.tokens.lineHeight.md,\n fontWeight: theme.tokens.fontWeight.medium,\n },\n labelVertical: { textAlign: 'center' },\n labelSelected: { color: theme.colors.primaryText },\n description: {\n fontSize: theme.tokens.fontSize.sm,\n lineHeight: theme.tokens.lineHeight.sm,\n color: theme.colors.textLight,\n marginTop: theme.tokens.spacing.xxs,\n },\n descriptionVertical: { textAlign: 'center' },\n}));\n\n/**\n * OptionCard\n * Mutually exclusive option with an icon and label that highlights when\n * `isSelected`. It is a radio with a card's face, so it announces itself as\n * one: wrap the group in a View with `accessibilityRole=\"radiogroup\"` so the\n * screen reader reads \"1 of 3\".\n *\n * Gestalt, similarity: every card in a group shares shape, size and type, so\n * they read as alternatives of one question. Von Restorff / figure-ground: the\n * selected one is the only filled card with an accent border and no shadow —\n * it stands out without the others losing legibility (their labels keep the\n * `text` color). The thicker selected border doesn't move the content.\n */\nexport const OptionCard = memo(\n ({\n label,\n description,\n iconName,\n isSelected,\n onPress,\n disabled = false,\n layout = 'horizontal',\n style,\n testID,\n }: OptionCardProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = layout === 'vertical';\n\n // Feedback through transform, not opacity: with elevation, animating opacity\n // triggers an Android bug that redraws the shadow as a dark border.\n return (\n <PressableScale\n style={[\n styles.card,\n isVertical ? styles.cardVertical : styles.cardHorizontal,\n isSelected && styles.cardSelected,\n disabled && styles.cardDisabled,\n style,\n ]}\n onPress={onPress}\n disabled={disabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}\n accessibilityLabel={description ? `${label}, ${description}` : label}\n testID={testID}\n >\n {!!iconName && <Icon name={iconName} size={ICON_SIZE} color={isSelected ? colors.primary : colors.textLight} />}\n\n <View style={[styles.text, isVertical && styles.textVertical]}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.label, isVertical && styles.labelVertical, isSelected && styles.labelSelected]}\n >\n {label}\n </Text>\n {!!description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, isVertical && styles.descriptionVertical]}\n >\n {description}\n </Text>\n )}\n </View>\n </PressableScale>\n );\n }\n);\nOptionCard.displayName = 'OptionCard';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
16
16
  "core",
17
- "pressable-scale"
17
+ "pressable-scale",
18
+ "transition"
18
19
  ]
19
20
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/OptionWheel.tsx",
12
- "content": "import React, { useCallback, useEffect, useMemo, useRef } from 'react';\nimport {\n Animated,\n ScrollView,\n Text,\n View,\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../atoms/Transition';\n\nexport interface OptionWheelOption<T> {\n label: string;\n value: T;\n /** The wheel refuses to settle here and slides to the nearest enabled row. */\n disabled?: boolean;\n}\n\nexport type OptionWheelVariant = 'window' | 'spotlight';\n\nexport interface OptionWheelProps<T> {\n options: OptionWheelOption<T>[];\n selectedValue: T | undefined;\n onSelect: (value: T) => void;\n /** Title above the wheel. */\n label?: string;\n /** Required when there's no `label`: the wheel as a whole needs a name. */\n accessibilityLabel?: string;\n /**\n * Rows on screen. Forced to the next odd number so one of them is the\n * centre. @default 5\n */\n visibleCount?: number;\n /** Height of each row. @default 44 */\n itemHeight?: number;\n /** Fixed text to the right of the centre row ('kg', 'min'). */\n unit?: string;\n /** Tilts the far rows to suggest a cylinder. @default true */\n perspective?: boolean;\n /**\n * 'window' marks the pick with a band behind the centre row. 'spotlight'\n * drops the band and lets the far rows fall away and blur instead, which\n * needs a quiet surface under it to read.\n * @default 'window'\n */\n variant?: OptionWheelVariant;\n /**\n * Colour of the option labels. The surface the wheel sits on is whoever\n * composes it to paint — this is how the text keeps contrast against it.\n * @default theme.colors.text\n */\n textColor?: string;\n /**\n * Radius, in pixels, of the wheel the rows sit on. Its centre is off to the\n * side, so the rows bow inwards as they get further from the middle and tilt\n * with the tangent — the wheel reads as a rim seen edge-on instead of a flat\n * list. Smaller radius, rounder arc. Undefined leaves the rows flat.\n */\n curveRadius?: number;\n /** Which side the wheel's centre is on. @default 'left' */\n curveFrom?: 'left' | 'right';\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_VISIBLE_COUNT = 5;\n/** 44pt: el mínimo táctil de iOS, y alcanza para una línea `lg` con aire. */\nconst DEFAULT_ITEM_HEIGHT = 44;\n/**\n * Filas hacia cada lado que la interpolación distingue; más lejos, todas\n * iguales (`extrapolate: 'clamp'`). Los tres rangos son simétricos respecto\n * del centro: la fila de arriba y la de abajo se ven igual, sólo que la de\n * arriba se inclina hacia atrás y la de abajo hacia adelante.\n */\nconst FALLOFF = 2;\n\n/**\n * Cada variante es un juego de rangos. 'spotlight' cae mucho más rápido y se\n * inclina más porque no tiene banda que señale el centro: todo el trabajo de\n * decir cuál está elegida lo hacen el contraste y la nitidez.\n */\nconst RANGES: Record<OptionWheelVariant, { opacity: number[]; scale: number[]; rotation: string[] }> = {\n window: {\n opacity: [0.22, 0.55, 1, 0.55, 0.22],\n scale: [0.8, 0.9, 1, 0.9, 0.8],\n rotation: ['60deg', '32deg', '0deg', '-32deg', '-60deg'],\n },\n spotlight: {\n opacity: [0.16, 0.42, 1, 0.42, 0.16],\n scale: [0.72, 0.87, 1, 0.87, 0.72],\n rotation: ['72deg', '40deg', '0deg', '-40deg', '-72deg'],\n },\n};\n\n/**\n * React Native no tiene desenfoque sin dependencia nativa, así que en\n * 'spotlight' cada fila que no está en el centro se dibuja además dos veces\n * corrida y a media tinta. La superposición emborrona el trazo: no es un blur\n * gaussiano, pero a ese tamaño y con esa opacidad lee igual, y sigue siendo\n * `opacity` y `transform` sobre el mismo scroll, o sea el hilo nativo.\n */\n/**\n * Dónde cae cada punto del `inputRange` sobre el arco. `Animated` no sabe\n * calcular raíces ni arcosenos, pero no hace falta: las cinco distancias al\n * centro son fijas por fila, así que la flecha del arco y el ángulo de la\n * tangente se resuelven una vez acá y entran como `outputRange`.\n */\nfunction arcOffsets(radius: number, itemHeight: number, sign: number) {\n const shift: number[] = [];\n const angle: string[] = [];\n for (let step = -FALLOFF; step <= FALLOFF; step += 1) {\n // Más allá del radio la fila ya dio la vuelta: se corta ahí en vez de\n // sacarle la raíz a un negativo.\n const dy = Math.min(Math.abs(step * itemHeight), radius);\n // Flecha del arco: cuánto se mete la fila hacia el centro de la rueda.\n shift.push(sign * (radius - Math.sqrt(radius * radius - dy * dy)));\n // La tangente del arco, para que la fila siga la curva en vez de cruzarla.\n const degrees = Math.asin(dy / radius) * (180 / Math.PI);\n angle.push(`${-sign * Math.sign(step) * degrees}deg`);\n }\n return { shift, angle };\n}\n\nconst GHOST_SHIFTS = [-1, 1];\nconst GHOST_SPREAD = 3;\nconst GHOST_OPACITY_RANGE = [0.55, 0.3, 0, 0.3, 0.55];\n\nconst useStyles = createStyles((theme) => ({\n root: { gap: theme.tokens.spacing.sm },\n label: {\n fontWeight: theme.tokens.fontWeight.bold,\n fontSize: theme.tokens.fontSize.md,\n color: theme.colors.text,\n },\n viewport: { overflow: 'hidden' },\n disabled: { opacity: 0.5 },\n // La ventana central: figura contra el fondo de opciones que pasan.\n window: {\n position: 'absolute',\n left: 0,\n right: 0,\n borderTopWidth: 1,\n borderBottomWidth: 1,\n borderColor: theme.colors.border,\n backgroundColor: theme.colors.primarySoft,\n },\n row: { justifyContent: 'center', alignItems: 'center' },\n ghost: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n justifyContent: 'center',\n alignItems: 'center',\n },\n rowContent: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.xs },\n text: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n textDisabled: { color: theme.colors.textLight },\n unit: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n}));\n\n/** El índice habilitado más cercano a `index`, o -1 si no hay ninguno. */\nfunction nearestEnabled<T>(options: OptionWheelOption<T>[], index: number): number {\n if (options.length === 0) return -1;\n for (let step = 0; step < options.length; step += 1) {\n const before = index - step;\n const after = index + step;\n if (after < options.length && !options[after]?.disabled) return after;\n if (before >= 0 && !options[before]?.disabled) return before;\n }\n return -1;\n}\n\n/**\n * OptionWheel\n * Selector de rueda: las opciones pasan por una ventana fija y la que queda en\n * el centro es la elegida. El scroll hace snap a la altura de fila, así que\n * soltar el dedo en cualquier lado siempre deja una opción centrada y no media.\n *\n * Gestalt, figura y fondo: lo que marca la selección no es un estilo sobre la\n * fila —que viajaría con ella— sino una ventana quieta encima de la lista. La\n * figura se queda, el fondo se mueve, y por eso la rueda se lee sin leer: el\n * lugar señala, no el color. Y destino común: todas las filas se achican y se\n * inclinan con la misma ley respecto del centro, de modo que se perciben como\n * una superficie rígida que gira y no como N filas animándose por su cuenta.\n *\n * La escala, la opacidad y la inclinación salen de interpolar el\n * `contentOffset` del scroll, no del estado de React: se calculan en el hilo\n * nativo (`useNativeDriver`) y el hilo de JS sólo participa cuando la rueda ya\n * frenó, para avisar qué quedó elegido. Con \"reducir movimiento\" activo se\n * apaga la inclinación y queda el tamaño, que es información, no decoración.\n *\n * `disabled` en una opción no puede impedir que el snap la centre, así que la\n * rueda la centra y se corre sola a la habilitada más cercana.\n */\nexport function OptionWheel<T extends string | number>({\n options,\n selectedValue,\n onSelect,\n label,\n accessibilityLabel,\n visibleCount = DEFAULT_VISIBLE_COUNT,\n itemHeight = DEFAULT_ITEM_HEIGHT,\n unit,\n perspective = true,\n variant = 'window',\n textColor,\n curveRadius,\n curveFrom = 'left',\n disabled = false,\n style,\n testID,\n}: OptionWheelProps<T>) {\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const scrollRef = useRef<ScrollView>(null);\n const scrollY = useRef(new Animated.Value(0)).current;\n // El primer posicionamiento no se anima: la rueda tiene que aparecer ya\n // puesta en su valor, no viajando hasta él.\n const settled = useRef(false);\n\n // Impar siempre: con un número par no hay fila central que alinear con la\n // ventana, y el snap dejaría la selección entre dos filas.\n const rows = visibleCount % 2 === 0 ? visibleCount + 1 : visibleCount;\n const padding = ((rows - 1) / 2) * itemHeight;\n const height = rows * itemHeight;\n\n const selectedIndex = useMemo(\n () => options.findIndex((option) => option.value === selectedValue),\n [options, selectedValue]\n );\n\n const scrollToIndex = useCallback(\n (index: number, animated: boolean) => {\n const y = index * itemHeight;\n scrollRef.current?.scrollTo({ y, animated });\n // `scrollY` sólo lo alimenta `onScroll`, y un salto sin animar no\n // siempre lo emite: sin esto el valor se queda en 0, la interpolación\n // cree que la fila centrada es la primera y apaga la que de verdad está\n // en la ventana. El scroll animado sí emite en todo el recorrido, así\n // que ahí forzarlo pelearía con la animación.\n if (!animated) scrollY.setValue(y);\n },\n [itemHeight, scrollY]\n );\n\n useEffect(() => {\n if (selectedIndex < 0) return;\n scrollToIndex(selectedIndex, settled.current && !reduceMotion);\n settled.current = true;\n }, [selectedIndex, scrollToIndex, reduceMotion]);\n\n /**\n * Corre tanto al final del arrastre como al final de la inercia: en Android\n * un arrastre corto sin impulso no dispara `onMomentumScrollEnd`, y la rueda\n * se quedaría con la selección vieja. Reentrar con el mismo índice es inocuo\n * porque sólo avisa cuando cambia.\n */\n const commit = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n const raw = Math.round(event.nativeEvent.contentOffset.y / itemHeight);\n const clamped = Math.min(Math.max(raw, 0), options.length - 1);\n const landed = options[clamped];\n if (!landed) return;\n const target = landed.disabled ? nearestEnabled(options, clamped) : clamped;\n const option = options[target];\n // Sin opciones habilitadas no hay dónde parar: la rueda se queda donde\n // la dejaron y no avisa nada.\n if (!option) return;\n if (target !== clamped) scrollToIndex(target, true);\n if (option.value !== selectedValue) onSelect(option.value);\n },\n [options, itemHeight, selectedValue, onSelect, scrollToIndex]\n );\n\n /** VoiceOver/TalkBack recorren la rueda con increment/decrement, no con el dedo. */\n const step = useCallback(\n (delta: number) => {\n if (disabled || selectedIndex < 0) return;\n const next = nearestEnabled(options, selectedIndex + delta);\n const option = options[next];\n // En el borde, `nearestEnabled` devuelve la fila donde ya estamos.\n if (!option || next === selectedIndex) return;\n onSelect(option.value);\n },\n [disabled, options, selectedIndex, onSelect]\n );\n\n const onLayout = useCallback(\n (_: LayoutChangeEvent) => {\n if (selectedIndex >= 0) scrollToIndex(selectedIndex, false);\n },\n [selectedIndex, scrollToIndex]\n );\n\n const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;\n const ranges = RANGES[variant];\n // El desenfoque simulado triplica los nodos de texto: sólo va donde aporta,\n // y con \"reducir movimiento\" no va, porque ahí la nitidez es lo único que\n // queda para distinguir la fila elegida.\n const smeared = variant === 'spotlight' && !reduceMotion;\n const arc = useMemo(\n () =>\n curveRadius && curveRadius > 0 && !reduceMotion\n ? arcOffsets(curveRadius, itemHeight, curveFrom === 'left' ? -1 : 1)\n : undefined,\n [curveRadius, itemHeight, curveFrom, reduceMotion]\n );\n\n return (\n <View style={[styles.root, disabled && styles.disabled, style]} testID={testID}>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n </Text>\n )}\n\n <View\n accessible\n accessibilityRole=\"adjustable\"\n accessibilityLabel={label ?? accessibilityLabel}\n accessibilityValue={{ text: selected?.label }}\n accessibilityState={{ disabled }}\n accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n onAccessibilityAction={(event) => step(event.nativeEvent.actionName === 'increment' ? 1 : -1)}\n style={[styles.viewport, { height }]}\n testID={testID && `${testID}-viewport`}\n >\n {variant === 'window' && (\n <View\n pointerEvents=\"none\"\n style={[styles.window, { top: padding, height: itemHeight, borderRadius: theme.tokens.radius.sm }]}\n />\n )}\n\n <Animated.ScrollView\n ref={scrollRef as never}\n onLayout={onLayout}\n scrollEnabled={!disabled}\n showsVerticalScrollIndicator={false}\n snapToInterval={itemHeight}\n decelerationRate=\"fast\"\n scrollEventThrottle={16}\n contentContainerStyle={{ paddingVertical: padding }}\n onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {\n useNativeDriver: true,\n })}\n onScrollEndDrag={commit}\n onMomentumScrollEnd={commit}\n testID={testID && `${testID}-scroll`}\n >\n {options.map((option, index) => {\n const centre = index * itemHeight;\n const inputRange = [\n centre - FALLOFF * itemHeight,\n centre - itemHeight,\n centre,\n centre + itemHeight,\n centre + FALLOFF * itemHeight,\n ];\n type Interpolated = Animated.AnimatedInterpolation<string | number>;\n const transform: Array<\n | { perspective: number }\n | { scale: Interpolated }\n | { rotateX: Interpolated }\n | { rotateZ: Interpolated }\n | { translateX: Interpolated }\n > = [\n { scale: scrollY.interpolate({ inputRange, outputRange: ranges.scale, extrapolate: 'clamp' }) },\n ];\n if (perspective && !reduceMotion) {\n // El perspective va primero: aplicado después de la rotación no\n // deforma nada y la fila se ve plana aunque esté rotada.\n transform.unshift({ perspective: height * 2 });\n transform.push({\n rotateX: scrollY.interpolate({ inputRange, outputRange: ranges.rotation, extrapolate: 'clamp' }),\n });\n }\n\n if (arc) {\n transform.push(\n { translateX: scrollY.interpolate({ inputRange, outputRange: arc.shift, extrapolate: 'clamp' }) },\n { rotateZ: scrollY.interpolate({ inputRange, outputRange: arc.angle, extrapolate: 'clamp' }) }\n );\n }\n\n const label = (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.text, option.disabled && styles.textDisabled, !!textColor && { color: textColor }]}\n >\n {option.label}\n </Text>\n );\n\n return (\n <Animated.View\n key={String(option.value)}\n testID={testID && `${testID}-item-${index}`}\n style={[\n styles.row,\n {\n height: itemHeight,\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: ranges.opacity,\n extrapolate: 'clamp',\n }),\n transform,\n },\n ]}\n >\n {smeared &&\n GHOST_SHIFTS.map((direction) => (\n <Animated.View\n key={direction}\n pointerEvents=\"none\"\n // `accessibilityElementsHidden` no alcanza: el lector\n // leería la fila tres veces igual, porque la rueda entera\n // ya es un solo elemento y esto vive adentro.\n style={[\n styles.ghost,\n {\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: GHOST_OPACITY_RANGE,\n extrapolate: 'clamp',\n }),\n transform: [\n {\n translateX: scrollY.interpolate({\n inputRange,\n outputRange: [\n direction * GHOST_SPREAD,\n direction * (GHOST_SPREAD / 2),\n 0,\n direction * (GHOST_SPREAD / 2),\n direction * GHOST_SPREAD,\n ],\n extrapolate: 'clamp',\n }),\n },\n ],\n },\n ]}\n >\n {label}\n </Animated.View>\n ))}\n\n <View style={styles.rowContent}>\n {label}\n {!!unit && index === selectedIndex && (\n <Text allowFontScaling={allowFontScaling} style={styles.unit}>\n {unit}\n </Text>\n )}\n </View>\n </Animated.View>\n );\n })}\n </Animated.ScrollView>\n </View>\n </View>\n );\n}\n"
12
+ "content": "import React, { useCallback, useEffect, useMemo, useRef, type ComponentRef } from 'react';\nimport {\n Animated,\n ScrollView,\n Text,\n View,\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../atoms/Transition';\n\nexport interface OptionWheelOption<T> {\n label: string;\n value: T;\n /** The wheel refuses to settle here and slides to the nearest enabled row. */\n disabled?: boolean;\n}\n\nexport type OptionWheelVariant = 'window' | 'spotlight';\n\nexport interface OptionWheelProps<T> {\n /** The range, in order. Each option has a label, its value, and may be disabled. */\n options: OptionWheelOption<T>[];\n /** Value of the option in the centre row (controlled). */\n selectedValue: T | undefined;\n /** Called with the value of the option the wheel settles on. */\n onSelect: (value: T) => void;\n /** Title above the wheel. */\n label?: string;\n /** Required when there's no `label`: the wheel as a whole needs a name. */\n accessibilityLabel?: string;\n /**\n * Rows on screen. Forced to the next odd number so one of them is the\n * centre. @default 5\n */\n visibleCount?: number;\n /** Height of each row. @default 44 */\n itemHeight?: number;\n /** Fixed text to the right of the centre row ('kg', 'min'). */\n unit?: string;\n /** Tilts the far rows to suggest a cylinder. @default true */\n perspective?: boolean;\n /**\n * 'window' marks the pick with a band behind the centre row. 'spotlight'\n * drops the band and lets the far rows fall away and blur instead, which\n * needs a quiet surface under it to read.\n * @default 'window'\n */\n variant?: OptionWheelVariant;\n /**\n * Colour of the option labels. The surface the wheel sits on is whoever\n * composes it to paint — this is how the text keeps contrast against it.\n * @default theme.colors.text\n */\n textColor?: string;\n /**\n * Radius, in pixels, of the wheel the rows sit on. Its centre is off to the\n * side, so the rows bow inwards as they get further from the middle and tilt\n * with the tangent — the wheel reads as a rim seen edge-on instead of a flat\n * list. Smaller radius, rounder arc. Undefined leaves the rows flat.\n */\n curveRadius?: number;\n /** Which side the wheel's centre is on. @default 'left' */\n curveFrom?: 'left' | 'right';\n /** Dims the wheel and stops it from turning. @default false */\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_VISIBLE_COUNT = 5;\n/** 44pt: iOS's touch minimum, and enough for an `lg` line with room to spare. */\nconst DEFAULT_ITEM_HEIGHT = 44;\n/**\n * Rows toward each side that the interpolation distinguishes; further out,\n * all the same (`extrapolate: 'clamp'`). The three ranges are symmetric\n * around the center: the row above and the row below look the same, except\n * the one above tilts backward and the one below forward.\n */\nconst FALLOFF = 2;\n\n/**\n * Each variant is a set of ranges. 'spotlight' falls off much faster and\n * tilts more because it has no band marking the center: all the work of\n * saying which one is selected is done by contrast and sharpness.\n */\nconst RANGES: Record<OptionWheelVariant, { opacity: number[]; scale: number[]; rotation: string[] }> = {\n window: {\n opacity: [0.22, 0.55, 1, 0.55, 0.22],\n scale: [0.8, 0.9, 1, 0.9, 0.8],\n rotation: ['60deg', '32deg', '0deg', '-32deg', '-60deg'],\n },\n spotlight: {\n opacity: [0.16, 0.42, 1, 0.42, 0.16],\n scale: [0.72, 0.87, 1, 0.87, 0.72],\n rotation: ['72deg', '40deg', '0deg', '-40deg', '-72deg'],\n },\n};\n\n/**\n * React Native has no blur without a native dependency, so in 'spotlight'\n * each row that isn't in the center is also drawn twice, offset and at half\n * ink. The overlap smears the stroke: it isn't a gaussian blur, but at that\n * size and opacity it reads the same, and it is still `opacity` and\n * `transform` over the same scroll, i.e. the native thread.\n */\n/**\n * Where each point of the `inputRange` falls on the arc. `Animated` can't\n * compute roots or arcsines, but it doesn't need to: the five distances to\n * the center are fixed per row, so the arc's sagitta and the tangent's angle\n * are resolved once here and go in as `outputRange`.\n */\nfunction arcOffsets(radius: number, itemHeight: number, sign: number) {\n const shift: number[] = [];\n const angle: string[] = [];\n for (let step = -FALLOFF; step <= FALLOFF; step += 1) {\n // Beyond the radius the row has already turned over: cut it off there\n // instead of taking the root of a negative.\n const dy = Math.min(Math.abs(step * itemHeight), radius);\n // Arc sagitta: how far the row moves in toward the center of the wheel.\n shift.push(sign * (radius - Math.sqrt(radius * radius - dy * dy)));\n // The arc's tangent, so the row follows the curve instead of crossing it.\n const degrees = Math.asin(dy / radius) * (180 / Math.PI);\n angle.push(`${-sign * Math.sign(step) * degrees}deg`);\n }\n return { shift, angle };\n}\n\nconst GHOST_SHIFTS = [-1, 1];\nconst GHOST_SPREAD = 3;\nconst GHOST_OPACITY_RANGE = [0.55, 0.3, 0, 0.3, 0.55];\n\nconst useStyles = createStyles((theme) => ({\n root: { gap: theme.tokens.spacing.sm },\n label: {\n fontWeight: theme.tokens.fontWeight.bold,\n fontSize: theme.tokens.fontSize.md,\n color: theme.colors.text,\n },\n viewport: { overflow: 'hidden' },\n disabled: { opacity: 0.5 },\n // The center window: figure against the ground of options passing by.\n window: {\n position: 'absolute',\n left: 0,\n right: 0,\n borderTopWidth: 1,\n borderBottomWidth: 1,\n borderColor: theme.colors.border,\n backgroundColor: theme.colors.primarySoft,\n },\n row: { justifyContent: 'center', alignItems: 'center' },\n ghost: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n justifyContent: 'center',\n alignItems: 'center',\n },\n rowContent: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.xs },\n text: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n textDisabled: { color: theme.colors.textLight },\n unit: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n}));\n\n/** The enabled index closest to `index`, or -1 if there is none. */\nfunction nearestEnabled<T>(options: OptionWheelOption<T>[], index: number): number {\n if (options.length === 0) return -1;\n for (let step = 0; step < options.length; step += 1) {\n const before = index - step;\n const after = index + step;\n if (after < options.length && !options[after]?.disabled) return after;\n if (before >= 0 && !options[before]?.disabled) return before;\n }\n return -1;\n}\n\n/**\n * OptionWheel\n * Wheel picker: the options pass through a fixed window and the one left in\n * the center is the selected one. The scroll snaps to the row height, so\n * lifting the finger anywhere always leaves one option centered, never half.\n *\n * Gestalt, figure and ground: what marks the selection isn't a style on the\n * row —which would travel with it— but a still window on top of the list. The\n * figure stays, the ground moves, and that is why the wheel reads without\n * reading: the place signals, not the color. And common fate: all rows shrink\n * and tilt by the same law relative to the center, so they are perceived as a\n * rigid surface that turns and not as N rows animating on their own.\n *\n * Scale, opacity and tilt come from interpolating the scroll's\n * `contentOffset`, not from React state: they are computed on the native\n * thread (`useNativeDriver`) and the JS thread only takes part once the wheel\n * has stopped, to report what was selected. With \"reduce motion\" on, the tilt\n * is turned off and the size stays, since it is information, not decoration.\n *\n * `disabled` on an option can't keep the snap from centering it, so the\n * wheel centers it and moves on its own to the nearest enabled one.\n */\nexport function OptionWheel<T extends string | number>({\n options,\n selectedValue,\n onSelect,\n label,\n accessibilityLabel,\n visibleCount = DEFAULT_VISIBLE_COUNT,\n itemHeight = DEFAULT_ITEM_HEIGHT,\n unit,\n perspective = true,\n variant = 'window',\n textColor,\n curveRadius,\n curveFrom = 'left',\n disabled = false,\n style,\n testID,\n}: OptionWheelProps<T>) {\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);\n const scrollY = useRef(new Animated.Value(0)).current;\n // The first positioning isn't animated: the wheel has to appear already\n // set at its value, not traveling to it.\n const settled = useRef(false);\n\n // Always odd: with an even number there is no center row to line up with\n // the window, and the snap would leave the selection between two rows.\n const rows = visibleCount % 2 === 0 ? visibleCount + 1 : visibleCount;\n const padding = ((rows - 1) / 2) * itemHeight;\n const height = rows * itemHeight;\n\n const selectedIndex = useMemo(\n () => options.findIndex((option) => option.value === selectedValue),\n [options, selectedValue]\n );\n\n const scrollToIndex = useCallback(\n (index: number, animated: boolean) => {\n const y = index * itemHeight;\n scrollRef.current?.scrollTo({ y, animated });\n // `scrollY` is only fed by `onScroll`, and an unanimated jump doesn't\n // always emit it: without this the value stays at 0, the interpolation\n // thinks the centered row is the first one and dims the one that is\n // really in the window. The animated scroll does emit along the whole\n // path, so forcing it there would fight the animation.\n if (!animated) scrollY.setValue(y);\n },\n [itemHeight, scrollY]\n );\n\n useEffect(() => {\n if (selectedIndex < 0) return;\n scrollToIndex(selectedIndex, settled.current && !reduceMotion);\n settled.current = true;\n }, [selectedIndex, scrollToIndex, reduceMotion]);\n\n /**\n * Runs both at the end of the drag and at the end of momentum: on Android\n * a short drag with no momentum doesn't fire `onMomentumScrollEnd`, and the\n * wheel would keep the old selection. Re-entering with the same index is\n * harmless because it only reports when it changes.\n */\n const commit = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n const raw = Math.round(event.nativeEvent.contentOffset.y / itemHeight);\n const clamped = Math.min(Math.max(raw, 0), options.length - 1);\n const landed = options[clamped];\n if (!landed) return;\n const target = landed.disabled ? nearestEnabled(options, clamped) : clamped;\n const option = options[target];\n // With no enabled options there is nowhere to stop: the wheel stays where\n // it was left and reports nothing.\n if (!option) return;\n if (target !== clamped) scrollToIndex(target, true);\n if (option.value !== selectedValue) onSelect(option.value);\n },\n [options, itemHeight, selectedValue, onSelect, scrollToIndex]\n );\n\n /** VoiceOver/TalkBack go through the wheel with increment/decrement, not the finger. */\n const step = useCallback(\n (delta: number) => {\n if (disabled || selectedIndex < 0) return;\n const next = nearestEnabled(options, selectedIndex + delta);\n const option = options[next];\n // At the edge, `nearestEnabled` returns the row we are already on.\n if (!option || next === selectedIndex) return;\n onSelect(option.value);\n },\n [disabled, options, selectedIndex, onSelect]\n );\n\n const onLayout = useCallback(\n (_: LayoutChangeEvent) => {\n if (selectedIndex >= 0) scrollToIndex(selectedIndex, false);\n },\n [selectedIndex, scrollToIndex]\n );\n\n const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;\n const ranges = RANGES[variant];\n // The simulated blur triples the text nodes: it only goes where it helps,\n // and not with \"reduce motion\", because there sharpness is the only thing\n // left to tell the selected row apart.\n const smeared = variant === 'spotlight' && !reduceMotion;\n const arc = useMemo(\n () =>\n curveRadius && curveRadius > 0 && !reduceMotion\n ? arcOffsets(curveRadius, itemHeight, curveFrom === 'left' ? -1 : 1)\n : undefined,\n [curveRadius, itemHeight, curveFrom, reduceMotion]\n );\n\n return (\n <View style={[styles.root, disabled && styles.disabled, style]} testID={testID}>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n </Text>\n )}\n\n <View\n accessible\n accessibilityRole=\"adjustable\"\n accessibilityLabel={label ?? accessibilityLabel}\n accessibilityValue={{ text: selected?.label }}\n accessibilityState={{ disabled }}\n accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n onAccessibilityAction={(event) => step(event.nativeEvent.actionName === 'increment' ? 1 : -1)}\n style={[styles.viewport, { height }]}\n testID={testID && `${testID}-viewport`}\n >\n {variant === 'window' && (\n <View\n pointerEvents=\"none\"\n style={[styles.window, { top: padding, height: itemHeight, borderRadius: theme.tokens.radius.sm }]}\n />\n )}\n\n <Animated.ScrollView\n ref={scrollRef as never}\n onLayout={onLayout}\n scrollEnabled={!disabled}\n showsVerticalScrollIndicator={false}\n snapToInterval={itemHeight}\n decelerationRate=\"fast\"\n scrollEventThrottle={16}\n contentContainerStyle={{ paddingVertical: padding }}\n onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {\n useNativeDriver: true,\n })}\n onScrollEndDrag={commit}\n onMomentumScrollEnd={commit}\n testID={testID && `${testID}-scroll`}\n >\n {options.map((option, index) => {\n const centre = index * itemHeight;\n const inputRange = [\n centre - FALLOFF * itemHeight,\n centre - itemHeight,\n centre,\n centre + itemHeight,\n centre + FALLOFF * itemHeight,\n ];\n type Interpolated = Animated.AnimatedInterpolation<string | number>;\n const transform: Array<\n | { perspective: number }\n | { scale: Interpolated }\n | { rotateX: Interpolated }\n | { rotateZ: Interpolated }\n | { translateX: Interpolated }\n > = [\n { scale: scrollY.interpolate({ inputRange, outputRange: ranges.scale, extrapolate: 'clamp' }) },\n ];\n if (perspective && !reduceMotion) {\n // perspective goes first: applied after the rotation it doesn't\n // distort anything and the row looks flat even though it's rotated.\n transform.unshift({ perspective: height * 2 });\n transform.push({\n rotateX: scrollY.interpolate({ inputRange, outputRange: ranges.rotation, extrapolate: 'clamp' }),\n });\n }\n\n if (arc) {\n transform.push(\n { translateX: scrollY.interpolate({ inputRange, outputRange: arc.shift, extrapolate: 'clamp' }) },\n { rotateZ: scrollY.interpolate({ inputRange, outputRange: arc.angle, extrapolate: 'clamp' }) }\n );\n }\n\n const label = (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.text, option.disabled && styles.textDisabled, !!textColor && { color: textColor }]}\n >\n {option.label}\n </Text>\n );\n\n return (\n <Animated.View\n key={String(option.value)}\n testID={testID && `${testID}-item-${index}`}\n style={[\n styles.row,\n {\n height: itemHeight,\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: ranges.opacity,\n extrapolate: 'clamp',\n }),\n transform,\n },\n ]}\n >\n {smeared &&\n GHOST_SHIFTS.map((direction) => (\n <Animated.View\n key={direction}\n pointerEvents=\"none\"\n // `accessibilityElementsHidden` isn't enough: the reader\n // would read the row three times over, because the whole\n // wheel is already a single element and this lives inside it.\n style={[\n styles.ghost,\n {\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: GHOST_OPACITY_RANGE,\n extrapolate: 'clamp',\n }),\n transform: [\n {\n translateX: scrollY.interpolate({\n inputRange,\n outputRange: [\n direction * GHOST_SPREAD,\n direction * (GHOST_SPREAD / 2),\n 0,\n direction * (GHOST_SPREAD / 2),\n direction * GHOST_SPREAD,\n ],\n extrapolate: 'clamp',\n }),\n },\n ],\n },\n ]}\n >\n {label}\n </Animated.View>\n ))}\n\n <View style={styles.rowContent}>\n {label}\n {!!unit && index === selectedIndex && (\n <Text allowFontScaling={allowFontScaling} style={styles.unit}>\n {unit}\n </Text>\n )}\n </View>\n </Animated.View>\n );\n })}\n </Animated.ScrollView>\n </View>\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,10 +9,11 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "atoms/PressableScale.tsx",
12
- "content": "import React, { memo, useRef } from 'react';\nimport {\n Animated,\n Pressable,\n type GestureResponderEvent,\n type PressableProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\n\nexport interface PressableScaleProps extends Omit<PressableProps, 'style'> {\n /** Target scale when pressed. @default 0.96 */\n scaleTo?: number;\n style?: StyleProp<ViewStyle>;\n children: React.ReactNode;\n}\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\n/**\n * PressableScale\n * Feedback táctil de \"achicarse\" al presionar, con Animated.spring de RN\n * (useNativeDriver: true — corre en el hilo nativo, sin coste de JS por frame).\n * No depende de reanimated: la librería entera es cero-dependencias.\n */\nexport const PressableScale = memo(\n ({ scaleTo = 0.96, style, children, onPressIn, onPressOut, disabled, ...props }: PressableScaleProps) => {\n const scale = useRef(new Animated.Value(1)).current;\n\n const animateTo = (value: number) => {\n Animated.spring(scale, {\n toValue: value,\n useNativeDriver: true,\n speed: 50,\n bounciness: 6,\n }).start();\n };\n\n const handlePressIn = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(scaleTo);\n onPressIn?.(e);\n };\n\n const handlePressOut = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(1);\n onPressOut?.(e);\n };\n\n return (\n <AnimatedPressable\n {...props}\n disabled={disabled}\n onPressIn={handlePressIn}\n onPressOut={handlePressOut}\n style={[style, { transform: [{ scale }] }]}\n >\n {children}\n </AnimatedPressable>\n );\n }\n);\nPressableScale.displayName = 'PressableScale';\n"
12
+ "content": "import React, { memo, useRef } from 'react';\nimport {\n Animated,\n Pressable,\n type GestureResponderEvent,\n type PressableProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { useReduceMotion } from './Transition';\n\nexport interface PressableScaleProps extends Omit<PressableProps, 'style' | 'children'> {\n /** Target scale when pressed. @default 0.96 */\n scaleTo?: number;\n style?: StyleProp<ViewStyle>;\n /** Pressable content. */\n children: React.ReactNode;\n}\n\n/** Short spring with almost no bounce: feedback arrives in ~100 ms (Doherty). */\nconst PRESS_SPRING = { speed: 50, bounciness: 6 } as const;\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\n/**\n * PressableScale\n * Tactile \"shrink\" feedback on press, with RN's Animated.spring\n * (useNativeDriver: true — runs on the native thread, no JS cost per frame).\n * Doesn't depend on reanimated: the whole library is zero-dependency.\n *\n * Gestalt, figure/ground: as it sinks, the pressed element separates for an\n * instant from the rest and confirms what was touched (Doherty: immediate\n * response). Announces itself as a button by default (`accessibilityRole` can\n * be changed) and, when disabled, exposes `disabled` to the screen reader.\n * With Reduce Motion it doesn't scale.\n */\nexport const PressableScale = memo(\n ({\n scaleTo = 0.96,\n style,\n children,\n onPressIn,\n onPressOut,\n disabled,\n accessibilityRole = 'button',\n accessibilityState,\n ...props\n }: PressableScaleProps) => {\n const scale = useRef(new Animated.Value(1)).current;\n const reduceMotion = useReduceMotion();\n\n const animateTo = (value: number) => {\n if (reduceMotion) return;\n Animated.spring(scale, {\n toValue: value,\n useNativeDriver: true,\n ...PRESS_SPRING,\n }).start();\n };\n\n const handlePressIn = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(scaleTo);\n onPressIn?.(e);\n };\n\n const handlePressOut = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(1);\n onPressOut?.(e);\n };\n\n return (\n <AnimatedPressable\n {...props}\n accessibilityRole={accessibilityRole}\n accessibilityState={{ ...accessibilityState, disabled: !!disabled }}\n disabled={disabled}\n onPressIn={handlePressIn}\n onPressOut={handlePressOut}\n style={[style, { transform: [{ scale }] }]}\n >\n {children}\n </AnimatedPressable>\n );\n }\n);\nPressableScale.displayName = 'PressableScale';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
16
- "core"
16
+ "core",
17
+ "transition"
17
18
  ]
18
19
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/ReorderableList.tsx",
12
- "content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Fixed height of each row: needed to calculate the target index without measuring layout async. */\n itemHeight: number;\n renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;\n /** Called on release, with the array already reordered. Does not mutate `data`. */\n onReorder: (data: T[]) => void;\n /**\n * Starts dragging row `index`. Use it to turn off the container's scroll\n * (`scrollEnabled={false}`) while it lasts: on iOS the parent\n * `UIScrollView` cancels touches on the content as soon as its\n * vertical gesture starts, and without this the drag dies within a few pixels.\n */\n onDragStart?: (index: number) => void;\n /** Ends the drag — on release or cancellation. Always runs if `onDragStart` ran. */\n onDragEnd?: () => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\n/** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */\nexport function move<T>(data: T[], from: number, to: number): T[] {\n const next = data.slice();\n const [item] = next.splice(from, 1) as [T];\n next.splice(to, 0, item);\n return next;\n}\n\nconst useStyles = createStyles(() => ({\n row: { width: '100%' },\n}));\n\n/**\n * ReorderableList\n * Ley de destino común (common fate): al arrastrar una fila, las filas que\n * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo\n * que comunica \"van a intercambiar lugar\" antes de soltar el dedo, no un\n * simple resaltado del ítem activo.\n *\n * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):\n * PanResponder + Animated alcanzan porque el cálculo de índice destino\n * necesita el valor de arrastre en JS en cada movimiento (no algo que\n * useNativeDriver pueda resolver solo).\n *\n * Dentro de un scroll vertical hay que cablear `onDragStart`/`onDragEnd` a\n * `scrollEnabled`, o en iOS el arrastre no llega a empezar:\n *\n * ```tsx\n * const [dragging, setDragging] = useState(false);\n * <ScrollView scrollEnabled={!dragging}>\n * <ReorderableList onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)} … />\n * </ScrollView>\n * ```\n */\nexport function ReorderableList<T>({\n data,\n keyExtractor,\n itemHeight,\n renderItem,\n onReorder,\n onDragStart,\n onDragEnd,\n disabled = false,\n style,\n}: ReorderableListProps<T>) {\n const styles = useStyles();\n const [activeIndex, setActiveIndex] = useState<number | null>(null);\n const [targetIndex, setTargetIndex] = useState<number | null>(null);\n const dy = useRef(new Animated.Value(0)).current;\n const activeIndexRef = useRef<number | null>(null);\n // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de\n // abajo) y su closure queda fijo al render de esa primera vez: leer\n // `targetIndex` (state) directamente ahí siempre daría el valor del\n // montaje. Un ref sí refleja la última escritura sin importar cuándo se\n // creó la closure que lo lee.\n const targetIndexRef = useRef<number | null>(null);\n const dataRef = useRef(data);\n dataRef.current = data;\n // Igual razón que targetIndexRef: el responder de cada fila se crea una\n // sola vez, así que leer `disabled` directo del closure congelaría el\n // valor del momento en que esa fila se creó.\n const disabledRef = useRef(disabled);\n disabledRef.current = disabled;\n // Misma razón: los callbacks se leen desde closures creadas una sola vez.\n const onReorderRef = useRef(onReorder);\n onReorderRef.current = onReorder;\n const onDragStartRef = useRef(onDragStart);\n onDragStartRef.current = onDragStart;\n const onDragEndRef = useRef(onDragEnd);\n onDragEndRef.current = onDragEnd;\n\n const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;\n // Si `data` se achica, los responders de índices que ya no existen quedan\n // colgados en el Map para siempre: se podan en cada render.\n for (const index of panResponders.keys()) {\n if (index >= data.length) panResponders.delete(index);\n }\n\n function panResponderFor(index: number) {\n let responder = panResponders.get(index);\n if (responder) return responder;\n\n const endDrag = () => {\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n onDragEndRef.current?.();\n };\n\n responder = PanResponder.create({\n onStartShouldSetPanResponder: () => !disabledRef.current,\n onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,\n // Un scroll padre pide el responder apenas su gesto vertical arranca —\n // el mismo eje que el arrastre. Cederlo cancelaría el drag a mitad de\n // camino, así que la fila se lo queda hasta soltar.\n onPanResponderTerminationRequest: () => false,\n onPanResponderGrant: () => {\n activeIndexRef.current = index;\n targetIndexRef.current = index;\n setActiveIndex(index);\n setTargetIndex(index);\n dy.setValue(0);\n onDragStartRef.current?.(index);\n },\n onPanResponderMove: (_, gesture) => {\n dy.setValue(gesture.dy);\n const from = activeIndexRef.current;\n if (from === null) return;\n const rawTarget = from + Math.round(gesture.dy / itemHeight);\n const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));\n targetIndexRef.current = clamped;\n setTargetIndex((prev) => (prev === clamped ? prev : clamped));\n },\n onPanResponderRelease: () => {\n const from = activeIndexRef.current;\n const to = targetIndexRef.current;\n endDrag();\n if (from === null || to === null || from === to) return;\n onReorderRef.current(move(dataRef.current, from, to));\n },\n onPanResponderTerminate: endDrag,\n });\n panResponders.set(index, responder);\n return responder;\n }\n\n return (\n <View style={style}>\n {data.map((item, index) => {\n const isActive = activeIndex === index;\n // Filas entre el origen y el destino se corren un lugar entero en el\n // sentido del arrastre: es el efecto de \"grupo que se mueve junto\".\n let shift = 0;\n if (activeIndex !== null && targetIndex !== null && !isActive) {\n if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;\n else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;\n }\n\n return (\n <Animated.View\n key={keyExtractor(item, index)}\n style={[\n styles.row,\n isActive\n ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }\n : { transform: [{ translateY: shift }] },\n ]}\n {...panResponderFor(index).panHandlers}\n >\n {renderItem(item, index, isActive)}\n </Animated.View>\n );\n })}\n </View>\n );\n}\n"
12
+ "content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useLabels } from '../theme/UIProvider';\n\nexport interface ReorderableListProps<T> {\n /** Items, in their current order. */\n data: T[];\n /** Stable key per item. */\n keyExtractor: (item: T, index: number) => string;\n /** Fixed height of each row: needed to calculate the target index without measuring layout async. */\n itemHeight: number;\n /** Renders each row; the third argument is true while that row is being dragged. */\n renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;\n /** Called on release, with the array already reordered. Does not mutate `data`. */\n onReorder: (data: T[]) => void;\n /**\n * Starts dragging row `index`. Use it to turn off the container's scroll\n * (`scrollEnabled={false}`) while it lasts: on iOS the parent\n * `UIScrollView` cancels touches on the content as soon as its\n * vertical gesture starts, and without this the drag dies within a few pixels.\n */\n onDragStart?: (index: number) => void;\n /** Ends the drag — on release or cancellation. Always runs if `onDragStart` ran. */\n onDragEnd?: () => void;\n /** Turns off dragging. @default false */\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\n/** Reorders by moving the item from `from` to `to`, without mutating `data`. Exported separately so it can be tested without simulating gestures. */\nexport function move<T>(data: T[], from: number, to: number): T[] {\n const next = data.slice();\n const [item] = next.splice(from, 1) as [T];\n next.splice(to, 0, item);\n return next;\n}\n\nconst useStyles = createStyles(() => ({\n row: { width: '100%' },\n}));\n\n/**\n * ReorderableList\n * Law of common fate: when dragging a row, the rows it has to cross shift\n * together in the same direction — that is what communicates \"they are\n * going to swap places\" before the finger lifts, not a simple highlight of\n * the active item.\n *\n * No external gesture/animation dependencies (reanimated, gesture-handler):\n * PanResponder + Animated are enough because computing the target index\n * needs the drag value in JS on every move (not something useNativeDriver\n * can resolve on its own).\n *\n * Inside a vertical scroll you have to wire `onDragStart`/`onDragEnd` to\n * `scrollEnabled`, or on iOS the drag never gets to start:\n *\n * Screen readers can't drag: each row is one accessible element with two\n * actions, \"Move up\" and \"Move down\" (`labels.moveUp` / `labels.moveDown`),\n * that call `onReorder` the same way a drop does. The row's content is read\n * as its label, so keep interactive controls out of reorderable rows.\n *\n * ```tsx\n * const [dragging, setDragging] = useState(false);\n * <ScrollView scrollEnabled={!dragging}>\n * <ReorderableList onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)} … />\n * </ScrollView>\n * ```\n */\nexport function ReorderableList<T>({\n data,\n keyExtractor,\n itemHeight,\n renderItem,\n onReorder,\n onDragStart,\n onDragEnd,\n disabled = false,\n style,\n}: ReorderableListProps<T>) {\n const styles = useStyles();\n const labels = useLabels();\n const [activeIndex, setActiveIndex] = useState<number | null>(null);\n const [targetIndex, setTargetIndex] = useState<number | null>(null);\n const dy = useRef(new Animated.Value(0)).current;\n const activeIndexRef = useRef<number | null>(null);\n // Each row's PanResponder is created only once (cached in the Map below)\n // and its closure stays fixed to that first render: reading `targetIndex`\n // (state) directly there would always give the value from mount. A ref\n // does reflect the latest write no matter when the closure reading it was\n // created.\n const targetIndexRef = useRef<number | null>(null);\n const dataRef = useRef(data);\n dataRef.current = data;\n // Same reason as targetIndexRef: each row's responder is created only\n // once, so reading `disabled` straight from the closure would freeze the\n // value from when that row was created.\n const disabledRef = useRef(disabled);\n disabledRef.current = disabled;\n // Same reason: the callbacks are read from closures created only once.\n const onReorderRef = useRef(onReorder);\n onReorderRef.current = onReorder;\n const onDragStartRef = useRef(onDragStart);\n onDragStartRef.current = onDragStart;\n const onDragEndRef = useRef(onDragEnd);\n onDragEndRef.current = onDragEnd;\n\n const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;\n // If `data` shrinks, the responders for indexes that no longer exist stay\n // stuck in the Map forever: they are pruned on every render.\n for (const index of panResponders.keys()) {\n if (index >= data.length) panResponders.delete(index);\n }\n\n function panResponderFor(index: number) {\n let responder = panResponders.get(index);\n if (responder) return responder;\n\n const endDrag = () => {\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n onDragEndRef.current?.();\n };\n\n responder = PanResponder.create({\n onStartShouldSetPanResponder: () => !disabledRef.current,\n onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,\n // A parent scroll requests the responder as soon as its vertical gesture\n // starts — the same axis as the drag. Yielding it would cancel the drag\n // midway, so the row keeps it until release.\n onPanResponderTerminationRequest: () => false,\n onPanResponderGrant: () => {\n activeIndexRef.current = index;\n targetIndexRef.current = index;\n setActiveIndex(index);\n setTargetIndex(index);\n dy.setValue(0);\n onDragStartRef.current?.(index);\n },\n onPanResponderMove: (_, gesture) => {\n dy.setValue(gesture.dy);\n const from = activeIndexRef.current;\n if (from === null) return;\n const rawTarget = from + Math.round(gesture.dy / itemHeight);\n const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));\n targetIndexRef.current = clamped;\n setTargetIndex((prev) => (prev === clamped ? prev : clamped));\n },\n onPanResponderRelease: () => {\n const from = activeIndexRef.current;\n const to = targetIndexRef.current;\n endDrag();\n if (from === null || to === null || from === to) return;\n onReorderRef.current(move(dataRef.current, from, to));\n },\n onPanResponderTerminate: endDrag,\n });\n panResponders.set(index, responder);\n return responder;\n }\n\n return (\n <View style={style}>\n {data.map((item, index) => {\n const isActive = activeIndex === index;\n // Rows between the origin and the target shift one full place in the\n // drag direction: it is the \"group moving together\" effect.\n let shift = 0;\n if (activeIndex !== null && targetIndex !== null && !isActive) {\n if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;\n else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;\n }\n\n return (\n <Animated.View\n key={keyExtractor(item, index)}\n style={[\n styles.row,\n isActive\n ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }\n : { transform: [{ translateY: shift }] },\n ]}\n {...panResponderFor(index).panHandlers}\n accessible\n accessibilityActions={[\n ...(index > 0 ? [{ name: 'moveUp', label: labels.moveUp }] : []),\n ...(index < data.length - 1 ? [{ name: 'moveDown', label: labels.moveDown }] : []),\n ]}\n onAccessibilityAction={(event) => {\n if (disabled) return;\n const to = event.nativeEvent.actionName === 'moveUp' ? index - 1 : index + 1;\n if (to < 0 || to >= data.length) return;\n onReorder(move(data, index, to));\n }}\n >\n {renderItem(item, index, isActive)}\n </Animated.View>\n );\n })}\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Screen.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Which safe area insets to apply. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensates for the keyboard: on iOS with scroll the ScrollView itself does it, in\n * other cases a KeyboardAvoidingView does. Set to false when\n * the content already brings its own scroller that handles the keyboard (Wizard,\n * List, SearchList): the two compensations add up and the focused field\n * ends up pushed off screen.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
12
+ "content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets, useTheme } from '../theme/UIProvider';\nimport { KeyboardHandled, KeyboardPanArea, useKeyboardHost } from '../theme/KeyboardAvoidance';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n /** Content of the screen. */\n children: React.ReactNode;\n /**\n * Fixed bar on top (back button, title, actions). It stays in place while\n * the content scrolls or moves up for the keyboard. The top safe area goes\n * above it.\n */\n header?: React.ReactNode;\n style?: ViewStyle;\n /** Wraps the content in a ScrollView. @default true */\n scrollable?: boolean;\n /** Style of the ScrollView's content container. Only applies when `scrollable`. */\n contentContainerStyle?: ViewStyle;\n /** Which safe area insets to apply. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Keeps the focused Input above the keyboard by moving only the content\n * (the `header` and a tab bar outside the Screen stay put). On iOS with\n * scroll, the ScrollView scrolls to it instead. Set to false when the\n * content brings its own keyboard handling.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n header: { backgroundColor: theme.colors.background },\n}));\n\n/**\n * Screen\n * Root screen container: theme background, safe area (via the UIProvider's\n * insets), keyboard and optional scroll handled consistently.\n *\n * Keyboard: with the UIProvider handling it (default), Screen shifts only its\n * content — clipped below the `header` — to keep the focused Input above the\n * keyboard. The header stays fixed and a tab bar outside the Screen stays\n * behind the keyboard, as in a native app. On iOS with scroll, the ScrollView\n * already brings the field into view (automaticallyAdjustKeyboardInsets) and\n * nothing is added. Without a host (`keyboardAvoidance={false}` on the\n * provider) the usual KeyboardAvoidingView remains.\n *\n * Gestalt, common region: the header shares the screen's background and stays\n * fixed; only the content moves, so navigation isn't lost.\n */\nexport const Screen = memo(({\n children,\n header,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const theme = useTheme();\n const host = useKeyboardHost();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n const topInset = applyTop ? insets.top : 0;\n\n const scrollHandles = keyboardAvoiding && scrollable && Platform.OS === 'ios';\n const panArea = keyboardAvoiding && !scrollHandles && !!host;\n const avoidWithView = keyboardAvoiding && !scrollHandles && !host;\n\n // With a header, the top inset goes above the header; the content starts\n // right below it.\n const contentTop = header ? 0 : topInset;\n\n let content = scrollable ? (\n <ScrollView\n style={styles.container}\n automaticallyAdjustKeyboardInsets={scrollHandles}\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: contentTop },\n applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {scrollHandles ? <KeyboardHandled>{children}</KeyboardHandled> : children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: contentTop, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n );\n\n if (panArea) {\n content = (\n <KeyboardPanArea margin={theme.tokens.spacing.md} topLimit={insets.top} clip>\n {content}\n </KeyboardPanArea>\n );\n } else if (avoidWithView) {\n content = (\n <KeyboardAvoidingView\n style={styles.container}\n behavior={Platform.OS === 'ios' ? 'padding' : 'height'}\n keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : theme.tokens.spacing.xl}\n >\n {content}\n </KeyboardAvoidingView>\n );\n }\n\n return (\n <View style={[styles.wrapper, style]}>\n {header && <View style={[styles.header, { paddingTop: topInset }]}>{header}</View>}\n {content}\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,13 +9,19 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/SearchList.tsx",
12
- "content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /** Optional chrome above the search bar (e.g. back button + title). */\n header?: React.ReactNode;\n\n searchValue: string;\n onSearchChange: (text: string) => void;\n searchPlaceholder?: string;\n searchIsLoading?: boolean;\n onScanPress?: () => void;\n scanAccessibilityLabel?: string;\n extraActions?: React.ReactNode;\n\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Initial load: while true and there's no data, placeholders are shown. */\n isLoading?: boolean;\n /** 'replace' swaps the whole list for a spinner/skeleton; 'overlay' shows it on top while keeping the layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Reload with data already on screen (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlled by the consumer: while false, a fullscreen spinner is shown. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n /** Ghost rows for the initial load. @default 6 */\n skeletonCount?: number;\n /** Custom placeholder, to match the real shape of the item. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onLoadMore?: () => void;\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: StyleProp<ViewStyle>;\n containerStyle?: ViewStyle;\n\n /** List component to use (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1 },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * SearchList\n * Buscador + lista + estados de carga/paginación/vacío. El contenido de\n * cada ítem (renderItem) queda a cargo de quien la usa, ya que difiere por\n * completo entre casos de uso.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel = 'Scan',\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"search\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View style={styles.loadingOverlay}>\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View testID=\"list-skeleton\" style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\n"
12
+ "content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /** Optional chrome above the search bar (e.g. back button + title). */\n header?: React.ReactNode;\n\n /** Text of the search box (controlled). */\n searchValue: string;\n /** Called with the new text; the clear button calls it with an empty string. */\n onSearchChange: (text: string) => void;\n /** Placeholder of the search box. @default labels.search */\n searchPlaceholder?: string;\n /** Shows a spinner inside the search box. @default false */\n searchIsLoading?: boolean;\n /** Shows a button next to the search box and is called when it is pressed. */\n onScanPress?: () => void;\n /** Accessible name of the `onScanPress` button. @default labels.scan */\n scanAccessibilityLabel?: string;\n /** Extra nodes at the end of the search row. */\n extraActions?: React.ReactNode;\n\n /** Items to render. */\n data: T[];\n /** Stable key per item. */\n keyExtractor: (item: T, index: number) => string;\n /** Renders each row (same as FlatList's `renderItem`). */\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Initial load: while true and there's no data, placeholders are shown. */\n isLoading?: boolean;\n /** 'replace' swaps the whole list for a spinner/skeleton; 'overlay' shows it on top while keeping the layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Reload with data already on screen (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n /** Shows a spinner in the footer while the next page loads. @default false */\n isLoadingMore?: boolean;\n /** Controlled by the consumer: while false, a fullscreen spinner is shown. @default true */\n isReady?: boolean;\n /**\n * Text of the spinner shown while `isReady` is false and of the `overlay` loading. @default\n * labels.loading\n */\n loadingText?: string;\n /** Text of the footer spinner shown with `isLoadingMore`. @default labels.loadingMore */\n loadingMoreText?: string;\n /**\n * Whether more pages remain. While false, `noMoreText` shows at the end of a non-empty list.\n * @default false\n */\n hasMore?: boolean;\n /** Text shown at the end of the list when `hasMore` is false. */\n noMoreText?: string;\n\n /** Ghost rows for the initial load. @default 6 */\n skeletonCount?: number;\n /** Custom placeholder, to match the real shape of the item. */\n renderSkeletonItem?: () => React.ReactElement;\n\n /** Called when scrolling gets close to the end. Takes precedence over `onEndReached`. */\n onLoadMore?: () => void;\n /** Called when scrolling gets close to the end. Ignored when `onLoadMore` is passed. */\n onEndReached?: () => void;\n /** How close to the end `onEndReached` fires, in visible lengths of the list. @default 0.3 */\n onEndReachedThreshold?: number;\n /** Enables pull-to-refresh and is called when the user pulls. */\n onRefresh?: () => void;\n\n /** Title of the empty state. @default labels.noResultsTitle */\n emptyTitle?: string;\n /** Supporting line of the empty state. @default labels.noResultsDescription */\n emptyDescription?: string;\n /** Icon of the empty state. @default 'search' */\n emptyIconName?: IconName;\n\n /** Rendered above the items, inside the scroll. */\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n /** Rendered below the items, before the pagination loader and `noMoreText`. */\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n /** Style of the list's content container (also used by the skeleton). */\n contentContainerStyle?: StyleProp<ViewStyle>;\n /** Style of the outer wrapper. */\n containerStyle?: ViewStyle;\n\n /** List component to use (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n /** Extra props passed to `ListComponent`; they override the ones set here. */\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1 },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg, gap: theme.tokens.spacing.md },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: SCAN_SIZE, height: SCAN_SIZE, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1 },\n empty: { marginTop: theme.tokens.spacing.xxxl },\n}));\n\n/** The scan button is square and as tall as the search Input beside it. */\nconst SCAN_SIZE = 50;\n/** Same as Avatar's default size: the placeholder row matches a typical avatar row. */\nconst SKELETON_AVATAR = 44;\n\n/**\n * SearchList\n * Search box + list + loading/pagination/empty states. The content of each\n * item (renderItem) is up to whoever uses it, since it differs completely\n * between use cases.\n *\n * Design:\n * - Proximity + common region: the search row (box, scan, extra actions)\n * sits right on top of the results it filters.\n * - Doherty threshold: every wait has immediate feedback (placeholders, a\n * spinner in the box while searching, a footer loader while paging), and\n * \"no results\" only appears once it is known there is nothing.\n * - Jakob's law: the clear (×) inside the box, pull-to-refresh, the keyboard\n * dismissed when the list is dragged.\n * - Fitts's law: the scan button is as tall as the box, a 50pt target.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel,\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // The custom footer and the pagination loader coexist: previously the first\n // covered the second and the list stopped signaling it was fetching more.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Empty only once it is known there is nothing: during the initial load\n // placeholders are shown, not \"no results\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={styles.empty} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName, styles.empty]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"scan\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel ?? labels.scan}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View\n style={styles.loadingOverlay}\n accessible\n accessibilityRole=\"progressbar\"\n accessibilityLabel={resolvedLoadingText}\n accessibilityState={{ busy: true }}\n >\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View\n testID=\"list-skeleton\"\n style={[styles.listContent, contentContainerStyle]}\n // The placeholders are hidden from screen readers: the group\n // announces that the list is loading instead of silence.\n accessible\n accessibilityRole=\"progressbar\"\n accessibilityLabel={resolvedLoadingText}\n accessibilityState={{ busy: true }}\n >\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={SKELETON_AVATAR} />\n <View style={styles.skeletonText}>\n <Skeleton variant=\"text\" width=\"60%\" />\n <Skeleton variant=\"text\" width=\"35%\" />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\n"
13
+ },
14
+ {
15
+ "path": "atoms/inputValidation.ts",
16
+ "content": "import type { Labels } from '../theme/labels';\n\n/** A rule's value, optionally with its own message instead of the default label. */\nexport type InputRuleValue<T> = T | { value: T; message: string };\n\nexport interface InputRules {\n /** Fewer characters than this is an error. Message: `labels.minLengthError`. */\n minLength?: InputRuleValue<number>;\n /** More characters than this is an error. Message: `labels.maxLengthError`. */\n maxLength?: InputRuleValue<number>;\n /** The value must match. Message: `labels.patternError`. */\n pattern?: InputRuleValue<RegExp>;\n /**\n * Custom check, run last. Return an error message, or `false` for\n * `labels.patternError`; anything else means valid.\n */\n validate?: (value: string) => string | boolean | null | undefined;\n}\n\nfunction unpack<T>(rule: InputRuleValue<T>): { value: T; message?: string } {\n return typeof rule === 'object' && rule !== null && 'value' in rule && 'message' in rule\n ? (rule as { value: T; message: string })\n : { value: rule as T };\n}\n\n/**\n * Error for the value, or undefined if it is valid. Order: required, min\n * length, max length, pattern, custom validation — a single error is shown,\n * the first one. An empty optional field is valid: the other rules don't run on nothing.\n */\nexport function validateInputValue(\n value: string,\n { required, rules }: { required?: boolean; rules?: InputRules },\n labels: Labels\n): string | undefined {\n if (value.trim() === '') return required ? labels.requiredError : undefined;\n if (!rules) return undefined;\n\n if (rules.minLength !== undefined) {\n const { value: n, message } = unpack(rules.minLength);\n if (value.length < n) return message ?? labels.minLengthError.replace('{n}', String(n));\n }\n if (rules.maxLength !== undefined) {\n const { value: n, message } = unpack(rules.maxLength);\n if (value.length > n) return message ?? labels.maxLengthError.replace('{n}', String(n));\n }\n if (rules.pattern !== undefined) {\n const { value: re, message } = unpack(rules.pattern);\n // A RegExp with the `g` flag keeps lastIndex between calls: reset it.\n re.lastIndex = 0;\n if (!re.test(value)) return message ?? labels.patternError;\n }\n if (rules.validate) {\n const result = rules.validate(value);\n if (typeof result === 'string' && result !== '') return result;\n if (result === false) return labels.patternError;\n }\n return undefined;\n}\n"
13
17
  }
14
18
  ],
15
19
  "registryDependencies": [
16
20
  "core",
17
21
  "input",
18
22
  "empty-state",
23
+ "button",
24
+ "transition",
19
25
  "skeleton",
20
26
  "spinner",
21
27
  "icon-button",
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
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"
12
+ "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n Easing,\n I18nManager,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../theme/useReduceMotion';\n\nexport interface SegmentedControlOption<T extends string = string> {\n value: T;\n /** Visible text. Kept to one line (ellipsized); screen readers get it whole. */\n label: string;\n /** Disables only this segment. */\n disabled?: boolean;\n}\n\nexport interface SegmentedControlProps<T extends string = string> {\n /** The choices, all visible at once. Two to four read best (Hick); more belong in a Select. */\n options: SegmentedControlOption<T>[];\n /** The selected option's value. */\n value: T;\n /** Called with the new value; not called when pressing the segment already selected. */\n onChange: (value: T) => void;\n /** Disables the whole control. @default false */\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Inset between the track and the segments. */\nconst TRACK_PADDING = 3;\n/** Minimum touch target (Fitts): the segment plus the track inset reach it. */\nconst MIN_TOUCH = 44;\nconst DISABLED_OPACITY = 0.5;\nconst HIT_SLOP = { top: TRACK_PADDING, bottom: TRACK_PADDING };\n\nconst useStyles = createStyles((theme) => ({\n track: {\n flexDirection: 'row',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.md,\n padding: TRACK_PADDING,\n },\n segment: {\n flex: 1,\n minHeight: MIN_TOUCH - TRACK_PADDING * 2,\n paddingVertical: theme.tokens.spacing.sm,\n paddingHorizontal: theme.tokens.spacing.xs,\n borderRadius: theme.tokens.radius.sm,\n alignItems: 'center',\n justifyContent: 'center',\n },\n // Light: a white segment with a shadow over the grey track. Dark: shadows\n // don't show and `surface` is darker than the track (the segment looked\n // sunken), so the selected one takes the soft accent fill instead.\n // One shared indicator slides under the segments; until the track is\n // measured, the selected segment paints this same fill itself.\n indicator: {\n position: 'absolute',\n top: TRACK_PADDING,\n bottom: TRACK_PADDING,\n start: TRACK_PADDING,\n borderRadius: theme.tokens.radius.sm,\n },\n segmentActive:\n theme.scheme === 'dark'\n ? { backgroundColor: theme.colors.primarySoft }\n : { backgroundColor: theme.colors.surface, ...theme.tokens.shadow.sm },\n segmentDisabled: { opacity: DISABLED_OPACITY },\n label: {\n fontSize: theme.tokens.fontSize.md,\n lineHeight: theme.tokens.lineHeight.md,\n fontWeight: theme.tokens.fontWeight.medium,\n color: theme.colors.textLight,\n },\n labelActive: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n}));\n\n/**\n * SegmentedControl\n * Picks one option among a few, all visible. It knows no global state:\n * it receives `value` and reports through `onChange`.\n *\n * Gestalt, common region: one track holds every segment, so they read as a\n * single question. Similarity: equal widths and one line each, so no option\n * looks more important. Figure-ground: the selected segment is raised (surface\n * + shadow) above the sunken track; in dark, where shadows vanish, it takes\n * the soft accent fill. Fitts: every segment reaches 44pt.\n *\n * Continuity: the selection is one indicator that slides from the old segment\n * to the new one, so the eye follows the change instead of seeing one fill\n * vanish and another appear. With Reduce Motion it jumps.\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 theme = useTheme();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n const [trackWidth, setTrackWidth] = useState(0);\n const offset = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const index = options.findIndex((option) => option.value === value);\n const segmentWidth = trackWidth > 0 && options.length > 0 ? (trackWidth - TRACK_PADDING * 2) / options.length : 0;\n const showIndicator = segmentWidth > 0 && index >= 0;\n // transform isn't mirrored in RTL: the indicator starts at the right edge\n // (`start`), so it moves left.\n const target = index * segmentWidth * (I18nManager.isRTL ? -1 : 1);\n\n useEffect(() => {\n if (!showIndicator) return;\n // First placement (and Reduce Motion) jumps; only a change of value slides.\n if (!placed.current || reduceMotion) {\n placed.current = true;\n offset.setValue(target);\n return;\n }\n const animation = Animated.timing(offset, {\n toValue: target,\n duration: theme.tokens.duration.base,\n easing: Easing.out(Easing.cubic),\n useNativeDriver: true,\n });\n animation.start();\n return () => animation.stop();\n }, [target, showIndicator, reduceMotion, offset, theme.tokens.duration.base]);\n\n // A new width (rotation, resize) places the indicator without animating.\n const onLayout = (e: LayoutChangeEvent) => {\n const width = e.nativeEvent.layout.width;\n if (width === trackWidth) return;\n placed.current = false;\n setTrackWidth(width);\n };\n\n const activeDisabled = disabled || !!options[index]?.disabled;\n\n return (\n <View style={[styles.track, style]} accessibilityRole=\"radiogroup\" testID={testID} onLayout={onLayout}>\n {showIndicator && (\n <Animated.View\n pointerEvents=\"none\"\n testID={testID && `${testID}-indicator`}\n style={[\n styles.indicator,\n styles.segmentActive,\n { width: segmentWidth, transform: [{ translateX: offset }] },\n activeDisabled && styles.segmentDisabled,\n ]}\n />\n )}\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={[\n styles.segment,\n active && !showIndicator && styles.segmentActive,\n isDisabled && styles.segmentDisabled,\n ]}\n onPress={() => {\n if (!active) onChange(option.value);\n }}\n disabled={isDisabled}\n hitSlop={HIT_SLOP}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: active, checked: active, disabled: isDisabled }}\n accessibilityLabel={option.label}\n testID={testID && `${testID}-${option.value}`}\n >\n <Text\n allowFontScaling={allowFontScaling}\n numberOfLines={1}\n style={[styles.label, active && styles.labelActive]}\n >\n {option.label}\n </Text>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n}\n\n// memo() loses generics when wrapping: the cast restores them so that\n// `value` and `onChange` stay tied to the union of `options`.\nexport const SegmentedControl = memo(SegmentedControlInner) as typeof SegmentedControlInner;\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Select.tsx",
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 /** Adds a search box inside the dropdown. @default false */\n searchable?: boolean;\n searchPlaceholder?: string;\n /** Maximum height of the dropdown, if the available space allows it. @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"
12
+ "content": "import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';\nimport {\n ActivityIndicator,\n Keyboard,\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 { NativeOverlay } from '../theme/NativeOverlay';\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 /** Glyph from the library's icon set, left of the label. */\n iconName?: IconName;\n /** Emoji left of the label. Takes precedence over `iconName`. */\n emoji?: string;\n disabled?: boolean;\n}\n\nexport interface SelectProps<T> {\n /** Text above the field; also its accessible name. */\n label?: string;\n /** Shows an asterisk next to the label. @default false */\n required?: boolean;\n /** Options of the dropdown: label, value and, optionally, icon, emoji or disabled. */\n options: SelectOption<T>[];\n /** Value of the selected option (controlled). `undefined` shows the placeholder. */\n selectedValue: T | undefined;\n /** Called with the value of the chosen option; the dropdown then closes. */\n onSelect: (value: T) => void;\n /** Text while nothing is selected. @default labels.selectPlaceholder */\n placeholder?: string;\n /** Error shown under the field. */\n error?: string;\n /** Shows a spinner in place of the chevron and blocks opening. @default false */\n isLoading?: boolean;\n /** Dims the field and blocks opening. @default false */\n disabled?: boolean;\n /** Adds a search box inside the dropdown. @default false */\n searchable?: boolean;\n /** Placeholder of the search box (with `searchable`). @default labels.search */\n searchPlaceholder?: string;\n /** Maximum height of the dropdown, if the available space allows it. @default 280 */\n maxHeight?: number;\n /** Called with `true` when the dropdown opens and `false` when it closes. */\n onOpenChange?: (open: boolean) => void;\n /** Style of the outer wrapper (label, field and error). */\n containerStyle?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Room between the field and the dropdown. */\nconst GAP = 8;\n/** Below this the dropdown can't even show two rows. */\nconst MIN_HEIGHT = 120;\n/** Same height as Input and DateField, so a form's fields line up. */\nconst FIELD_MIN_HEIGHT = 50;\nconst ICON_SIZE = 20;\nconst CHECK_SIZE = 18;\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: FIELD_MIN_HEIGHT,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldOpen: { borderColor: theme.colors.primary },\n fieldError: { borderColor: theme.colors.error },\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.surfaceRaised,\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 + theme.tokens.spacing.xxs,\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, 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 * Single-option dropdown. The list is measured against the window and drawn\n * inside a Modal, not in the field's tree: that way no `overflow: hidden`\n * clips it, it doesn't depend on zIndex (unreliable on Android) and the\n * Modal's backdrop handles closing by tapping outside and with the back button.\n *\n * Design:\n * - Proximity + continuity: the list opens right against the field, toward\n * wherever there is more room, so the eye goes from field to options.\n * - Von Restorff: the picked option alone has the accent fill and a check.\n * - Jakob's law: same field as Input and DateField (height, border, label,\n * red border with an error); tap outside or Android back closes it.\n * - Hick's law: past a handful of options, `searchable` narrows the list.\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<ComponentRef<typeof 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 fieldName = label ?? placeholder ?? labels.selectPlaceholder;\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 // Opening doesn't wait for the measurement: if `measureInWindow` doesn't\n // respond, the list still appears (centered) instead of the field ceasing to work.\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 // The search box opens the keyboard over the dropdown: without subtracting\n // it, the list is drawn where it can no longer be seen.\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 // It opens toward wherever there is more room: against the bottom edge of\n // the screen, upward is the only way for it to fit.\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={ICON_SIZE} 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, !!error && styles.fieldError, open && styles.fieldOpen, isDisabled && styles.fieldDisabled]}\n onPress={openList}\n disabled={isDisabled}\n accessibilityRole=\"combobox\"\n accessibilityLabel={required ? `${fieldName}, ${labels.required}` : fieldName}\n accessibilityHint={error || undefined}\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={ICON_SIZE} color={colors.textLight} />\n )}\n </Pressable>\n\n {open && (\n <NativeOverlay transparent animationType=\"fade\" statusBarTranslucent onRequestClose={close}>\n <Pressable style={styles.backdrop} onPress={close} testID=\"select-backdrop\" />\n\n <View\n style={[styles.sheet, placement]}\n accessibilityRole=\"radiogroup\"\n // On iOS it may be drawn inside another open overlay: the reader stays in the list.\n accessibilityViewIsModal\n onAccessibilityEscape={close}\n testID=\"select-list\"\n >\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={CHECK_SIZE} 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 </NativeOverlay>\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": [