@delacour/react-native-ui 0.1.0-alpha.3 → 0.1.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 (482) hide show
  1. package/README.md +11 -7
  2. package/package.json +49 -5
  3. package/src/components/accordion/AGENTS.md +21 -9
  4. package/src/components/accordion/accordion-content.tsx +7 -4
  5. package/src/components/accordion/accordion-item.tsx +15 -13
  6. package/src/components/accordion/accordion.context.tsx +11 -5
  7. package/src/components/accordion/accordion.variants.test.ts +64 -3
  8. package/src/components/accordion/accordion.variants.ts +33 -9
  9. package/src/components/alert/AGENTS.md +102 -0
  10. package/src/components/alert/alert-action.tsx +17 -0
  11. package/src/components/alert/alert-close-button.tsx +50 -0
  12. package/src/components/alert/alert-content.tsx +15 -0
  13. package/src/components/alert/alert-description.tsx +16 -0
  14. package/src/components/alert/alert-glyphs.ts +25 -0
  15. package/src/components/alert/alert-indicator.tsx +40 -0
  16. package/src/components/alert/alert-title.tsx +18 -0
  17. package/src/components/alert/alert.context.tsx +69 -0
  18. package/src/components/alert/alert.tsx +160 -0
  19. package/src/components/alert/alert.types.ts +10 -0
  20. package/src/components/alert/alert.variants.test.ts +218 -0
  21. package/src/components/alert/alert.variants.ts +180 -0
  22. package/src/components/alert/index.ts +23 -0
  23. package/src/components/avatar/AGENTS.md +106 -0
  24. package/src/components/avatar/avatar-badge.tsx +51 -0
  25. package/src/components/avatar/avatar-group.tsx +139 -0
  26. package/src/components/avatar/avatar.context.tsx +93 -0
  27. package/src/components/avatar/avatar.tsx +201 -0
  28. package/src/components/avatar/avatar.utils.test.ts +174 -0
  29. package/src/components/avatar/avatar.utils.ts +141 -0
  30. package/src/components/avatar/avatar.variants.test.ts +251 -0
  31. package/src/components/avatar/avatar.variants.ts +270 -0
  32. package/src/components/avatar/index.ts +39 -0
  33. package/src/components/badge/AGENTS.md +5 -0
  34. package/src/components/badge/badge.tsx +6 -1
  35. package/src/components/bottom-sheet/AGENTS.md +186 -221
  36. package/src/components/bottom-sheet/bottom-sheet-background.tsx +31 -22
  37. package/src/components/bottom-sheet/bottom-sheet-close.tsx +23 -30
  38. package/src/components/bottom-sheet/bottom-sheet-container.tsx +54 -207
  39. package/src/components/bottom-sheet/bottom-sheet-content.tsx +25 -48
  40. package/src/components/bottom-sheet/bottom-sheet-description.tsx +8 -2
  41. package/src/components/bottom-sheet/bottom-sheet-flat-list.tsx +65 -0
  42. package/src/components/bottom-sheet/bottom-sheet-footer.tsx +53 -120
  43. package/src/components/bottom-sheet/bottom-sheet-handle.tsx +31 -26
  44. package/src/components/bottom-sheet/bottom-sheet-legend-list.tsx +69 -0
  45. package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +25 -147
  46. package/src/components/bottom-sheet/bottom-sheet-provider.tsx +42 -0
  47. package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +38 -52
  48. package/src/components/bottom-sheet/bottom-sheet-section-list.tsx +58 -0
  49. package/src/components/bottom-sheet/bottom-sheet-steps.tsx +88 -0
  50. package/src/components/bottom-sheet/bottom-sheet-text-input.tsx +44 -0
  51. package/src/components/bottom-sheet/bottom-sheet-title.tsx +12 -8
  52. package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +19 -37
  53. package/src/components/bottom-sheet/bottom-sheet.tsx +113 -60
  54. package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +47 -70
  55. package/src/components/bottom-sheet/bottom-sheet.variants.ts +50 -105
  56. package/src/components/bottom-sheet/index.ts +39 -17
  57. package/src/components/bottom-sheet/use-bottom-sheet-input.ts +16 -115
  58. package/src/components/button/AGENTS.md +45 -11
  59. package/src/components/button/button-group.tsx +44 -8
  60. package/src/components/button/button.context.tsx +10 -0
  61. package/src/components/button/button.tsx +2 -1
  62. package/src/components/button/button.variants.test.ts +154 -0
  63. package/src/components/button/button.variants.ts +71 -6
  64. package/src/components/button/index.ts +2 -0
  65. package/src/components/calendar/AGENTS.md +142 -0
  66. package/src/components/calendar/calendar-caption.tsx +116 -0
  67. package/src/components/calendar/calendar-day.tsx +103 -0
  68. package/src/components/calendar/calendar-grid.tsx +183 -0
  69. package/src/components/calendar/calendar-header.tsx +34 -0
  70. package/src/components/calendar/calendar-nav.tsx +51 -0
  71. package/src/components/calendar/calendar-picker.tsx +128 -0
  72. package/src/components/calendar/calendar-weekdays.tsx +48 -0
  73. package/src/components/calendar/calendar.context.tsx +108 -0
  74. package/src/components/calendar/calendar.date.test.ts +456 -0
  75. package/src/components/calendar/calendar.date.ts +584 -0
  76. package/src/components/calendar/calendar.tsx +389 -0
  77. package/src/components/calendar/calendar.types.ts +87 -0
  78. package/src/components/calendar/calendar.variants.test.ts +363 -0
  79. package/src/components/calendar/calendar.variants.ts +426 -0
  80. package/src/components/calendar/index.ts +96 -0
  81. package/src/components/card/AGENTS.md +88 -0
  82. package/src/components/card/card-action.tsx +16 -0
  83. package/src/components/card/card-content.tsx +12 -0
  84. package/src/components/card/card-description.tsx +12 -0
  85. package/src/components/card/card-footer.tsx +30 -0
  86. package/src/components/card/card-header.tsx +45 -0
  87. package/src/components/card/card-title.tsx +25 -0
  88. package/src/components/card/card.context.tsx +64 -0
  89. package/src/components/card/card.tsx +94 -0
  90. package/src/components/card/card.types.ts +13 -0
  91. package/src/components/card/card.variants.test.ts +240 -0
  92. package/src/components/card/card.variants.ts +177 -0
  93. package/src/components/card/index.ts +15 -0
  94. package/src/components/chart/AGENTS.md +9 -1
  95. package/src/components/chart/chart.tsx +8 -1
  96. package/src/components/chip/AGENTS.md +113 -0
  97. package/src/components/chip/chip-close-button.tsx +48 -0
  98. package/src/components/chip/chip-end-content.tsx +15 -0
  99. package/src/components/chip/chip-label.tsx +21 -0
  100. package/src/components/chip/chip-start-content.tsx +16 -0
  101. package/src/components/chip/chip.context.tsx +66 -0
  102. package/src/components/chip/chip.tsx +268 -0
  103. package/src/components/chip/chip.types.ts +10 -0
  104. package/src/components/chip/chip.variants.test.ts +335 -0
  105. package/src/components/chip/chip.variants.ts +321 -0
  106. package/src/components/chip/index.ts +24 -0
  107. package/src/components/collapsible/AGENTS.md +97 -0
  108. package/src/components/collapsible/collapsible-content.tsx +97 -0
  109. package/src/components/collapsible/collapsible-description.tsx +17 -0
  110. package/src/components/collapsible/collapsible-indicator.tsx +92 -0
  111. package/src/components/collapsible/collapsible-title.tsx +17 -0
  112. package/src/components/collapsible/collapsible-trigger.tsx +133 -0
  113. package/src/components/collapsible/collapsible.context.tsx +91 -0
  114. package/src/components/collapsible/collapsible.tsx +167 -0
  115. package/src/components/collapsible/collapsible.types.ts +9 -0
  116. package/src/components/collapsible/collapsible.variants.test.ts +338 -0
  117. package/src/components/collapsible/collapsible.variants.ts +228 -0
  118. package/src/components/collapsible/index.ts +32 -0
  119. package/src/components/context-menu/AGENTS.md +103 -0
  120. package/src/components/context-menu/context-menu-content.tsx +64 -0
  121. package/src/components/context-menu/context-menu-item.tsx +22 -0
  122. package/src/components/context-menu/context-menu-preview.tsx +60 -0
  123. package/src/components/context-menu/context-menu-trigger.tsx +160 -0
  124. package/src/components/context-menu/context-menu.context.tsx +91 -0
  125. package/src/components/context-menu/context-menu.tsx +90 -0
  126. package/src/components/context-menu/context-menu.variants.test.ts +266 -0
  127. package/src/components/context-menu/context-menu.variants.ts +147 -0
  128. package/src/components/context-menu/index.ts +18 -0
  129. package/src/components/dialog/AGENTS.md +97 -0
  130. package/src/components/dialog/dialog-body.tsx +21 -0
  131. package/src/components/dialog/dialog-close.tsx +82 -0
  132. package/src/components/dialog/dialog-content.tsx +153 -0
  133. package/src/components/dialog/dialog-description.tsx +23 -0
  134. package/src/components/dialog/dialog-footer.tsx +32 -0
  135. package/src/components/dialog/dialog-header.tsx +20 -0
  136. package/src/components/dialog/dialog-title.tsx +42 -0
  137. package/src/components/dialog/dialog-trigger.tsx +54 -0
  138. package/src/components/dialog/dialog.context.tsx +90 -0
  139. package/src/components/dialog/dialog.tsx +119 -0
  140. package/src/components/dialog/dialog.variants.test.ts +224 -0
  141. package/src/components/dialog/dialog.variants.ts +173 -0
  142. package/src/components/dialog/index.ts +30 -0
  143. package/src/components/drawer/AGENTS.md +123 -0
  144. package/src/components/drawer/drawer-body.tsx +46 -0
  145. package/src/components/drawer/drawer-close.tsx +76 -0
  146. package/src/components/drawer/drawer-content.tsx +186 -0
  147. package/src/components/drawer/drawer-description.tsx +23 -0
  148. package/src/components/drawer/drawer-footer.tsx +21 -0
  149. package/src/components/drawer/drawer-header.tsx +42 -0
  150. package/src/components/drawer/drawer-title.tsx +35 -0
  151. package/src/components/drawer/drawer-trigger.tsx +54 -0
  152. package/src/components/drawer/drawer.context.tsx +114 -0
  153. package/src/components/drawer/drawer.tsx +119 -0
  154. package/src/components/drawer/drawer.variants.test.ts +392 -0
  155. package/src/components/drawer/drawer.variants.ts +244 -0
  156. package/src/components/drawer/index.ts +41 -0
  157. package/src/components/drawer/use-drawer-pan.ts +108 -0
  158. package/src/components/empty-state/AGENTS.md +90 -0
  159. package/src/components/empty-state/empty-state-content.tsx +17 -0
  160. package/src/components/empty-state/empty-state-description.tsx +12 -0
  161. package/src/components/empty-state/empty-state-header.tsx +17 -0
  162. package/src/components/empty-state/empty-state-media.tsx +56 -0
  163. package/src/components/empty-state/empty-state-title.tsx +18 -0
  164. package/src/components/empty-state/empty-state.context.tsx +65 -0
  165. package/src/components/empty-state/empty-state.tsx +84 -0
  166. package/src/components/empty-state/empty-state.types.ts +14 -0
  167. package/src/components/empty-state/empty-state.variants.test.ts +245 -0
  168. package/src/components/empty-state/empty-state.variants.ts +132 -0
  169. package/src/components/empty-state/index.ts +20 -0
  170. package/src/components/fab/AGENTS.md +119 -0
  171. package/src/components/fab/fab-action.tsx +118 -0
  172. package/src/components/fab/fab-group.tsx +214 -0
  173. package/src/components/fab/fab-label.tsx +18 -0
  174. package/src/components/fab/fab.context.tsx +125 -0
  175. package/src/components/fab/fab.tsx +156 -0
  176. package/src/components/fab/fab.types.ts +17 -0
  177. package/src/components/fab/fab.variants.test.ts +274 -0
  178. package/src/components/fab/fab.variants.ts +234 -0
  179. package/src/components/fab/index.ts +37 -0
  180. package/src/components/feedback/AGENTS.md +95 -0
  181. package/src/components/feedback/feedback-action.tsx +16 -0
  182. package/src/components/feedback/feedback-cancel.tsx +34 -0
  183. package/src/components/feedback/feedback-close.tsx +21 -0
  184. package/src/components/feedback/feedback-content.tsx +24 -0
  185. package/src/components/feedback/feedback-field.tsx +70 -0
  186. package/src/components/feedback/feedback-footer.tsx +21 -0
  187. package/src/components/feedback/feedback-panel.tsx +54 -0
  188. package/src/components/feedback/feedback-submit.tsx +74 -0
  189. package/src/components/feedback/feedback-title.tsx +34 -0
  190. package/src/components/feedback/feedback-trigger.tsx +20 -0
  191. package/src/components/feedback/feedback.context.tsx +73 -0
  192. package/src/components/feedback/feedback.tsx +135 -0
  193. package/src/components/feedback/feedback.variants.test.ts +185 -0
  194. package/src/components/feedback/feedback.variants.ts +128 -0
  195. package/src/components/feedback/index.ts +24 -0
  196. package/src/components/field/AGENTS.md +19 -0
  197. package/src/components/field/field-label.tsx +19 -5
  198. package/src/components/field/field.context.tsx +17 -0
  199. package/src/components/field/field.tsx +15 -2
  200. package/src/components/field/field.variants.test.ts +24 -0
  201. package/src/components/field/field.variants.ts +20 -0
  202. package/src/components/field/index.ts +1 -0
  203. package/src/components/item/AGENTS.md +92 -0
  204. package/src/components/item/index.ts +25 -0
  205. package/src/components/item/item-actions.tsx +28 -0
  206. package/src/components/item/item-content.tsx +15 -0
  207. package/src/components/item/item-description.tsx +12 -0
  208. package/src/components/item/item-footer.tsx +13 -0
  209. package/src/components/item/item-group.tsx +32 -0
  210. package/src/components/item/item-header.tsx +13 -0
  211. package/src/components/item/item-media.tsx +37 -0
  212. package/src/components/item/item-separator.tsx +13 -0
  213. package/src/components/item/item-title.tsx +17 -0
  214. package/src/components/item/item.context.tsx +66 -0
  215. package/src/components/item/item.tsx +218 -0
  216. package/src/components/item/item.types.ts +12 -0
  217. package/src/components/item/item.variants.test.ts +263 -0
  218. package/src/components/item/item.variants.ts +200 -0
  219. package/src/components/kpi/AGENTS.md +125 -0
  220. package/src/components/kpi/index.ts +45 -0
  221. package/src/components/kpi/kpi-action.tsx +12 -0
  222. package/src/components/kpi/kpi-content.tsx +35 -0
  223. package/src/components/kpi/kpi-footer.tsx +52 -0
  224. package/src/components/kpi/kpi-group.tsx +70 -0
  225. package/src/components/kpi/kpi-header.tsx +25 -0
  226. package/src/components/kpi/kpi-icon.tsx +28 -0
  227. package/src/components/kpi/kpi-sparkline.tsx +145 -0
  228. package/src/components/kpi/kpi-stat.tsx +20 -0
  229. package/src/components/kpi/kpi-title.tsx +19 -0
  230. package/src/components/kpi/kpi-trend.tsx +105 -0
  231. package/src/components/kpi/kpi-value.tsx +32 -0
  232. package/src/components/kpi/kpi.context.tsx +111 -0
  233. package/src/components/kpi/kpi.tsx +154 -0
  234. package/src/components/kpi/kpi.types.ts +9 -0
  235. package/src/components/kpi/kpi.variants.test.ts +428 -0
  236. package/src/components/kpi/kpi.variants.ts +347 -0
  237. package/src/components/label/AGENTS.md +89 -0
  238. package/src/components/label/index.ts +10 -0
  239. package/src/components/label/label.tsx +97 -0
  240. package/src/components/label/label.variants.test.ts +176 -0
  241. package/src/components/label/label.variants.ts +123 -0
  242. package/src/components/menu/AGENTS.md +176 -0
  243. package/src/components/menu/index.ts +44 -0
  244. package/src/components/menu/menu-background.tsx +17 -0
  245. package/src/components/menu/menu-checkbox-item.tsx +57 -0
  246. package/src/components/menu/menu-content.tsx +303 -0
  247. package/src/components/menu/menu-item.tsx +162 -0
  248. package/src/components/menu/menu-label.tsx +14 -0
  249. package/src/components/menu/menu-radio-group.tsx +50 -0
  250. package/src/components/menu/menu-radio-item.tsx +57 -0
  251. package/src/components/menu/menu-separator.tsx +11 -0
  252. package/src/components/menu/menu-sub-content.tsx +72 -0
  253. package/src/components/menu/menu-sub-trigger.tsx +50 -0
  254. package/src/components/menu/menu-sub.tsx +75 -0
  255. package/src/components/menu/menu-trigger.tsx +57 -0
  256. package/src/components/menu/menu.context.tsx +214 -0
  257. package/src/components/menu/menu.tsx +88 -0
  258. package/src/components/menu/menu.types.ts +33 -0
  259. package/src/components/menu/menu.variants.test.ts +400 -0
  260. package/src/components/menu/menu.variants.ts +353 -0
  261. package/src/components/meter/AGENTS.md +135 -0
  262. package/src/components/meter/index.ts +43 -0
  263. package/src/components/meter/meter-fill.tsx +19 -0
  264. package/src/components/meter/meter-header.tsx +17 -0
  265. package/src/components/meter/meter-label.tsx +18 -0
  266. package/src/components/meter/meter-output.tsx +42 -0
  267. package/src/components/meter/meter-segments.tsx +83 -0
  268. package/src/components/meter/meter-track.tsx +16 -0
  269. package/src/components/meter/meter.context.tsx +62 -0
  270. package/src/components/meter/meter.tsx +286 -0
  271. package/src/components/meter/meter.types.ts +30 -0
  272. package/src/components/meter/meter.variants.test.ts +430 -0
  273. package/src/components/meter/meter.variants.ts +347 -0
  274. package/src/components/overlay/AGENTS.md +89 -0
  275. package/src/components/overlay/index.ts +27 -0
  276. package/src/components/overlay/overlay-portal.tsx +84 -0
  277. package/src/components/overlay/overlay-presence.test.ts +46 -0
  278. package/src/components/overlay/overlay-presence.ts +49 -0
  279. package/src/components/overlay/overlay-registry.test.ts +96 -0
  280. package/src/components/overlay/overlay-registry.ts +99 -0
  281. package/src/components/overlay/overlay-scrim.tsx +49 -0
  282. package/src/components/overlay/overlay.context.tsx +48 -0
  283. package/src/components/overlay/overlay.tsx +118 -0
  284. package/src/components/overlay/overlay.variants.test.ts +37 -0
  285. package/src/components/overlay/overlay.variants.ts +33 -0
  286. package/src/components/overlay/use-overlay-back-handler.ts +34 -0
  287. package/src/components/overlay/use-overlay-presence.ts +87 -0
  288. package/src/components/popover/AGENTS.md +138 -0
  289. package/src/components/popover/index.ts +48 -0
  290. package/src/components/popover/popover-anchor.tsx +49 -0
  291. package/src/components/popover/popover-arrow.tsx +89 -0
  292. package/src/components/popover/popover-close.tsx +73 -0
  293. package/src/components/popover/popover-content.tsx +235 -0
  294. package/src/components/popover/popover-description.tsx +18 -0
  295. package/src/components/popover/popover-title.tsx +33 -0
  296. package/src/components/popover/popover-trigger.tsx +56 -0
  297. package/src/components/popover/popover.context.tsx +71 -0
  298. package/src/components/popover/popover.position.test.ts +354 -0
  299. package/src/components/popover/popover.position.ts +286 -0
  300. package/src/components/popover/popover.tsx +123 -0
  301. package/src/components/popover/popover.variants.test.ts +135 -0
  302. package/src/components/popover/popover.variants.ts +85 -0
  303. package/src/components/popover/use-anchor-measure.ts +76 -0
  304. package/src/components/popover/use-anchored-content.ts +219 -0
  305. package/src/components/pressable/AGENTS.md +11 -1
  306. package/src/components/pressable/pressable.tsx +7 -2
  307. package/src/components/pressable/pressable.variants.test.ts +29 -0
  308. package/src/components/pressable/pressable.variants.ts +17 -0
  309. package/src/components/progress/AGENTS.md +120 -0
  310. package/src/components/progress/index.ts +40 -0
  311. package/src/components/progress/progress-fill.tsx +152 -0
  312. package/src/components/progress/progress-header.tsx +19 -0
  313. package/src/components/progress/progress-label.tsx +27 -0
  314. package/src/components/progress/progress-output.tsx +47 -0
  315. package/src/components/progress/progress-track.tsx +38 -0
  316. package/src/components/progress/progress.context.tsx +79 -0
  317. package/src/components/progress/progress.tsx +171 -0
  318. package/src/components/progress/progress.types.ts +23 -0
  319. package/src/components/progress/progress.variants.test.ts +335 -0
  320. package/src/components/progress/progress.variants.ts +284 -0
  321. package/src/components/progress-button/AGENTS.md +116 -0
  322. package/src/components/progress-button/index.ts +36 -0
  323. package/src/components/progress-button/progress-button-done.tsx +33 -0
  324. package/src/components/progress-button/progress-button-label.tsx +27 -0
  325. package/src/components/progress-button/progress-button.context.tsx +93 -0
  326. package/src/components/progress-button/progress-button.tsx +414 -0
  327. package/src/components/progress-button/progress-button.variants.test.ts +314 -0
  328. package/src/components/progress-button/progress-button.variants.ts +236 -0
  329. package/src/components/provider/AGENTS.md +38 -25
  330. package/src/components/provider/provider.tsx +38 -11
  331. package/src/components/radio/AGENTS.md +8 -10
  332. package/src/components/radio/radio.variants.test.ts +5 -6
  333. package/src/components/radio/radio.variants.ts +6 -7
  334. package/src/components/rating/AGENTS.md +143 -0
  335. package/src/components/rating/index.ts +43 -0
  336. package/src/components/rating/rating-output.tsx +39 -0
  337. package/src/components/rating/rating-star.tsx +70 -0
  338. package/src/components/rating/rating-stars.tsx +219 -0
  339. package/src/components/rating/rating.context.tsx +79 -0
  340. package/src/components/rating/rating.tsx +163 -0
  341. package/src/components/rating/rating.variants.test.ts +347 -0
  342. package/src/components/rating/rating.variants.ts +354 -0
  343. package/src/components/screen/screen-navbar-back-button.tsx +1 -1
  344. package/src/components/selection-mode/AGENTS.md +155 -0
  345. package/src/components/selection-mode/index.ts +43 -0
  346. package/src/components/selection-mode/selection-mode-action.tsx +65 -0
  347. package/src/components/selection-mode/selection-mode-bar.tsx +79 -0
  348. package/src/components/selection-mode/selection-mode-group.tsx +123 -0
  349. package/src/components/selection-mode/selection-mode-header.tsx +85 -0
  350. package/src/components/selection-mode/selection-mode-indicator.tsx +118 -0
  351. package/src/components/selection-mode/selection-mode-item.tsx +182 -0
  352. package/src/components/selection-mode/selection-mode.context.tsx +89 -0
  353. package/src/components/selection-mode/selection-mode.tsx +207 -0
  354. package/src/components/selection-mode/selection-mode.types.ts +36 -0
  355. package/src/components/selection-mode/selection-mode.variants.test.ts +292 -0
  356. package/src/components/selection-mode/selection-mode.variants.ts +329 -0
  357. package/src/components/skeleton/AGENTS.md +120 -0
  358. package/src/components/skeleton/index.ts +15 -0
  359. package/src/components/skeleton/skeleton-clock.ts +48 -0
  360. package/src/components/skeleton/skeleton-group.tsx +64 -0
  361. package/src/components/skeleton/skeleton-shimmer.tsx +80 -0
  362. package/src/components/skeleton/skeleton.context.tsx +45 -0
  363. package/src/components/skeleton/skeleton.tsx +227 -0
  364. package/src/components/skeleton/skeleton.variants.test.ts +245 -0
  365. package/src/components/skeleton/skeleton.variants.ts +222 -0
  366. package/src/components/slide-button/AGENTS.md +122 -0
  367. package/src/components/slide-button/index.ts +43 -0
  368. package/src/components/slide-button/slide-button-label.tsx +70 -0
  369. package/src/components/slide-button/slide-button-thumb.tsx +93 -0
  370. package/src/components/slide-button/slide-button.context.tsx +76 -0
  371. package/src/components/slide-button/slide-button.tsx +457 -0
  372. package/src/components/slide-button/slide-button.variants.test.ts +288 -0
  373. package/src/components/slide-button/slide-button.variants.ts +324 -0
  374. package/src/components/slider/index.ts +2 -0
  375. package/src/components/slider/slider-thumb.tsx +33 -1
  376. package/src/components/slider/slider.variants.test.ts +52 -0
  377. package/src/components/slider/slider.variants.ts +56 -2
  378. package/src/components/stack-card/AGENTS.md +169 -0
  379. package/src/components/stack-card/index.ts +37 -0
  380. package/src/components/stack-card/stack-card-action.tsx +66 -0
  381. package/src/components/stack-card/stack-card-actions.tsx +18 -0
  382. package/src/components/stack-card/stack-card-card.tsx +71 -0
  383. package/src/components/stack-card/stack-card-empty.tsx +30 -0
  384. package/src/components/stack-card/stack-card-slot.tsx +90 -0
  385. package/src/components/stack-card/stack-card-stamp.tsx +76 -0
  386. package/src/components/stack-card/stack-card.context.tsx +117 -0
  387. package/src/components/stack-card/stack-card.tsx +557 -0
  388. package/src/components/stack-card/stack-card.types.ts +26 -0
  389. package/src/components/stack-card/stack-card.variants.test.ts +355 -0
  390. package/src/components/stack-card/stack-card.variants.ts +360 -0
  391. package/src/components/steps/AGENTS.md +153 -0
  392. package/src/components/steps/index.ts +45 -0
  393. package/src/components/steps/steps-connector.tsx +31 -0
  394. package/src/components/steps/steps-description.tsx +26 -0
  395. package/src/components/steps/steps-indicator.tsx +106 -0
  396. package/src/components/steps/steps-item.tsx +225 -0
  397. package/src/components/steps/steps-panel.tsx +41 -0
  398. package/src/components/steps/steps-title.tsx +32 -0
  399. package/src/components/steps/steps-track.tsx +59 -0
  400. package/src/components/steps/steps.context.tsx +123 -0
  401. package/src/components/steps/steps.tsx +166 -0
  402. package/src/components/steps/steps.variants.test.ts +388 -0
  403. package/src/components/steps/steps.variants.ts +468 -0
  404. package/src/components/surface/AGENTS.md +72 -0
  405. package/src/components/surface/index.ts +15 -0
  406. package/src/components/surface/surface.context.tsx +54 -0
  407. package/src/components/surface/surface.tsx +70 -0
  408. package/src/components/surface/surface.variants.test.ts +180 -0
  409. package/src/components/surface/surface.variants.ts +133 -0
  410. package/src/components/swipe/AGENTS.md +140 -0
  411. package/src/components/swipe/index.ts +31 -0
  412. package/src/components/swipe/swipe-action.tsx +99 -0
  413. package/src/components/swipe/swipe-end.tsx +14 -0
  414. package/src/components/swipe/swipe-group.tsx +55 -0
  415. package/src/components/swipe/swipe-panel.tsx +55 -0
  416. package/src/components/swipe/swipe-start.tsx +15 -0
  417. package/src/components/swipe/swipe.context.tsx +135 -0
  418. package/src/components/swipe/swipe.tsx +393 -0
  419. package/src/components/swipe/swipe.types.ts +7 -0
  420. package/src/components/swipe/swipe.variants.test.ts +337 -0
  421. package/src/components/swipe/swipe.variants.ts +290 -0
  422. package/src/components/switch/switch.variants.ts +2 -2
  423. package/src/components/tabs/AGENTS.md +4 -3
  424. package/src/components/tabs/tabs.variants.test.ts +1 -1
  425. package/src/components/tabs/tabs.variants.ts +4 -6
  426. package/src/components/textarea/AGENTS.md +86 -0
  427. package/src/components/textarea/index.ts +16 -0
  428. package/src/components/textarea/textarea.tsx +157 -0
  429. package/src/components/textarea/textarea.variants.test.ts +219 -0
  430. package/src/components/textarea/textarea.variants.ts +171 -0
  431. package/src/components/toast/AGENTS.md +140 -0
  432. package/src/components/toast/index.ts +74 -0
  433. package/src/components/toast/toast-action.tsx +59 -0
  434. package/src/components/toast/toast-api.ts +32 -0
  435. package/src/components/toast/toast-close.tsx +44 -0
  436. package/src/components/toast/toast-content.tsx +11 -0
  437. package/src/components/toast/toast-description.tsx +11 -0
  438. package/src/components/toast/toast-indicator.tsx +34 -0
  439. package/src/components/toast/toast-item.tsx +308 -0
  440. package/src/components/toast/toast-title.tsx +13 -0
  441. package/src/components/toast/toast-viewport.tsx +135 -0
  442. package/src/components/toast/toast.context.tsx +69 -0
  443. package/src/components/toast/toast.store.test.ts +259 -0
  444. package/src/components/toast/toast.store.ts +338 -0
  445. package/src/components/toast/toast.timer.test.ts +64 -0
  446. package/src/components/toast/toast.timer.ts +51 -0
  447. package/src/components/toast/toast.tsx +132 -0
  448. package/src/components/toast/toast.variants.test.ts +393 -0
  449. package/src/components/toast/toast.variants.ts +304 -0
  450. package/src/components/toggle-button/AGENTS.md +106 -0
  451. package/src/components/toggle-button/index.ts +30 -0
  452. package/src/components/toggle-button/toggle-button-group.tsx +156 -0
  453. package/src/components/toggle-button/toggle-button-label.tsx +19 -0
  454. package/src/components/toggle-button/toggle-button.context.tsx +130 -0
  455. package/src/components/toggle-button/toggle-button.tsx +172 -0
  456. package/src/components/toggle-button/toggle-button.variants.test.ts +189 -0
  457. package/src/components/toggle-button/toggle-button.variants.ts +160 -0
  458. package/src/components/tooltip/AGENTS.md +133 -0
  459. package/src/components/tooltip/index.ts +28 -0
  460. package/src/components/tooltip/tooltip-arrow.tsx +40 -0
  461. package/src/components/tooltip/tooltip-content.tsx +179 -0
  462. package/src/components/tooltip/tooltip-description.tsx +19 -0
  463. package/src/components/tooltip/tooltip-text.tsx +21 -0
  464. package/src/components/tooltip/tooltip-title.tsx +20 -0
  465. package/src/components/tooltip/tooltip-trigger.tsx +103 -0
  466. package/src/components/tooltip/tooltip.context.tsx +68 -0
  467. package/src/components/tooltip/tooltip.tsx +213 -0
  468. package/src/components/tooltip/tooltip.variants.test.ts +231 -0
  469. package/src/components/tooltip/tooltip.variants.ts +177 -0
  470. package/src/hooks/use-calm-motion.tsx +31 -0
  471. package/src/lib/calm-motion.test.ts +17 -0
  472. package/src/lib/calm-motion.ts +23 -0
  473. package/src/lib/cn.test.ts +14 -0
  474. package/src/lib/cn.ts +2 -2
  475. package/src/no-gorhom.test.ts +54 -0
  476. package/src/styles/geometry.test.ts +1 -0
  477. package/src/styles/theme.css +4 -0
  478. package/src/styles/tokens.css +11 -0
  479. package/src/styles/tokens.test.ts +22 -0
  480. package/src/styles/tokens.ts +20 -0
  481. package/src/components/bottom-sheet/bottom-sheet-portal.tsx +0 -72
  482. package/src/components/bottom-sheet/bottom-sheet.context.tsx +0 -135
