@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,259 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createToastApi,
4
+ createToastStore,
5
+ normalizeToastInput,
6
+ reduceToasts,
7
+ type ToastItem,
8
+ type ToastMessageItem,
9
+ type ToastState,
10
+ } from "./toast.store";
11
+
12
+ const AT = { now: 1000, batchIndex: 0 };
13
+
14
+ function message(id: string, title = id): ToastMessageItem {
15
+ const item = normalizeToastInput({ id, title }, { ...AT, id });
16
+ if (item.kind !== "message") throw new Error("expected a message");
17
+ return item;
18
+ }
19
+
20
+ function ids(state: ToastState): string[] {
21
+ return state.map((item) => item.id);
22
+ }
23
+
24
+ /** A store whose microtasks run when the test says so, so a batch is explicit. */
25
+ function manualStore() {
26
+ const queue: (() => void)[] = [];
27
+ let now = 0;
28
+ const store = createToastStore({
29
+ now: () => now,
30
+ schedule: (task) => {
31
+ queue.push(task);
32
+ },
33
+ });
34
+ return {
35
+ store,
36
+ api: createToastApi(store),
37
+ flush: () => {
38
+ for (const task of queue.splice(0)) task();
39
+ },
40
+ advance: (ms: number) => {
41
+ now += ms;
42
+ },
43
+ };
44
+ }
45
+
46
+ describe("normalizeToastInput", () => {
47
+ test("a string is a default-status message titled with it", () => {
48
+ const item = normalizeToastInput("Saved", { ...AT, id: "t1" });
49
+ expect(item).toMatchObject({
50
+ kind: "message",
51
+ id: "t1",
52
+ title: "Saved",
53
+ status: "default",
54
+ placement: "bottom",
55
+ isLoading: false,
56
+ isExiting: false,
57
+ revision: 0,
58
+ createdAt: 1000,
59
+ });
60
+ });
61
+
62
+ test("a render function makes a custom toast", () => {
63
+ const render = () => null;
64
+ const item = normalizeToastInput({ render, placement: "top" }, { ...AT, id: "t2" });
65
+ expect(item.kind).toBe("custom");
66
+ expect(item.placement).toBe("top");
67
+ });
68
+
69
+ test("an id on the options wins over the generated one", () => {
70
+ expect(normalizeToastInput({ id: "mine", title: "x" }, { ...AT, id: "t3" }).id).toBe("mine");
71
+ });
72
+ });
73
+
74
+ describe("reduceToasts", () => {
75
+ test("add appends, oldest first", () => {
76
+ const state = reduceToasts(reduceToasts([], { type: "add", item: message("a") }), {
77
+ type: "add",
78
+ item: message("b"),
79
+ });
80
+ expect(ids(state)).toEqual(["a", "b"]);
81
+ });
82
+
83
+ test("add with an existing id replaces it in place and bumps its revision", () => {
84
+ let state: ToastState = [message("a"), message("b")];
85
+ state = reduceToasts(state, { type: "hide", id: "a" });
86
+ state = reduceToasts(state, { type: "add", item: message("a", "Again") });
87
+ expect(ids(state)).toEqual(["a", "b"]);
88
+ expect(state[0]).toMatchObject({ title: "Again", revision: 1, isExiting: false });
89
+ });
90
+
91
+ test("update merges into the toast with that id and bumps its revision", () => {
92
+ const state = reduceToasts([message("a")], {
93
+ type: "update",
94
+ id: "a",
95
+ patch: { status: "success", description: "All done" },
96
+ });
97
+ expect(state[0]).toMatchObject({ title: "a", status: "success", description: "All done", revision: 1 });
98
+ });
99
+
100
+ test("update of an unknown id is the identity", () => {
101
+ const state: ToastState = [message("a")];
102
+ expect(reduceToasts(state, { type: "update", id: "zzz", patch: { title: "x" } })).toBe(state);
103
+ });
104
+
105
+ test("update with a title turns a custom toast into a message", () => {
106
+ const custom = normalizeToastInput({ id: "c", render: () => null }, { ...AT, id: "c" });
107
+ const [item] = reduceToasts([custom], { type: "update", id: "c", patch: { title: "Now words" } });
108
+ expect(item).toMatchObject({ kind: "message", title: "Now words", status: "default" });
109
+ });
110
+
111
+ test("hide marks the toast exiting and keeps it, so the viewport can animate it out", () => {
112
+ const state = reduceToasts([message("a")], { type: "hide", id: "a" });
113
+ expect(ids(state)).toEqual(["a"]);
114
+ expect(state[0]?.isExiting).toBe(true);
115
+ });
116
+
117
+ test("hide of an exiting or unknown toast is the identity", () => {
118
+ const exiting = reduceToasts([message("a")], { type: "hide", id: "a" });
119
+ expect(reduceToasts(exiting, { type: "hide", id: "a" })).toBe(exiting);
120
+ expect(reduceToasts(exiting, { type: "hide", id: "zzz" })).toBe(exiting);
121
+ });
122
+
123
+ test("remove drops it", () => {
124
+ const state = reduceToasts([message("a"), message("b")], { type: "remove", id: "a" });
125
+ expect(ids(state)).toEqual(["b"]);
126
+ });
127
+
128
+ test("hideAll marks every toast exiting", () => {
129
+ const state = reduceToasts([message("a"), message("b")], { type: "hideAll" });
130
+ expect(state.every((item: ToastItem) => item.isExiting)).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe("createToastStore", () => {
135
+ test("notifies subscribers and hands out a fresh snapshot", () => {
136
+ const { store, api } = manualStore();
137
+ const before = store.getSnapshot();
138
+ let calls = 0;
139
+ const unsubscribe = store.subscribe(() => {
140
+ calls += 1;
141
+ });
142
+ api.show("Saved");
143
+ expect(calls).toBe(1);
144
+ expect(store.getSnapshot()).not.toBe(before);
145
+ unsubscribe();
146
+ api.show("Again");
147
+ expect(calls).toBe(1);
148
+ });
149
+
150
+ test("a no-op action keeps the snapshot and notifies no one", () => {
151
+ const { store, api } = manualStore();
152
+ api.show("Saved");
153
+ const snapshot = store.getSnapshot();
154
+ let calls = 0;
155
+ store.subscribe(() => {
156
+ calls += 1;
157
+ });
158
+ api.update("missing", { title: "x" });
159
+ expect(store.getSnapshot()).toBe(snapshot);
160
+ expect(calls).toBe(0);
161
+ });
162
+
163
+ test("toasts added in one tick are numbered for the stagger, and the count resets after it", () => {
164
+ const { store, api, flush } = manualStore();
165
+ api.show("one");
166
+ api.show("two");
167
+ api.show("three");
168
+ flush();
169
+ api.show("four");
170
+ expect(store.getSnapshot().map((item) => item.batchIndex)).toEqual([0, 1, 2, 0]);
171
+ });
172
+
173
+ test("ids come from a counter and never repeat", () => {
174
+ const { api } = manualStore();
175
+ const a = api.show("a");
176
+ const b = api.show("b");
177
+ expect(a.id).not.toBe(b.id);
178
+ });
179
+ });
180
+
181
+ describe("createToastApi", () => {
182
+ test("the api is callable as well as a namespace", () => {
183
+ const { store, api } = manualStore();
184
+ api("Saved");
185
+ expect(store.getSnapshot()[0]).toMatchObject({ title: "Saved" });
186
+ });
187
+
188
+ test("the status shorthands set the status, and error is destructive", () => {
189
+ const { store, api } = manualStore();
190
+ api.success("s");
191
+ api.info("i");
192
+ api.warning("w");
193
+ api.error("e", { description: "why" });
194
+ expect(store.getSnapshot().map((item) => (item.kind === "message" ? item.status : null))).toEqual([
195
+ "success",
196
+ "info",
197
+ "warning",
198
+ "destructive",
199
+ ]);
200
+ expect(store.getSnapshot()[3]).toMatchObject({ description: "why" });
201
+ });
202
+
203
+ test("the handle hides its own toast", () => {
204
+ const { store, api } = manualStore();
205
+ const handle = api.show("Saved");
206
+ handle.hide();
207
+ expect(store.getSnapshot()[0]?.isExiting).toBe(true);
208
+ });
209
+
210
+ test("hide and hideAll", () => {
211
+ const { store, api } = manualStore();
212
+ const a = api.show("a");
213
+ api.show("b");
214
+ api.hide(a.id);
215
+ expect(store.getSnapshot().map((item) => item.isExiting)).toEqual([true, false]);
216
+ api.hideAll();
217
+ expect(store.getSnapshot().every((item) => item.isExiting)).toBe(true);
218
+ });
219
+
220
+ test("promise: loading, then success in place under the same id", async () => {
221
+ const { store, api } = manualStore();
222
+ const result = api.promise(Promise.resolve(3), {
223
+ loading: "Uploading…",
224
+ success: (count) => `Uploaded ${count} files`,
225
+ error: "Upload failed",
226
+ });
227
+ const [loading] = store.getSnapshot();
228
+ expect(loading).toMatchObject({ kind: "message", title: "Uploading…", isLoading: true, status: "default" });
229
+
230
+ expect(await result).toBe(3);
231
+ const after = store.getSnapshot();
232
+ expect(after).toHaveLength(1);
233
+ expect(after[0]).toMatchObject({
234
+ id: loading?.id,
235
+ title: "Uploaded 3 files",
236
+ status: "success",
237
+ isLoading: false,
238
+ revision: 1,
239
+ });
240
+ });
241
+
242
+ test("promise: loading, then error in place, and the caller still sees the rejection", async () => {
243
+ const { store, api } = manualStore();
244
+ const failure = new Error("offline");
245
+ const result = api.promise(Promise.reject(failure), {
246
+ loading: "Saving…",
247
+ success: "Saved",
248
+ error: (error) => `Failed: ${(error as Error).message}`,
249
+ });
250
+ const id = store.getSnapshot()[0]?.id;
251
+ await expect(result).rejects.toBe(failure);
252
+ expect(store.getSnapshot()[0]).toMatchObject({
253
+ id,
254
+ title: "Failed: offline",
255
+ status: "destructive",
256
+ isLoading: false,
257
+ });
258
+ });
259
+ });
@@ -0,0 +1,338 @@
1
+ import type { ReactNode } from "react";
2
+ import type { AlertStatus } from "../alert/alert.variants";
3
+ import type { HapticFeedback } from "../pressable/pressable";
4
+
5
+ /** What the toast says about what just happened — Alert's statuses, so the two read as one family. */
6
+ export type ToastStatus = AlertStatus;
7
+
8
+ /** Which edge of the screen the toast enters from and stacks against. */
9
+ export type ToastPlacement = "top" | "bottom";
10
+
11
+ /** What `toast()` returns: the toast's id and a way to hide it. */
12
+ export type ToastHandle = {
13
+ id: string;
14
+ hide: () => void;
15
+ };
16
+
17
+ /** A single trailing action. Pressing it calls `onPress` and hides the toast. */
18
+ export type ToastActionOptions = {
19
+ label: string;
20
+ onPress: (handle: ToastHandle) => void;
21
+ };
22
+
23
+ type ToastCommonOptions = {
24
+ /** Pass an existing toast's id to replace it in place rather than adding another. */
25
+ id?: string;
26
+ /** Milliseconds before it hides itself. `0` stays until hidden. Default 4000, 6000 with an action, at least 10000 under a screen reader. */
27
+ duration?: number;
28
+ /** The edge it enters from. Default `"bottom"`. */
29
+ placement?: ToastPlacement;
30
+ /** A haptic played as it appears. Defaults to the status's: success, warning, error, or none. `false` for none. */
31
+ haptic?: HapticFeedback | false;
32
+ /** Called once the toast has left the screen, however it was hidden. */
33
+ onHide?: () => void;
34
+ };
35
+
36
+ export type ToastOptions = ToastCommonOptions & {
37
+ /** Default `"default"`. Picks the glyph, the title's colour and the default haptic. */
38
+ status?: ToastStatus;
39
+ title: string;
40
+ description?: string;
41
+ action?: ToastActionOptions;
42
+ /** Draws a spinner for the glyph and, unless `duration` says otherwise, stays until updated. `toast.promise` sets it. */
43
+ isLoading?: boolean;
44
+ };
45
+
46
+ export type ToastCustomOptions = ToastCommonOptions & {
47
+ /** Draws the whole toast. Compose `<Toast>` inside it to keep the house look. */
48
+ render: (handle: ToastHandle) => ReactNode;
49
+ };
50
+
51
+ /** Anything `toast()` accepts: a title, a message, or a custom render. */
52
+ export type ToastInput = string | ToastOptions | ToastCustomOptions;
53
+
54
+ /** What `toast.update` merges in. A `title` turns a custom toast into a message; a `render` the other way. */
55
+ export type ToastPatch = Partial<Omit<ToastOptions, "id">> & { render?: ToastCustomOptions["render"] };
56
+
57
+ type ToastItemBase = {
58
+ id: string;
59
+ placement: ToastPlacement;
60
+ duration: number | undefined;
61
+ haptic: HapticFeedback | false | undefined;
62
+ onHide: (() => void) | undefined;
63
+ /** Hidden, and animating out. The viewport removes it once the exit ends. */
64
+ isExiting: boolean;
65
+ /** Bumped by every update, so the viewport restarts the clock and announces it again. */
66
+ revision: number;
67
+ /** When it was first shown, ms. */
68
+ createdAt: number;
69
+ /** Its place among toasts shown in the same tick — what the entrance stagger counts. */
70
+ batchIndex: number;
71
+ };
72
+
73
+ export type ToastMessageItem = ToastItemBase & {
74
+ kind: "message";
75
+ status: ToastStatus;
76
+ title: string;
77
+ description: string | undefined;
78
+ action: ToastActionOptions | undefined;
79
+ isLoading: boolean;
80
+ };
81
+
82
+ export type ToastCustomItem = ToastItemBase & {
83
+ kind: "custom";
84
+ render: ToastCustomOptions["render"];
85
+ };
86
+
87
+ /** One toast in the store. */
88
+ export type ToastItem = ToastMessageItem | ToastCustomItem;
89
+
90
+ /** Every toast, oldest first. Exiting toasts stay until the viewport removes them. */
91
+ export type ToastState = readonly ToastItem[];
92
+
93
+ export type ToastStoreAction =
94
+ | { type: "add"; item: ToastItem }
95
+ | { type: "update"; id: string; patch: ToastPatch }
96
+ | { type: "hide"; id: string }
97
+ | { type: "remove"; id: string }
98
+ | { type: "hideAll" };
99
+
100
+ function isCustomInput(input: ToastOptions | ToastCustomOptions): input is ToastCustomOptions {
101
+ return "render" in input && typeof input.render === "function";
102
+ }
103
+
104
+ /** Turns whatever `toast()` was given into a store item. */
105
+ export function normalizeToastInput(
106
+ input: ToastInput,
107
+ context: { id: string; now: number; batchIndex: number }
108
+ ): ToastItem {
109
+ const options: ToastOptions | ToastCustomOptions = typeof input === "string" ? { title: input } : input;
110
+ const base: ToastItemBase = {
111
+ id: options.id ?? context.id,
112
+ placement: options.placement ?? "bottom",
113
+ duration: options.duration,
114
+ haptic: options.haptic,
115
+ onHide: options.onHide,
116
+ isExiting: false,
117
+ revision: 0,
118
+ createdAt: context.now,
119
+ batchIndex: context.batchIndex,
120
+ };
121
+ if (isCustomInput(options)) return { ...base, kind: "custom", render: options.render };
122
+ return {
123
+ ...base,
124
+ kind: "message",
125
+ status: options.status ?? "default",
126
+ title: options.title,
127
+ description: options.description,
128
+ action: options.action,
129
+ isLoading: options.isLoading ?? false,
130
+ };
131
+ }
132
+
133
+ function patchItem(item: ToastItem, patch: ToastPatch): ToastItem {
134
+ const base: ToastItemBase = {
135
+ id: item.id,
136
+ placement: patch.placement ?? item.placement,
137
+ duration: "duration" in patch ? patch.duration : item.duration,
138
+ haptic: "haptic" in patch ? patch.haptic : item.haptic,
139
+ onHide: patch.onHide ?? item.onHide,
140
+ isExiting: item.isExiting,
141
+ revision: item.revision + 1,
142
+ createdAt: item.createdAt,
143
+ batchIndex: item.batchIndex,
144
+ };
145
+ if (patch.render) return { ...base, kind: "custom", render: patch.render };
146
+ if (item.kind === "custom") {
147
+ if (patch.title === undefined) return { ...base, kind: "custom", render: item.render };
148
+ return {
149
+ ...base,
150
+ kind: "message",
151
+ status: patch.status ?? "default",
152
+ title: patch.title,
153
+ description: patch.description,
154
+ action: patch.action,
155
+ isLoading: patch.isLoading ?? false,
156
+ };
157
+ }
158
+ return {
159
+ ...base,
160
+ kind: "message",
161
+ status: patch.status ?? item.status,
162
+ title: patch.title ?? item.title,
163
+ description: "description" in patch ? patch.description : item.description,
164
+ action: "action" in patch ? patch.action : item.action,
165
+ isLoading: patch.isLoading ?? item.isLoading,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * The store's whole behaviour, as a pure reducer.
171
+ *
172
+ * `hide` only marks a toast exiting, so the viewport can animate it out;
173
+ * `remove` drops it once that exit has run. An action that changes nothing
174
+ * returns the state it was given, so the store can skip notifying anyone.
175
+ */
176
+ export function reduceToasts(state: ToastState, action: ToastStoreAction): ToastState {
177
+ switch (action.type) {
178
+ case "add": {
179
+ const index = state.findIndex((item) => item.id === action.item.id);
180
+ if (index === -1) return [...state, action.item];
181
+ const previous = state[index] as ToastItem;
182
+ const replaced: ToastItem = {
183
+ ...action.item,
184
+ revision: previous.revision + 1,
185
+ createdAt: previous.createdAt,
186
+ batchIndex: previous.batchIndex,
187
+ };
188
+ return state.map((item, at) => (at === index ? replaced : item));
189
+ }
190
+ case "update": {
191
+ const index = state.findIndex((item) => item.id === action.id);
192
+ if (index === -1) return state;
193
+ return state.map((item, at) => (at === index ? patchItem(item, action.patch) : item));
194
+ }
195
+ case "hide": {
196
+ const index = state.findIndex((item) => item.id === action.id && !item.isExiting);
197
+ if (index === -1) return state;
198
+ return state.map((item, at) => (at === index ? { ...item, isExiting: true } : item));
199
+ }
200
+ case "remove": {
201
+ const next = state.filter((item) => item.id !== action.id);
202
+ return next.length === state.length ? state : next;
203
+ }
204
+ case "hideAll": {
205
+ if (state.every((item) => item.isExiting)) return state;
206
+ return state.map((item) => (item.isExiting ? item : { ...item, isExiting: true }));
207
+ }
208
+ default:
209
+ return state;
210
+ }
211
+ }
212
+
213
+ export type ToastStore = {
214
+ getSnapshot: () => ToastState;
215
+ subscribe: (listener: () => void) => () => void;
216
+ dispatch: (action: ToastStoreAction) => void;
217
+ /** Builds an item for `input`, with a fresh id and its place in this tick's batch. */
218
+ createItem: (input: ToastInput) => ToastItem;
219
+ };
220
+
221
+ /**
222
+ * A tiny external store, read with `useSyncExternalStore`.
223
+ *
224
+ * It lives outside React so `toast()` works from anywhere — an API client, a
225
+ * mutation's `onError` — with no provider and no hook. `schedule` closes the
226
+ * current batch: every toast created before it runs shares one tick, and the
227
+ * viewport staggers their entrances by `batchIndex`.
228
+ */
229
+ export function createToastStore({
230
+ now = Date.now,
231
+ schedule = queueMicrotask,
232
+ }: {
233
+ now?: () => number;
234
+ schedule?: (task: () => void) => void;
235
+ } = {}): ToastStore {
236
+ let state: ToastState = [];
237
+ let counter = 0;
238
+ let batchSize = 0;
239
+ const listeners = new Set<() => void>();
240
+
241
+ return {
242
+ getSnapshot: () => state,
243
+ subscribe: (listener) => {
244
+ listeners.add(listener);
245
+ return () => {
246
+ listeners.delete(listener);
247
+ };
248
+ },
249
+ dispatch: (action) => {
250
+ const next = reduceToasts(state, action);
251
+ if (next === state) return;
252
+ state = next;
253
+ for (const listener of listeners) listener();
254
+ },
255
+ createItem: (input) => {
256
+ counter += 1;
257
+ if (batchSize === 0) {
258
+ schedule(() => {
259
+ batchSize = 0;
260
+ });
261
+ }
262
+ const batchIndex = batchSize;
263
+ batchSize += 1;
264
+ return normalizeToastInput(input, { id: `toast-${counter}`, now: now(), batchIndex });
265
+ },
266
+ };
267
+ }
268
+
269
+ type ToastShorthand = (title: string, options?: Partial<Omit<ToastOptions, "title" | "status">>) => ToastHandle;
270
+
271
+ /** The messages `toast.promise` shows while the promise is pending, and once it settles. */
272
+ export type ToastPromiseMessages<T> = {
273
+ loading: string;
274
+ success: string | ((value: T) => string);
275
+ error: string | ((error: unknown) => string);
276
+ };
277
+
278
+ /** `toast` — callable with a title or options, and a namespace for the rest. */
279
+ export type ToastApi = {
280
+ (input: ToastInput): ToastHandle;
281
+ show: (input: ToastInput) => ToastHandle;
282
+ success: ToastShorthand;
283
+ info: ToastShorthand;
284
+ warning: ToastShorthand;
285
+ /** A `destructive` toast. */
286
+ error: ToastShorthand;
287
+ update: (id: string, patch: ToastPatch) => void;
288
+ hide: (id: string) => void;
289
+ hideAll: () => void;
290
+ /** One toast that says "loading" and becomes the success or the error in place. Resolves or rejects with `promise`. */
291
+ promise: <T>(promise: Promise<T>, messages: ToastPromiseMessages<T>) => Promise<T>;
292
+ };
293
+
294
+ /** The `toast` function over a store. The library's `toast` is this over one module-level store. */
295
+ export function createToastApi(store: ToastStore): ToastApi {
296
+ const hide = (id: string): void => store.dispatch({ type: "hide", id });
297
+ const handleOf = (id: string): ToastHandle => ({ id, hide: () => hide(id) });
298
+
299
+ const show = (input: ToastInput): ToastHandle => {
300
+ const item = store.createItem(input);
301
+ store.dispatch({ type: "add", item });
302
+ return handleOf(item.id);
303
+ };
304
+
305
+ const shorthand =
306
+ (status: ToastStatus): ToastShorthand =>
307
+ (title, options) =>
308
+ show({ ...options, title, status });
309
+
310
+ const update = (id: string, patch: ToastPatch): void => store.dispatch({ type: "update", id, patch });
311
+
312
+ function promise<T>(pending: Promise<T>, messages: ToastPromiseMessages<T>): Promise<T> {
313
+ const { id } = show({ title: messages.loading, isLoading: true });
314
+ pending.then(
315
+ (value) => {
316
+ const title = typeof messages.success === "function" ? messages.success(value) : messages.success;
317
+ update(id, { status: "success", title, isLoading: false });
318
+ },
319
+ (error: unknown) => {
320
+ const title = typeof messages.error === "function" ? messages.error(error) : messages.error;
321
+ update(id, { status: "destructive", title, isLoading: false });
322
+ }
323
+ );
324
+ return pending;
325
+ }
326
+
327
+ return Object.assign(show, {
328
+ show,
329
+ success: shorthand("success"),
330
+ info: shorthand("info"),
331
+ warning: shorthand("warning"),
332
+ error: shorthand("destructive"),
333
+ update,
334
+ hide,
335
+ hideAll: () => store.dispatch({ type: "hideAll" }),
336
+ promise,
337
+ });
338
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createToastTimer,
4
+ isToastTimerExpired,
5
+ isToastTimerRunning,
6
+ pauseToastTimer,
7
+ resumeToastTimer,
8
+ startToastTimer,
9
+ toastTimerRemaining,
10
+ } from "./toast.timer";
11
+
12
+ describe("toast timer", () => {
13
+ test("a new timer holds its whole duration and is not running", () => {
14
+ const timer = createToastTimer(4000);
15
+ expect(timer).toEqual({ remaining: 4000, startedAt: null });
16
+ expect(isToastTimerRunning(timer)).toBe(false);
17
+ });
18
+
19
+ test("expires once its duration has run", () => {
20
+ const timer = startToastTimer(createToastTimer(4000), 1000);
21
+ expect(isToastTimerRunning(timer)).toBe(true);
22
+ expect(isToastTimerExpired(timer, 4999)).toBe(false);
23
+ expect(isToastTimerExpired(timer, 5000)).toBe(true);
24
+ expect(toastTimerRemaining(timer, 2500)).toBe(2500);
25
+ });
26
+
27
+ test("pausing keeps the time left, and resuming carries on from it", () => {
28
+ const running = startToastTimer(createToastTimer(4000), 0);
29
+ const paused = pauseToastTimer(running, 1500);
30
+ expect(paused).toEqual({ remaining: 2500, startedAt: null });
31
+
32
+ // However long it sits paused, nothing is spent.
33
+ expect(isToastTimerExpired(paused, 60_000)).toBe(false);
34
+ expect(toastTimerRemaining(paused, 60_000)).toBe(2500);
35
+
36
+ const resumed = resumeToastTimer(paused, 60_000);
37
+ expect(isToastTimerExpired(resumed, 62_499)).toBe(false);
38
+ expect(isToastTimerExpired(resumed, 62_500)).toBe(true);
39
+ });
40
+
41
+ test("pausing twice spends nothing the second time", () => {
42
+ const paused = pauseToastTimer(startToastTimer(createToastTimer(4000), 0), 1000);
43
+ expect(pauseToastTimer(paused, 3000)).toBe(paused);
44
+ });
45
+
46
+ test("resuming a running timer does not restart it", () => {
47
+ const running = startToastTimer(createToastTimer(4000), 0);
48
+ expect(resumeToastTimer(running, 3000)).toBe(running);
49
+ });
50
+
51
+ test("remaining never drops below zero", () => {
52
+ const paused = pauseToastTimer(startToastTimer(createToastTimer(1000), 0), 5000);
53
+ expect(paused.remaining).toBe(0);
54
+ expect(isToastTimerExpired(paused, 5000)).toBe(true);
55
+ });
56
+
57
+ test("a zero duration never expires, running or paused", () => {
58
+ const running = startToastTimer(createToastTimer(0), 0);
59
+ expect(isToastTimerExpired(running, Number.MAX_SAFE_INTEGER)).toBe(false);
60
+ expect(toastTimerRemaining(running, 1_000_000)).toBe(Number.POSITIVE_INFINITY);
61
+ const paused = pauseToastTimer(running, 1_000_000);
62
+ expect(isToastTimerExpired(resumeToastTimer(paused, 2_000_000), Number.MAX_SAFE_INTEGER)).toBe(false);
63
+ });
64
+ });