@@ -0,0 +1,86 @@
1
+ # Textarea
2
+
3
+ A multiline text field, sized in rows. One component, no parts: it renders an
4
+ [`Input`](../input/AGENTS.md) and adds a height and an optional character count.
5
+
6
+ `import { Textarea } from "@delacour/react-native-ui/textarea";`
7
+
8
+ ## Files
9
+
10
+ | File | What it holds |
11
+ | --- | --- |
12
+ | `index.ts` | → `@delacour/react-native-ui/textarea` |
13
+ | `textarea.tsx` | `Textarea` |
14
+ | `textarea.variants.ts` | Pure `tv()` slots, the line metrics and the height and count resolvers, no RN imports |
15
+ | `textarea.variants.test.ts` | |
16
+
17
+ ## Design
18
+
19
+ - **The box is `Input`'s, and nothing here restates it.** Variants (`primary`,
20
+ `secondary`), sizes (`sm`, `md`, `lg`), the focus ring, the invalid border
21
+ that outranks it, the disabled fade, the `accent-*` caret and placeholder, and
22
+ the `Field` cascade all come from rendering an `Input` with `multiline`. A
23
+ second `tv()` for the same border would be a second thing that can drift, and
24
+ a textarea under a row of single-line fields has to read as the same control
25
+ at a different height — not as a close match.
26
+ - **`rows` is a line count, and it becomes points in JavaScript.** A row count
27
+ only turns into a height once the height of a line is a number, so
28
+ `TEXTAREA_LINE_HEIGHTS` holds it per size and `resolveTextareaRowsHeight` adds
29
+ the padding and the border either side. The result is a **style**, never a
30
+ class: a runtime `` `h-[${n}px]` `` is never compiled by Tailwind's static
31
+ scanner and would draw nothing — the rule in
32
+ [Sizing](../../../AGENTS.md#sizing). `size` moves the type and the leading, so
33
+ a small three-row field and a large one are both three rows.
34
+ - **Every number the height counts is pinned to the class that draws it.** The
35
+ line height is set by a `leading-*` class in `textareaVariants`' `field` slot,
36
+ and the padding is `Input`'s multiline `py-*`. `textarea.variants.test.ts`
37
+ resolves both classes and fails if either disagrees with its number — a
38
+ retune of `Input`'s padding would otherwise mis-size every textarea by a few
39
+ points, silently, with the last line clipped at the bottom edge.
40
+ - **A paragraph gets paragraph leading.** `Input` sets `leading-tight` because
41
+ it centres one line in a fixed box; four lines at 1.25× read as a wall. The
42
+ textarea's `leading-5`/`-6`/`-7` replace it through `Input`'s own className
43
+ merge rather than sitting beside it, and a test holds each between 1.2× and
44
+ 1.6× the `--text-input-*` size so the type scale can be retuned without this
45
+ going stale.
46
+ - **`autoGrow` measures nothing.** It sets `minHeight` at `rows` and
47
+ `maxHeight` at `maxRows`, and between the two React Native's multiline
48
+ `TextInput` sizes itself to its content; past the cap it scrolls. An
49
+ `onContentSizeChange` loop would draw one frame at the old height after every
50
+ new line, which is exactly the jump a growing field exists to avoid. The field
51
+ never shrinks below `rows`, so a message typed and cleared goes back to where
52
+ it started rather than collapsing to one line.
53
+ - **`maxRows` only type-checks with `autoGrow`, and `showCount` only with
54
+ `maxLength`.** Both are unions in `TextareaProps`. A ceiling on a field that
55
+ does not grow is a number nothing reads, and a count with no limit to count
56
+ towards says nothing — a runtime warning would only find either out on a
57
+ device.
58
+ - **A bad row count degrades rather than throws.** `resolveTextareaRows` floors
59
+ to whole lines, clamps to at least one, falls back on `NaN` or `Infinity`, and
60
+ lifts a `maxRows` below `rows` up to it — otherwise a growing field would
61
+ shrink as it grew.
62
+ - **The count turns destructive at the limit, not past it.** Native `maxLength`
63
+ stops typing at the limit, so "over" is a state someone typing never sees;
64
+ the count has to change at the moment the keyboard stops working. It renders
65
+ `Text.Caption` and passes its colour through the preset's `color` prop, so the
66
+ `count` slot carries no colour class to fight it. It uses `tabular-nums` so it
67
+ does not jitter as it counts, and announces as "12 of 280 characters" rather
68
+ than as a fraction.
69
+ - **The count works controlled and uncontrolled.** A `value` is read directly;
70
+ without one, the last text the field reported is, seeded from `defaultValue`
71
+ so the first frame is already right.
72
+ - **There is always a wrapping `View`.** `className` reaches the field, as it
73
+ does on `Input`, and `containerClassName` reaches the wrapper that holds the
74
+ field and its count. The wrapper is there whether or not the count is, so
75
+ flipping `showCount` never remounts the field and drops focus.
76
+ - **`multiline` and `numberOfLines` are withheld.** It is always multiline, and
77
+ `rows` is its height; `numberOfLines` would be a second answer to the same
78
+ question, and on Android the one that wins.
79
+ - **There is no `label`, `description` or `errorMessage` prop.** A textarea goes
80
+ in a [`Field`](../field/AGENTS.md) the way an `Input` does — the reason
81
+ `Input` ships no label part holds here too. `apps/playground`'s
82
+ `/textarea` gallery ends with a composed form showing the trade at a call site.
83
+ - **Not for `Input.Group` or `Button.Group`.** An inner `Input` would read
84
+ either context and take its box or its corner, but a decorated or joined
85
+ paragraph is not a control this library draws, and the height math assumes
86
+ the box is the field's own.
@@ -0,0 +1,16 @@
1
+ export { Textarea, type TextareaProps } from "./textarea";
2
+ export {
3
+ resolveTextareaCount,
4
+ resolveTextareaFieldClass,
5
+ resolveTextareaHeightStyle,
6
+ resolveTextareaRows,
7
+ resolveTextareaRowsHeight,
8
+ TEXTAREA_BORDER_WIDTH,
9
+ TEXTAREA_DEFAULT_MAX_ROWS,
10
+ TEXTAREA_DEFAULT_ROWS,
11
+ TEXTAREA_LINE_HEIGHTS,
12
+ TEXTAREA_PADDING_Y,
13
+ type TextareaCount,
14
+ type TextareaVariantProps,
15
+ textareaVariants,
16
+ } from "./textarea.variants";
@@ -0,0 +1,157 @@
1
+ import { type ReactElement, useCallback, useState } from "react";
2
+ import { View } from "react-native";
3
+ import { Input, type InputProps } from "../input";
4
+ import { Text } from "../text";
5
+ import {
6
+ resolveTextareaCount,
7
+ resolveTextareaFieldClass,
8
+ resolveTextareaHeightStyle,
9
+ textareaVariants,
10
+ } from "./textarea.variants";
11
+
12
+ /**
13
+ * Whether the field grows with its text.
14
+ *
15
+ * A union so `maxRows` is only accepted where it means something: on a field
16
+ * that does not grow, a ceiling is a number nothing reads.
17
+ */
18
+ type TextareaGrowProps =
19
+ | {
20
+ /** Stay `rows` tall and scroll past it. The default. */
21
+ autoGrow?: false;
22
+ maxRows?: never;
23
+ }
24
+ | {
25
+ /** Grow with the text, from `rows` up to `maxRows`, then scroll. */
26
+ autoGrow: true;
27
+ /** The tallest the field grows, in lines. Default 10. */
28
+ maxRows?: number;
29
+ };
30
+
31
+ /**
32
+ * Whether a character count sits under the field.
33
+ *
34
+ * A union so the count cannot be asked for without a limit: a count with
35
+ * nothing to count towards says nothing, and a runtime check would only find
36
+ * that out on a device.
37
+ */
38
+ type TextareaCountProps =
39
+ | {
40
+ showCount?: false;
41
+ /** The most characters the field accepts. */
42
+ maxLength?: number;
43
+ }
44
+ | {
45
+ /** Show how much of `maxLength` is used, under the field's trailing edge. */
46
+ showCount: true;
47
+ maxLength: number;
48
+ };
49
+
50
+ export type TextareaProps = Omit<InputProps, "multiline" | "numberOfLines" | "maxLength"> &
51
+ TextareaGrowProps &
52
+ TextareaCountProps & {
53
+ /** How many lines tall the field is — its height when fixed, its floor when growing. Default 4. */
54
+ rows?: number;
55
+ /** Class for the view holding the field and its count. `className` styles the field itself. */
56
+ containerClassName?: string;
57
+ };
58
+
59
+ /**
60
+ * A multiline text field, sized in rows.
61
+ *
62
+ * The box is `Input`'s — its variants, sizes, focus ring, invalid border,
63
+ * disabled fade and the `Field` cascade all come from there unchanged, so a
64
+ * textarea and the single-line fields above it in a form are the same control
65
+ * at a different height rather than two that happen to match.
66
+ *
67
+ * What this adds is the height. `rows` is a line count, and it becomes points
68
+ * through the paragraph leading and the box's own padding and border — see
69
+ * `resolveTextareaHeightStyle`. It is applied as a style, never a class,
70
+ * because a runtime number cannot be a Tailwind class. `size` changes the type
71
+ * and leading, so a small three-row field and a large one are both three rows.
72
+ *
73
+ * `autoGrow` floors the field at `rows` and caps it at `maxRows`; between the
74
+ * two, React Native's multiline `TextInput` sizes itself to its content, and
75
+ * past the cap it scrolls. `showCount` with `maxLength` puts a count under the
76
+ * trailing edge that turns destructive at the limit.
77
+ *
78
+ * `multiline` and `numberOfLines` are withheld: it is always multiline, and its
79
+ * height comes from `rows`.
80
+ *
81
+ * Label, description and error come from `Field`, exactly as they do for
82
+ * `Input` — there is no `label` prop.
83
+ *
84
+ * @example
85
+ * <Field>
86
+ * <Field.Label>Notes</Field.Label>
87
+ * <Textarea placeholder="Anything we should know?" />
88
+ * </Field>
89
+ *
90
+ * @example
91
+ * <Textarea autoGrow maxRows={8} rows={2} placeholder="Message" />
92
+ *
93
+ * @example
94
+ * <Textarea maxLength={280} onChangeText={setStatus} showCount value={status} />
95
+ */
96
+ export function Textarea({
97
+ rows,
98
+ autoGrow,
99
+ maxRows,
100
+ showCount,
101
+ maxLength,
102
+ size,
103
+ className,
104
+ containerClassName,
105
+ style,
106
+ value,
107
+ defaultValue,
108
+ onChangeText,
109
+ ...props
110
+ }: TextareaProps): ReactElement {
111
+ const resolvedSize = size ?? "md";
112
+ const slots = textareaVariants({ size: resolvedSize });
113
+
114
+ // The count reads the value's length whether or not the caller owns the
115
+ // value. Controlled, `value` is the truth; uncontrolled, the last text the
116
+ // field reported is — seeded from `defaultValue` so the count is right on
117
+ // the first frame rather than after the first keystroke.
118
+ const [ownLength, setOwnLength] = useState(() => defaultValue?.length ?? 0);
119
+ const length = value === undefined ? ownLength : value.length;
120
+
121
+ const handleChangeText = useCallback(
122
+ (text: string) => {
123
+ setOwnLength(text.length);
124
+ onChangeText?.(text);
125
+ },
126
+ [onChangeText]
127
+ );
128
+
129
+ const count = showCount && maxLength !== undefined ? resolveTextareaCount({ length, maxLength }) : null;
130
+
131
+ return (
132
+ <View className={slots.root({ className: containerClassName })}>
133
+ <Input
134
+ {...props}
135
+ className={resolveTextareaFieldClass({ className, size: resolvedSize })}
136
+ defaultValue={defaultValue}
137
+ maxLength={maxLength}
138
+ multiline
139
+ onChangeText={handleChangeText}
140
+ size={resolvedSize}
141
+ style={[resolveTextareaHeightStyle({ autoGrow, maxRows, rows, size: resolvedSize }), style]}
142
+ value={value}
143
+ />
144
+ {count ? (
145
+ <Text.Caption
146
+ accessibilityLabel={count.accessibilityLabel}
147
+ accessibilityLiveRegion="polite"
148
+ className={slots.count()}
149
+ color={count.color}
150
+ >
151
+ {count.label}
152
+ </Text.Caption>
153
+ ) : null}
154
+ </View>
155
+ );
156
+ }
157
+ Textarea.displayName = "DelacourUI.Textarea";
@@ -0,0 +1,219 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { INPUT_SIZES, inputVariants, resolveInputFieldClass } from "../input/input.variants";
5
+ import {
6
+ resolveTextareaCount,
7
+ resolveTextareaFieldClass,
8
+ resolveTextareaHeightStyle,
9
+ resolveTextareaRows,
10
+ resolveTextareaRowsHeight,
11
+ TEXTAREA_BORDER_WIDTH,
12
+ TEXTAREA_DEFAULT_MAX_ROWS,
13
+ TEXTAREA_DEFAULT_ROWS,
14
+ TEXTAREA_LINE_HEIGHTS,
15
+ TEXTAREA_PADDING_Y,
16
+ textareaVariants,
17
+ } from "./textarea.variants";
18
+
19
+ const TOKENS_CSS = readFileSync(join(import.meta.dirname, "../../styles/tokens.css"), "utf-8");
20
+
21
+ /** Points behind a `--text-input-*` token in `tokens.css`. */
22
+ function inputTextPoints(size: string): number {
23
+ const match = TOKENS_CSS.match(new RegExp(`--text-input-${size}:\\s*([\\d.]+)px;`));
24
+ if (!match?.[1]) throw new Error(`tokens.css defines no --text-input-${size}`);
25
+ return Number(match[1]);
26
+ }
27
+
28
+ /** Points behind the first `<prefix>-N` spacing utility in a class string, at Tailwind's 4pt step. */
29
+ function spacingPoints(cls: string, prefix: string): number | undefined {
30
+ const match = cls.match(new RegExp(`(?:^|\\s)${prefix}-(\\d+(?:\\.\\d+)?)(?:\\s|$)`));
31
+ return match?.[1] === undefined ? undefined : Number(match[1]) * 4;
32
+ }
33
+
34
+ describe("the metrics the height is derived from", () => {
35
+ // The height is a number, so the padding it counts has to be the padding
36
+ // the box actually wears. That padding is `Input`'s multiline `py-*`, and
37
+ // restating it here is only safe because this test reads it back.
38
+ test("count the vertical padding Input's multiline box wears", () => {
39
+ for (const size of INPUT_SIZES) {
40
+ const cls = resolveInputFieldClass({ isMultiline: true, size });
41
+ expect(spacingPoints(cls, "py")).toBe(TEXTAREA_PADDING_Y[size]);
42
+ }
43
+ });
44
+
45
+ test("count the one-pixel border every variant draws", () => {
46
+ expect(inputVariants().root()).toMatch(/(?:^|\s)border(?:\s|$)/);
47
+ expect(TEXTAREA_BORDER_WIDTH).toBe(1);
48
+ });
49
+
50
+ test("give each size the line height its field class sets", () => {
51
+ for (const size of INPUT_SIZES) {
52
+ const cls = resolveTextareaFieldClass({ size });
53
+ expect(spacingPoints(cls, "leading")).toBe(TEXTAREA_LINE_HEIGHTS[size]);
54
+ }
55
+ });
56
+
57
+ // A line shorter than its type clips descenders; one under 1.2× reads as
58
+ // a wall. The ratio is what is pinned, so the type scale can be retuned.
59
+ test("set every line between 1.2× and 1.6× the value's type size", () => {
60
+ for (const size of INPUT_SIZES) {
61
+ const ratio = TEXTAREA_LINE_HEIGHTS[size] / inputTextPoints(size);
62
+ expect(ratio).toBeGreaterThanOrEqual(1.2);
63
+ expect(ratio).toBeLessThanOrEqual(1.6);
64
+ }
65
+ });
66
+
67
+ test("step the line height with the size", () => {
68
+ const heights = INPUT_SIZES.map((size) => TEXTAREA_LINE_HEIGHTS[size]);
69
+ expect([...heights].sort((a, b) => a - b)).toEqual(heights);
70
+ expect(new Set(heights).size).toBe(heights.length);
71
+ });
72
+ });
73
+
74
+ describe("resolveTextareaFieldClass", () => {
75
+ test("replaces Input's single-line leading rather than adding a second", () => {
76
+ for (const size of INPUT_SIZES) {
77
+ const cls = resolveInputFieldClass({
78
+ className: resolveTextareaFieldClass({ size }),
79
+ isMultiline: true,
80
+ size,
81
+ });
82
+ expect(cls).not.toContain("leading-tight");
83
+ expect(cls.match(/\bleading-/g)).toHaveLength(1);
84
+ }
85
+ });
86
+
87
+ test("merges an incoming className last", () => {
88
+ expect(resolveTextareaFieldClass({ className: "leading-9", size: "md" })).toContain("leading-9");
89
+ expect(resolveTextareaFieldClass({ className: "leading-9", size: "md" })).not.toContain("leading-6");
90
+ });
91
+ });
92
+
93
+ describe("resolveTextareaRows", () => {
94
+ test("defaults to four rows and a ten-row ceiling", () => {
95
+ expect(TEXTAREA_DEFAULT_ROWS).toBe(4);
96
+ expect(TEXTAREA_DEFAULT_MAX_ROWS).toBe(10);
97
+ expect(resolveTextareaRows({})).toEqual({ maxRows: 10, rows: 4 });
98
+ });
99
+
100
+ test("never goes below one row", () => {
101
+ expect(resolveTextareaRows({ rows: 0 }).rows).toBe(1);
102
+ expect(resolveTextareaRows({ rows: -3 }).rows).toBe(1);
103
+ });
104
+
105
+ test("rounds a fractional row count down to whole lines", () => {
106
+ expect(resolveTextareaRows({ rows: 2.7 }).rows).toBe(2);
107
+ expect(resolveTextareaRows({ maxRows: 5.9, rows: 2 }).maxRows).toBe(5);
108
+ });
109
+
110
+ // A ceiling below the floor would make the field shrink as it grew.
111
+ test("lifts a ceiling below the floor up to it", () => {
112
+ expect(resolveTextareaRows({ maxRows: 2, rows: 6 })).toEqual({ maxRows: 6, rows: 6 });
113
+ });
114
+
115
+ test("falls back on a non-finite count", () => {
116
+ expect(resolveTextareaRows({ rows: Number.NaN })).toEqual({ maxRows: 10, rows: 4 });
117
+ expect(resolveTextareaRows({ maxRows: Number.POSITIVE_INFINITY, rows: 3 }).maxRows).toBe(10);
118
+ });
119
+ });
120
+
121
+ describe("resolveTextareaRowsHeight", () => {
122
+ test("is the lines plus the padding and border either side", () => {
123
+ for (const size of INPUT_SIZES) {
124
+ const expected = 3 * TEXTAREA_LINE_HEIGHTS[size] + 2 * TEXTAREA_PADDING_Y[size] + 2 * TEXTAREA_BORDER_WIDTH;
125
+ expect(resolveTextareaRowsHeight({ rows: 3, size })).toBe(expected);
126
+ }
127
+ });
128
+
129
+ test("grows by exactly one line per row", () => {
130
+ for (const size of INPUT_SIZES) {
131
+ const step = resolveTextareaRowsHeight({ rows: 5, size }) - resolveTextareaRowsHeight({ rows: 4, size });
132
+ expect(step).toBe(TEXTAREA_LINE_HEIGHTS[size]);
133
+ }
134
+ });
135
+
136
+ // A one-row textarea should not be shorter than the single-line field it
137
+ // sits beside in a form.
138
+ test("is never shorter than a single-line Input at one row", () => {
139
+ const INPUT_HEIGHTS = { lg: 52, md: 44, sm: 36 } as const;
140
+ for (const size of INPUT_SIZES) {
141
+ expect(resolveTextareaRowsHeight({ rows: 1, size })).toBeGreaterThanOrEqual(INPUT_HEIGHTS[size]);
142
+ }
143
+ });
144
+ });
145
+
146
+ describe("resolveTextareaHeightStyle", () => {
147
+ test("fixes the height at the row count when it does not grow", () => {
148
+ expect(resolveTextareaHeightStyle({ rows: 3, size: "md" })).toEqual({
149
+ height: resolveTextareaRowsHeight({ rows: 3, size: "md" }),
150
+ });
151
+ });
152
+
153
+ test("floors at rows and caps at maxRows when it grows", () => {
154
+ expect(resolveTextareaHeightStyle({ autoGrow: true, maxRows: 6, rows: 2, size: "sm" })).toEqual({
155
+ maxHeight: resolveTextareaRowsHeight({ rows: 6, size: "sm" }),
156
+ minHeight: resolveTextareaRowsHeight({ rows: 2, size: "sm" }),
157
+ });
158
+ });
159
+
160
+ test("caps a growing field at ten rows when maxRows is unset", () => {
161
+ expect(resolveTextareaHeightStyle({ autoGrow: true, size: "md" })).toEqual({
162
+ maxHeight: resolveTextareaRowsHeight({ rows: 10, size: "md" }),
163
+ minHeight: resolveTextareaRowsHeight({ rows: 4, size: "md" }),
164
+ });
165
+ });
166
+
167
+ test("ignores maxRows on a field that does not grow", () => {
168
+ expect(resolveTextareaHeightStyle({ maxRows: 8, rows: 2, size: "lg" })).toEqual({
169
+ height: resolveTextareaRowsHeight({ rows: 2, size: "lg" }),
170
+ });
171
+ });
172
+ });
173
+
174
+ describe("resolveTextareaCount", () => {
175
+ test("reads as used over limit", () => {
176
+ expect(resolveTextareaCount({ length: 12, maxLength: 280 }).label).toBe("12/280");
177
+ });
178
+
179
+ test("announces the count as a sentence", () => {
180
+ expect(resolveTextareaCount({ length: 1, maxLength: 280 }).accessibilityLabel).toBe("1 of 280 characters");
181
+ });
182
+
183
+ test("reports the limit once it is reached, and not before", () => {
184
+ expect(resolveTextareaCount({ length: 279, maxLength: 280 }).isAtLimit).toBe(false);
185
+ expect(resolveTextareaCount({ length: 280, maxLength: 280 }).isAtLimit).toBe(true);
186
+ });
187
+
188
+ test("is muted under the limit and destructive at it", () => {
189
+ expect(resolveTextareaCount({ length: 3, maxLength: 10 }).color).toBe("muted");
190
+ expect(resolveTextareaCount({ length: 10, maxLength: 10 }).color).toBe("destructive");
191
+ });
192
+
193
+ // A controlled value set past the limit in code is still over it; the
194
+ // native `maxLength` only stops typing.
195
+ test("still reports a value past the limit", () => {
196
+ expect(resolveTextareaCount({ length: 300, maxLength: 280 })).toEqual({
197
+ accessibilityLabel: "300 of 280 characters",
198
+ color: "destructive",
199
+ isAtLimit: true,
200
+ label: "300/280",
201
+ });
202
+ });
203
+ });
204
+
205
+ describe("the count slot", () => {
206
+ test("sits at the trailing edge", () => {
207
+ expect(textareaVariants().count()).toContain("self-end");
208
+ });
209
+
210
+ test("uses tabular figures, so the count does not jitter as it changes", () => {
211
+ expect(textareaVariants().count()).toContain("tabular-nums");
212
+ });
213
+
214
+ // Colour belongs to the `Text` preset's `color` prop, not to a class that
215
+ // would fight it in the merge.
216
+ test("carries no text colour of its own", () => {
217
+ expect(textareaVariants().count()).not.toMatch(/\btext-(?:muted|destructive|foreground)/);
218
+ });
219
+ });
@@ -0,0 +1,171 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+ import type { InputSize } from "../input/input.variants";
4
+
5
+ /** How many lines tall a textarea is when `rows` is not named. */
6
+ export const TEXTAREA_DEFAULT_ROWS = 4;
7
+
8
+ /** The tallest a growing textarea goes when `maxRows` is not named. */
9
+ export const TEXTAREA_DEFAULT_MAX_ROWS = 10;
10
+
11
+ /**
12
+ * Points per line of text, by size.
13
+ *
14
+ * A paragraph needs more leading than the one line `Input` sets `leading-tight`
15
+ * for, and a row count only becomes a height once the height of a line is a
16
+ * number. So the number lives here, and the `leading-*` class that sets it lives
17
+ * in {@link textareaVariants} beside it; `textarea.variants.test.ts` reads the
18
+ * class back and fails if the two ever disagree.
19
+ */
20
+ export const TEXTAREA_LINE_HEIGHTS: Record<InputSize, number> = { sm: 20, md: 24, lg: 28 };
21
+
22
+ /**
23
+ * Vertical padding either side of the text, by size.
24
+ *
25
+ * A restatement of `Input`'s multiline `py-*` compounds — the box is `Input`'s,
26
+ * so the padding is too. The test resolves `Input`'s class and pins these
27
+ * against it, so a retune there fails here rather than silently mis-sizing
28
+ * every textarea by a few points.
29
+ */
30
+ export const TEXTAREA_PADDING_Y: Record<InputSize, number> = { sm: 8, md: 10, lg: 12 };
31
+
32
+ /** The hairline `Input`'s box draws on every variant, counted top and bottom. */
33
+ export const TEXTAREA_BORDER_WIDTH = 1;
34
+
35
+ /**
36
+ * Styling for the parts a textarea adds around `Input`.
37
+ *
38
+ * Everything the box itself wears — border, fill, corner, focus and invalid —
39
+ * is `Input`'s, and is not restated here. What this adds is the paragraph
40
+ * leading, and the character count that sits under the box.
41
+ *
42
+ * `count` carries no colour class: it renders `Text.Caption`, whose `color`
43
+ * prop is the one place that decision is made — {@link resolveTextareaCount}.
44
+ *
45
+ * Free of React Native imports so it stays unit-testable. See AGENTS.md.
46
+ */
47
+ export const textareaVariants = tv({
48
+ slots: {
49
+ root: "gap-1.5",
50
+ field: "",
51
+ count: "self-end tabular-nums",
52
+ },
53
+ variants: {
54
+ size: {
55
+ sm: { field: "leading-5" },
56
+ md: { field: "leading-6" },
57
+ lg: { field: "leading-7" },
58
+ },
59
+ },
60
+ defaultVariants: {
61
+ size: "md",
62
+ },
63
+ });
64
+
65
+ export type TextareaVariantProps = VariantProps<typeof textareaVariants>;
66
+
67
+ /**
68
+ * The class the textarea hands `Input` for its field.
69
+ *
70
+ * `Input` merges it after its own field slot, so the `leading-*` here replaces
71
+ * the `leading-tight` that slot sets rather than sitting beside it — and a
72
+ * caller's className, merged last here, still wins over both.
73
+ *
74
+ * Pure, so it is reachable from `bun test`. See AGENTS.md.
75
+ */
76
+ export function resolveTextareaFieldClass({ size, className }: { size: InputSize; className?: string }): string {
77
+ return textareaVariants({ size }).field({ className });
78
+ }
79
+
80
+ /** A whole, positive line count, or the fallback when the value is not one. */
81
+ function wholeRows(value: number | undefined, fallback: number): number {
82
+ if (value === undefined || !Number.isFinite(value)) return fallback;
83
+ return Math.max(1, Math.floor(value));
84
+ }
85
+
86
+ /**
87
+ * The floor and ceiling a textarea is sized between, in lines.
88
+ *
89
+ * A ceiling below the floor is lifted up to it — otherwise a growing field
90
+ * would shrink as it grew.
91
+ *
92
+ * Pure, so it is reachable from `bun test`. See AGENTS.md.
93
+ */
94
+ export function resolveTextareaRows({ rows, maxRows }: { rows?: number; maxRows?: number }): {
95
+ rows: number;
96
+ maxRows: number;
97
+ } {
98
+ const floor = wholeRows(rows, TEXTAREA_DEFAULT_ROWS);
99
+ return { maxRows: Math.max(floor, wholeRows(maxRows, TEXTAREA_DEFAULT_MAX_ROWS)), rows: floor };
100
+ }
101
+
102
+ /**
103
+ * Points tall a box of `rows` lines is: the lines, plus the padding and the
104
+ * border either side.
105
+ *
106
+ * Pure, so it is reachable from `bun test`. See AGENTS.md.
107
+ */
108
+ export function resolveTextareaRowsHeight({ rows, size }: { rows: number; size: InputSize }): number {
109
+ return rows * TEXTAREA_LINE_HEIGHTS[size] + 2 * TEXTAREA_PADDING_Y[size] + 2 * TEXTAREA_BORDER_WIDTH;
110
+ }
111
+
112
+ /**
113
+ * The height a textarea is drawn at, as a style — never a class.
114
+ *
115
+ * A row count is a runtime number, and Tailwind's scanner is static: a
116
+ * `` `h-[${n}px]` `` is never compiled and would resolve to nothing. See
117
+ * AGENTS.md **Sizing**.
118
+ *
119
+ * A fixed field is exactly `rows` tall and scrolls past it. A growing one is
120
+ * floored at `rows` and capped at `maxRows`, and between the two React Native's
121
+ * own multiline `TextInput` sizes itself to its content — no measuring pass, so
122
+ * no frame drawn at the old height after a line is added.
123
+ *
124
+ * Pure, so it is reachable from `bun test`. See AGENTS.md.
125
+ */
126
+ export function resolveTextareaHeightStyle({
127
+ size,
128
+ rows,
129
+ maxRows,
130
+ autoGrow = false,
131
+ }: {
132
+ size: InputSize;
133
+ rows?: number;
134
+ maxRows?: number;
135
+ autoGrow?: boolean;
136
+ }): { height: number } | { minHeight: number; maxHeight: number } {
137
+ const lines = resolveTextareaRows({ maxRows, rows });
138
+ if (!autoGrow) return { height: resolveTextareaRowsHeight({ rows: lines.rows, size }) };
139
+ return {
140
+ maxHeight: resolveTextareaRowsHeight({ rows: lines.maxRows, size }),
141
+ minHeight: resolveTextareaRowsHeight({ rows: lines.rows, size }),
142
+ };
143
+ }
144
+
145
+ /** What the character count shows, announces and is coloured. */
146
+ export type TextareaCount = {
147
+ label: string;
148
+ accessibilityLabel: string;
149
+ isAtLimit: boolean;
150
+ color: "muted" | "destructive";
151
+ };
152
+
153
+ /**
154
+ * The character count under a textarea.
155
+ *
156
+ * Turns destructive once the limit is reached, not after it: the native
157
+ * `maxLength` stops typing at the limit, so "over" is a state a person typing
158
+ * can never see, and the count has to say something at the moment typing stops
159
+ * working. A controlled value set past the limit in code still reads as at it.
160
+ *
161
+ * Pure, so it is reachable from `bun test`. See AGENTS.md.
162
+ */
163
+ export function resolveTextareaCount({ length, maxLength }: { length: number; maxLength: number }): TextareaCount {
164
+ const isAtLimit = length >= maxLength;
165
+ return {
166
+ accessibilityLabel: `${length} of ${maxLength} characters`,
167
+ color: isAtLimit ? "destructive" : "muted",
168
+ isAtLimit,
169
+ label: `${length}/${maxLength}`,
170
+ };
171
+ }