@elabs-ai/components-ui 4.0.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 (449) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/dist/index.d.ts +4497 -0
  4. package/dist/index.js +15192 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/cn.d.ts +10 -0
  7. package/dist/lib/cn.js +20 -0
  8. package/dist/lib/cn.js.map +1 -0
  9. package/package.json +106 -0
  10. package/src/blocks/sidebar-02/app-sidebar.tsx +160 -0
  11. package/src/blocks/sidebar-02/logo.tsx +18 -0
  12. package/src/blocks/sidebar-02/nav-main.tsx +13 -0
  13. package/src/blocks/sidebar-02/nav-notifications.tsx +7 -0
  14. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +23 -0
  15. package/src/blocks/sidebar-02/team-switcher.tsx +7 -0
  16. package/src/blocks/sidebar-04/app-sidebar.tsx +236 -0
  17. package/src/blocks/sidebar-04/mail-context.tsx +29 -0
  18. package/src/blocks/sidebar-04/nav-user.tsx +6 -0
  19. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +150 -0
  20. package/src/blocks/sidebar-05/app-sidebar.tsx +375 -0
  21. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +18 -0
  22. package/src/blocks/sidebar-05/team-switcher.tsx +6 -0
  23. package/src/blocks-comparison-table.stories.tsx +165 -0
  24. package/src/components/accordion/accordion.stories.tsx +71 -0
  25. package/src/components/accordion/accordion.test.tsx +18 -0
  26. package/src/components/accordion/accordion.tsx +49 -0
  27. package/src/components/accordion/index.ts +1 -0
  28. package/src/components/advanced-group/advanced-group.stories.tsx +143 -0
  29. package/src/components/advanced-group/advanced-group.test.tsx +81 -0
  30. package/src/components/advanced-group/advanced-group.tsx +111 -0
  31. package/src/components/advanced-group/index.ts +2 -0
  32. package/src/components/alert/alert.stories.tsx +156 -0
  33. package/src/components/alert/alert.test.tsx +57 -0
  34. package/src/components/alert/alert.tsx +91 -0
  35. package/src/components/alert/index.ts +8 -0
  36. package/src/components/alert-dialog/alert-dialog.stories.tsx +73 -0
  37. package/src/components/alert-dialog/alert-dialog.tsx +104 -0
  38. package/src/components/alert-dialog/index.ts +12 -0
  39. package/src/components/app-shell/app-shell.stories.tsx +61 -0
  40. package/src/components/app-shell/app-shell.tsx +31 -0
  41. package/src/components/app-shell/index.ts +1 -0
  42. package/src/components/app-sidebar/app-sidebar.stories.tsx +121 -0
  43. package/src/components/app-sidebar/app-sidebar.test.tsx +55 -0
  44. package/src/components/app-sidebar/app-sidebar.tsx +53 -0
  45. package/src/components/app-sidebar/index.ts +1 -0
  46. package/src/components/aspect-ratio/aspect-ratio.stories.tsx +46 -0
  47. package/src/components/aspect-ratio/aspect-ratio.tsx +3 -0
  48. package/src/components/aspect-ratio/index.ts +1 -0
  49. package/src/components/attribution-panel/attribution-panel.stories.tsx +83 -0
  50. package/src/components/attribution-panel/attribution-panel.test.tsx +123 -0
  51. package/src/components/attribution-panel/attribution-panel.tsx +191 -0
  52. package/src/components/attribution-panel/attribution-types.ts +44 -0
  53. package/src/components/attribution-panel/attributions.generated.ts +1422 -0
  54. package/src/components/attribution-panel/index.ts +3 -0
  55. package/src/components/avatar/avatar.stories.tsx +45 -0
  56. package/src/components/avatar/avatar.test.tsx +13 -0
  57. package/src/components/avatar/avatar.tsx +46 -0
  58. package/src/components/avatar/index.ts +1 -0
  59. package/src/components/badge/badge.stories.tsx +44 -0
  60. package/src/components/badge/badge.test.tsx +10 -0
  61. package/src/components/badge/badge.tsx +28 -0
  62. package/src/components/badge/index.ts +1 -0
  63. package/src/components/bento-grid/bento-grid.stories.tsx +165 -0
  64. package/src/components/bento-grid/bento-grid.test.tsx +119 -0
  65. package/src/components/bento-grid/bento-grid.tsx +339 -0
  66. package/src/components/bento-grid/index.ts +9 -0
  67. package/src/components/bounded-number/bounded-number.stories.tsx +118 -0
  68. package/src/components/bounded-number/bounded-number.test.tsx +73 -0
  69. package/src/components/bounded-number/bounded-number.tsx +110 -0
  70. package/src/components/bounded-number/index.ts +1 -0
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +47 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +17 -0
  73. package/src/components/breadcrumb/breadcrumb.tsx +86 -0
  74. package/src/components/breadcrumb/index.ts +9 -0
  75. package/src/components/button/button.stories.tsx +70 -0
  76. package/src/components/button/button.test.tsx +50 -0
  77. package/src/components/button/button.tsx +63 -0
  78. package/src/components/button/index.ts +1 -0
  79. package/src/components/button-group/button-group.stories.tsx +45 -0
  80. package/src/components/button-group/button-group.tsx +70 -0
  81. package/src/components/button-group/index.ts +6 -0
  82. package/src/components/calendar/calendar.stories.tsx +95 -0
  83. package/src/components/calendar/calendar.test.tsx +140 -0
  84. package/src/components/calendar/calendar.tsx +70 -0
  85. package/src/components/calendar/index.ts +1 -0
  86. package/src/components/card/card.stories.tsx +438 -0
  87. package/src/components/card/card.test.tsx +153 -0
  88. package/src/components/card/card.tsx +332 -0
  89. package/src/components/card/index.ts +13 -0
  90. package/src/components/carousel/carousel.stories.tsx +102 -0
  91. package/src/components/carousel/carousel.tsx +225 -0
  92. package/src/components/carousel/index.ts +8 -0
  93. package/src/components/change-review/change-review.stories.tsx +288 -0
  94. package/src/components/change-review/change-review.test.tsx +261 -0
  95. package/src/components/change-review/change-review.tsx +637 -0
  96. package/src/components/change-review/index.ts +23 -0
  97. package/src/components/checkbox/checkbox.stories.tsx +119 -0
  98. package/src/components/checkbox/checkbox.test.tsx +40 -0
  99. package/src/components/checkbox/checkbox.tsx +34 -0
  100. package/src/components/checkbox/index.ts +1 -0
  101. package/src/components/collapsible/collapsible.stories.tsx +62 -0
  102. package/src/components/collapsible/collapsible.tsx +22 -0
  103. package/src/components/collapsible/index.ts +1 -0
  104. package/src/components/collapsible-panel/index.ts +8 -0
  105. package/src/components/collapsible-panel/use-collapsible-panel.test.tsx +93 -0
  106. package/src/components/collapsible-panel/use-collapsible-panel.ts +159 -0
  107. package/src/components/color-picker/color-picker.stories.tsx +93 -0
  108. package/src/components/color-picker/color-picker.test.tsx +79 -0
  109. package/src/components/color-picker/color-picker.tsx +341 -0
  110. package/src/components/color-picker/index.ts +1 -0
  111. package/src/components/combobox/combobox.stories.tsx +154 -0
  112. package/src/components/combobox/combobox.test.tsx +246 -0
  113. package/src/components/combobox/combobox.tsx +210 -0
  114. package/src/components/combobox/index.ts +1 -0
  115. package/src/components/command/command.stories.tsx +371 -0
  116. package/src/components/command/command.test.tsx +280 -0
  117. package/src/components/command/command.tsx +301 -0
  118. package/src/components/command/index.ts +13 -0
  119. package/src/components/confirm-dialog/confirm-dialog.stories.tsx +183 -0
  120. package/src/components/confirm-dialog/confirm-dialog.test.tsx +147 -0
  121. package/src/components/confirm-dialog/confirm-dialog.tsx +146 -0
  122. package/src/components/confirm-dialog/index.ts +2 -0
  123. package/src/components/context-menu/context-menu.stories.tsx +70 -0
  124. package/src/components/context-menu/context-menu.tsx +172 -0
  125. package/src/components/context-menu/index.ts +16 -0
  126. package/src/components/copyable-value/copyable-value.stories.tsx +80 -0
  127. package/src/components/copyable-value/copyable-value.test.tsx +128 -0
  128. package/src/components/copyable-value/copyable-value.tsx +101 -0
  129. package/src/components/copyable-value/index.ts +1 -0
  130. package/src/components/date-picker/date-picker.stories.tsx +42 -0
  131. package/src/components/date-picker/date-picker.tsx +49 -0
  132. package/src/components/date-picker/index.ts +1 -0
  133. package/src/components/date-range-picker/date-range-picker.stories.tsx +151 -0
  134. package/src/components/date-range-picker/date-range-picker.test.tsx +62 -0
  135. package/src/components/date-range-picker/date-range-picker.tsx +126 -0
  136. package/src/components/date-range-picker/index.ts +6 -0
  137. package/src/components/descriptions/descriptions.stories.tsx +43 -0
  138. package/src/components/descriptions/descriptions.test.tsx +41 -0
  139. package/src/components/descriptions/descriptions.tsx +95 -0
  140. package/src/components/descriptions/index.ts +6 -0
  141. package/src/components/dialog/dialog.stories.tsx +728 -0
  142. package/src/components/dialog/dialog.test.tsx +285 -0
  143. package/src/components/dialog/dialog.tsx +340 -0
  144. package/src/components/dialog/index.ts +18 -0
  145. package/src/components/dialog/use-dialog-dismiss-guard.test.tsx +80 -0
  146. package/src/components/dialog/use-dialog-dismiss-guard.ts +109 -0
  147. package/src/components/drawer/drawer.stories.tsx +76 -0
  148. package/src/components/drawer/drawer.tsx +85 -0
  149. package/src/components/drawer/index.ts +11 -0
  150. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +176 -0
  151. package/src/components/dropdown-menu/dropdown-menu.test.tsx +109 -0
  152. package/src/components/dropdown-menu/dropdown-menu.tsx +179 -0
  153. package/src/components/dropdown-menu/index.ts +17 -0
  154. package/src/components/empty-state/empty-state.stories.tsx +54 -0
  155. package/src/components/empty-state/empty-state.test.tsx +11 -0
  156. package/src/components/empty-state/empty-state.tsx +30 -0
  157. package/src/components/empty-state/index.ts +1 -0
  158. package/src/components/error-state/error-state.stories.tsx +51 -0
  159. package/src/components/error-state/error-state.tsx +27 -0
  160. package/src/components/error-state/index.ts +1 -0
  161. package/src/components/expand-dialog/expand-dialog.stories.tsx +246 -0
  162. package/src/components/expand-dialog/expand-dialog.test.tsx +185 -0
  163. package/src/components/expand-dialog/expand-dialog.tsx +387 -0
  164. package/src/components/expand-dialog/index.ts +11 -0
  165. package/src/components/field-row/field-row.stories.tsx +114 -0
  166. package/src/components/field-row/field-row.test.tsx +188 -0
  167. package/src/components/field-row/field-row.tsx +115 -0
  168. package/src/components/field-row/index.ts +1 -0
  169. package/src/components/file-upload/file-upload.stories.tsx +183 -0
  170. package/src/components/file-upload/file-upload.test.tsx +169 -0
  171. package/src/components/file-upload/file-upload.tsx +459 -0
  172. package/src/components/file-upload/index.ts +13 -0
  173. package/src/components/form/form.stories.tsx +71 -0
  174. package/src/components/form/form.tsx +141 -0
  175. package/src/components/form/index.ts +10 -0
  176. package/src/components/hover-card/hover-card.stories.tsx +57 -0
  177. package/src/components/hover-card/hover-card.tsx +25 -0
  178. package/src/components/hover-card/index.ts +1 -0
  179. package/src/components/icon-button/icon-button.stories.tsx +108 -0
  180. package/src/components/icon-button/icon-button.test.tsx +93 -0
  181. package/src/components/icon-button/icon-button.tsx +139 -0
  182. package/src/components/icon-button/index.ts +1 -0
  183. package/src/components/input/index.ts +1 -0
  184. package/src/components/input/input.stories.tsx +44 -0
  185. package/src/components/input/input.test.tsx +19 -0
  186. package/src/components/input/input.tsx +26 -0
  187. package/src/components/input-group/index.ts +11 -0
  188. package/src/components/input-group/input-group.stories.tsx +87 -0
  189. package/src/components/input-group/input-group.test.tsx +48 -0
  190. package/src/components/input-group/input-group.tsx +170 -0
  191. package/src/components/input-otp/index.ts +1 -0
  192. package/src/components/input-otp/input-otp.stories.tsx +66 -0
  193. package/src/components/input-otp/input-otp.tsx +68 -0
  194. package/src/components/kbd/index.ts +1 -0
  195. package/src/components/kbd/kbd.stories.tsx +45 -0
  196. package/src/components/kbd/kbd.tsx +20 -0
  197. package/src/components/key-value-editor/index.ts +1 -0
  198. package/src/components/key-value-editor/key-value-editor.stories.tsx +130 -0
  199. package/src/components/key-value-editor/key-value-editor.test.tsx +231 -0
  200. package/src/components/key-value-editor/key-value-editor.tsx +263 -0
  201. package/src/components/label/index.ts +1 -0
  202. package/src/components/label/label.stories.tsx +35 -0
  203. package/src/components/label/label.tsx +19 -0
  204. package/src/components/link-preview/index.ts +2 -0
  205. package/src/components/link-preview/link-preview.stories.tsx +178 -0
  206. package/src/components/link-preview/link-preview.test.tsx +123 -0
  207. package/src/components/link-preview/link-preview.tsx +194 -0
  208. package/src/components/list-editor/index.ts +1 -0
  209. package/src/components/list-editor/list-editor.stories.tsx +119 -0
  210. package/src/components/list-editor/list-editor.test.tsx +96 -0
  211. package/src/components/list-editor/list-editor.tsx +192 -0
  212. package/src/components/loading-state/index.ts +1 -0
  213. package/src/components/loading-state/loading-state.stories.tsx +38 -0
  214. package/src/components/loading-state/loading-state.tsx +26 -0
  215. package/src/components/locale-provider/index.ts +8 -0
  216. package/src/components/locale-provider/locale-provider.stories.tsx +83 -0
  217. package/src/components/locale-provider/locale-provider.test.tsx +238 -0
  218. package/src/components/locale-provider/locale-provider.tsx +187 -0
  219. package/src/components/locale-provider/messages.ts +300 -0
  220. package/src/components/match-highlight/index.ts +7 -0
  221. package/src/components/match-highlight/match-highlight.stories.tsx +146 -0
  222. package/src/components/match-highlight/match-highlight.test.tsx +202 -0
  223. package/src/components/match-highlight/match-highlight.tsx +145 -0
  224. package/src/components/mention-input/index.ts +28 -0
  225. package/src/components/mention-input/mention-input-mirror.stories.tsx +171 -0
  226. package/src/components/mention-input/mention-input.stories.tsx +463 -0
  227. package/src/components/mention-input/mention-input.test.tsx +617 -0
  228. package/src/components/mention-input/mention-input.tsx +1102 -0
  229. package/src/components/mention-input/mention-value.test.ts +253 -0
  230. package/src/components/mention-input/mention-value.ts +193 -0
  231. package/src/components/menubar/index.ts +17 -0
  232. package/src/components/menubar/menubar.stories.tsx +67 -0
  233. package/src/components/menubar/menubar.tsx +198 -0
  234. package/src/components/metric-card/index.ts +1 -0
  235. package/src/components/metric-card/metric-card.stories.tsx +224 -0
  236. package/src/components/metric-card/metric-card.test.tsx +198 -0
  237. package/src/components/metric-card/metric-card.tsx +229 -0
  238. package/src/components/model-picker/index.ts +11 -0
  239. package/src/components/model-picker/model-picker-state.ts +85 -0
  240. package/src/components/model-picker/model-picker.stories.tsx +128 -0
  241. package/src/components/model-picker/model-picker.test.tsx +241 -0
  242. package/src/components/model-picker/model-picker.tsx +270 -0
  243. package/src/components/nav-main/index.ts +1 -0
  244. package/src/components/nav-main/nav-main.tsx +147 -0
  245. package/src/components/nav-notifications/index.ts +5 -0
  246. package/src/components/nav-notifications/nav-notifications.test.tsx +15 -0
  247. package/src/components/nav-notifications/nav-notifications.tsx +70 -0
  248. package/src/components/nav-user/index.ts +1 -0
  249. package/src/components/nav-user/nav-user.test.tsx +29 -0
  250. package/src/components/nav-user/nav-user.tsx +114 -0
  251. package/src/components/navigation-menu/index.ts +10 -0
  252. package/src/components/navigation-menu/navigation-menu.stories.tsx +92 -0
  253. package/src/components/navigation-menu/navigation-menu.tsx +99 -0
  254. package/src/components/number-input/index.ts +1 -0
  255. package/src/components/number-input/number-input.stories.tsx +120 -0
  256. package/src/components/number-input/number-input.test.tsx +96 -0
  257. package/src/components/number-input/number-input.tsx +219 -0
  258. package/src/components/page-shell/index.ts +1 -0
  259. package/src/components/page-shell/page-shell.stories.tsx +105 -0
  260. package/src/components/page-shell/page-shell.test.tsx +57 -0
  261. package/src/components/page-shell/page-shell.tsx +62 -0
  262. package/src/components/pagination/index.ts +9 -0
  263. package/src/components/pagination/pagination.stories.tsx +57 -0
  264. package/src/components/pagination/pagination.tsx +77 -0
  265. package/src/components/popover/index.ts +1 -0
  266. package/src/components/popover/popover.stories.tsx +28 -0
  267. package/src/components/popover/popover.test.tsx +16 -0
  268. package/src/components/popover/popover.tsx +29 -0
  269. package/src/components/progress/index.ts +1 -0
  270. package/src/components/progress/progress.stories.tsx +80 -0
  271. package/src/components/progress/progress.test.tsx +35 -0
  272. package/src/components/progress/progress.tsx +50 -0
  273. package/src/components/radio-group/index.ts +1 -0
  274. package/src/components/radio-group/radio-group.stories.tsx +75 -0
  275. package/src/components/radio-group/radio-group.test.tsx +16 -0
  276. package/src/components/radio-group/radio-group.tsx +33 -0
  277. package/src/components/rating/index.ts +1 -0
  278. package/src/components/rating/rating.stories.tsx +111 -0
  279. package/src/components/rating/rating.test.tsx +41 -0
  280. package/src/components/rating/rating.tsx +226 -0
  281. package/src/components/resizable/index.ts +1 -0
  282. package/src/components/resizable/resizable.stories.tsx +25 -0
  283. package/src/components/resizable/resizable.tsx +40 -0
  284. package/src/components/reveal/index.ts +7 -0
  285. package/src/components/reveal/reveal.stories.tsx +90 -0
  286. package/src/components/reveal/reveal.test.tsx +43 -0
  287. package/src/components/reveal/reveal.tsx +95 -0
  288. package/src/components/revision-timeline/index.ts +10 -0
  289. package/src/components/revision-timeline/revision-timeline.stories.tsx +785 -0
  290. package/src/components/revision-timeline/revision-timeline.test.tsx +509 -0
  291. package/src/components/revision-timeline/revision-timeline.tsx +1107 -0
  292. package/src/components/scroll-area/index.ts +1 -0
  293. package/src/components/scroll-area/scroll-area.stories.tsx +45 -0
  294. package/src/components/scroll-area/scroll-area.tsx +43 -0
  295. package/src/components/section-header/index.ts +1 -0
  296. package/src/components/section-header/section-header.stories.tsx +20 -0
  297. package/src/components/section-header/section-header.tsx +36 -0
  298. package/src/components/segmented-field/index.ts +5 -0
  299. package/src/components/segmented-field/segmented-field.stories.tsx +121 -0
  300. package/src/components/segmented-field/segmented-field.test.tsx +163 -0
  301. package/src/components/segmented-field/segmented-field.tsx +235 -0
  302. package/src/components/select/index.ts +10 -0
  303. package/src/components/select/select.stories.tsx +106 -0
  304. package/src/components/select/select.test.tsx +98 -0
  305. package/src/components/select/select.tsx +230 -0
  306. package/src/components/separator/index.ts +2 -0
  307. package/src/components/separator/separator.stories.tsx +87 -0
  308. package/src/components/separator/separator.tsx +51 -0
  309. package/src/components/sheet/index.ts +12 -0
  310. package/src/components/sheet/sheet.stories.tsx +65 -0
  311. package/src/components/sheet/sheet.tsx +109 -0
  312. package/src/components/sidebar/index.ts +27 -0
  313. package/src/components/sidebar/sidebar.stories.tsx +69 -0
  314. package/src/components/sidebar/sidebar.test.tsx +43 -0
  315. package/src/components/sidebar/sidebar.tsx +688 -0
  316. package/src/components/skeleton/index.ts +1 -0
  317. package/src/components/skeleton/skeleton.stories.tsx +28 -0
  318. package/src/components/skeleton/skeleton.tsx +15 -0
  319. package/src/components/slider/index.ts +1 -0
  320. package/src/components/slider/slider.stories.tsx +211 -0
  321. package/src/components/slider/slider.test.tsx +168 -0
  322. package/src/components/slider/slider.tsx +136 -0
  323. package/src/components/slider-number/index.ts +1 -0
  324. package/src/components/slider-number/slider-number.stories.tsx +123 -0
  325. package/src/components/slider-number/slider-number.test.tsx +165 -0
  326. package/src/components/slider-number/slider-number.tsx +191 -0
  327. package/src/components/sonner/index.ts +1 -0
  328. package/src/components/sonner/sonner.stories.tsx +124 -0
  329. package/src/components/sonner/sonner.test.tsx +76 -0
  330. package/src/components/sonner/sonner.tsx +101 -0
  331. package/src/components/spinner/index.ts +1 -0
  332. package/src/components/spinner/spinner.stories.tsx +27 -0
  333. package/src/components/spinner/spinner.tsx +19 -0
  334. package/src/components/split-panel/index.ts +1 -0
  335. package/src/components/split-panel/split-panel.stories.tsx +60 -0
  336. package/src/components/split-panel/split-panel.test.tsx +38 -0
  337. package/src/components/split-panel/split-panel.tsx +95 -0
  338. package/src/components/state-panel/index.ts +1 -0
  339. package/src/components/state-panel/state-panel.stories.tsx +80 -0
  340. package/src/components/state-panel/state-panel.test.tsx +64 -0
  341. package/src/components/state-panel/state-panel.tsx +167 -0
  342. package/src/components/status-badge/index.ts +18 -0
  343. package/src/components/status-badge/status-badge.stories.tsx +132 -0
  344. package/src/components/status-badge/status-badge.test.tsx +189 -0
  345. package/src/components/status-badge/status-badge.tsx +330 -0
  346. package/src/components/switch/index.ts +1 -0
  347. package/src/components/switch/switch.stories.tsx +58 -0
  348. package/src/components/switch/switch.test.tsx +12 -0
  349. package/src/components/switch/switch.tsx +29 -0
  350. package/src/components/table/index.ts +10 -0
  351. package/src/components/table/table.stories.tsx +53 -0
  352. package/src/components/table/table.test.tsx +17 -0
  353. package/src/components/table/table.tsx +106 -0
  354. package/src/components/tabs/index.ts +1 -0
  355. package/src/components/tabs/tabs.stories.tsx +267 -0
  356. package/src/components/tabs/tabs.test.tsx +94 -0
  357. package/src/components/tabs/tabs.tsx +170 -0
  358. package/src/components/tag-input/index.ts +1 -0
  359. package/src/components/tag-input/tag-input.stories.tsx +120 -0
  360. package/src/components/tag-input/tag-input.test.tsx +97 -0
  361. package/src/components/tag-input/tag-input.tsx +253 -0
  362. package/src/components/team-switcher/index.ts +1 -0
  363. package/src/components/team-switcher/team-switcher.test.tsx +39 -0
  364. package/src/components/team-switcher/team-switcher.tsx +100 -0
  365. package/src/components/textarea/index.ts +1 -0
  366. package/src/components/textarea/textarea.stories.tsx +42 -0
  367. package/src/components/textarea/textarea.tsx +24 -0
  368. package/src/components/theme-switcher/index.ts +2 -0
  369. package/src/components/theme-switcher/theme-switcher.stories.tsx +146 -0
  370. package/src/components/theme-switcher/theme-switcher.test.tsx +253 -0
  371. package/src/components/theme-switcher/theme-switcher.tsx +280 -0
  372. package/src/components/theme-switcher/use-theme-transition.ts +62 -0
  373. package/src/components/timeline/index.ts +13 -0
  374. package/src/components/timeline/timeline.stories.tsx +99 -0
  375. package/src/components/timeline/timeline.test.tsx +196 -0
  376. package/src/components/timeline/timeline.tsx +188 -0
  377. package/src/components/toggle/index.ts +1 -0
  378. package/src/components/toggle/toggle.stories.tsx +116 -0
  379. package/src/components/toggle/toggle.test.tsx +119 -0
  380. package/src/components/toggle/toggle.tsx +64 -0
  381. package/src/components/toggle-group/index.ts +1 -0
  382. package/src/components/toggle-group/toggle-group.stories.tsx +110 -0
  383. package/src/components/toggle-group/toggle-group.tsx +59 -0
  384. package/src/components/toolbar/index.ts +14 -0
  385. package/src/components/toolbar/toolbar.stories.tsx +126 -0
  386. package/src/components/toolbar/toolbar.test.tsx +73 -0
  387. package/src/components/toolbar/toolbar.tsx +177 -0
  388. package/src/components/tooltip/index.ts +1 -0
  389. package/src/components/tooltip/tooltip.stories.tsx +67 -0
  390. package/src/components/tooltip/tooltip.tsx +27 -0
  391. package/src/components/top-nav/index.ts +1 -0
  392. package/src/components/top-nav/top-nav.stories.tsx +18 -0
  393. package/src/components/top-nav/top-nav.tsx +34 -0
  394. package/src/components/transfer/index.ts +2 -0
  395. package/src/components/transfer/transfer.stories.tsx +162 -0
  396. package/src/components/transfer/transfer.test.tsx +177 -0
  397. package/src/components/transfer/transfer.tsx +481 -0
  398. package/src/components/tree/index.ts +6 -0
  399. package/src/components/tree/tree.stories.tsx +618 -0
  400. package/src/components/tree/tree.test.tsx +699 -0
  401. package/src/components/tree/tree.tsx +1149 -0
  402. package/src/components/tree/use-tree-keyboard.ts +314 -0
  403. package/src/components/tree-select/index.ts +1 -0
  404. package/src/components/tree-select/tree-select.stories.tsx +127 -0
  405. package/src/components/tree-select/tree-select.test.tsx +40 -0
  406. package/src/components/tree-select/tree-select.tsx +137 -0
  407. package/src/components/typography/index.ts +31 -0
  408. package/src/components/typography/prose.test.tsx +48 -0
  409. package/src/components/typography/prose.tsx +194 -0
  410. package/src/components/typography/typography.stories.tsx +244 -0
  411. package/src/components/typography/typography.test.tsx +140 -0
  412. package/src/components/typography/typography.tsx +126 -0
  413. package/src/components/view-toolbar/index.ts +10 -0
  414. package/src/components/view-toolbar/view-toolbar.stories.tsx +342 -0
  415. package/src/components/view-toolbar/view-toolbar.test.tsx +262 -0
  416. package/src/components/view-toolbar/view-toolbar.tsx +320 -0
  417. package/src/components/virtual-select/index.ts +2 -0
  418. package/src/components/virtual-select/use-virtual-listbox.ts +132 -0
  419. package/src/components/virtual-select/virtual-select.stories.tsx +203 -0
  420. package/src/components/virtual-select/virtual-select.test.tsx +106 -0
  421. package/src/components/virtual-select/virtual-select.tsx +361 -0
  422. package/src/components/wizard/index.ts +12 -0
  423. package/src/components/wizard/wizard.stories.tsx +148 -0
  424. package/src/components/wizard/wizard.test.tsx +67 -0
  425. package/src/components/wizard/wizard.tsx +354 -0
  426. package/src/decoration.stories.tsx +236 -0
  427. package/src/index.ts +174 -0
  428. package/src/lib/cn.test.ts +39 -0
  429. package/src/lib/cn.ts +31 -0
  430. package/src/lib/compact-number.ts +61 -0
  431. package/src/lib/document-address.test.ts +75 -0
  432. package/src/lib/document-address.ts +265 -0
  433. package/src/lib/download.ts +93 -0
  434. package/src/lib/file-icon.test.ts +45 -0
  435. package/src/lib/file-icon.ts +58 -0
  436. package/src/lib/file-kind.test.ts +98 -0
  437. package/src/lib/file-kind.ts +303 -0
  438. package/src/lib/file-source.test.ts +217 -0
  439. package/src/lib/file-source.ts +269 -0
  440. package/src/lib/merge-refs.test.ts +37 -0
  441. package/src/lib/merge-refs.ts +17 -0
  442. package/src/lib/streamdown-translations.ts +99 -0
  443. package/src/lib/use-copy-to-clipboard.ts +75 -0
  444. package/src/lib/use-mobile.ts +16 -0
  445. package/src/motion.stories.tsx +266 -0
  446. package/src/taste-profile.test.tsx +252 -0
  447. package/src/templates-object-detail-hub.stories.tsx +466 -0
  448. package/src/templates-screen-states.stories.tsx +306 -0
  449. package/src/templates-settings.stories.tsx +245 -0
@@ -0,0 +1,339 @@
1
+ "use client";
2
+
3
+ /**
4
+ * BentoGrid + BentoGridItem — a hover-elevation bento layout.
5
+ *
6
+ * A responsive dense CSS-grid container. **The default separation gesture is
7
+ * ELEVATION, not colour:** the grid rests flat (`shadow-none` — border only) and
8
+ * each tile rises ~4px into `shadow-xl` with a brand-tinted edge on hover, so the
9
+ * tile under the pointer clearly separates from the sheet.
10
+ *
11
+ * The cursor-following **spotlight is OPT-IN** (`spotlight`, default `false`) —
12
+ * set it on the `BentoGrid` to enable it for every tile, or per tile to override
13
+ * the grid. Its colour is a DERIVED tint of the `--primary` token
14
+ * (`color-mix(in oklch, var(--primary) 12%, transparent)`), so it stays
15
+ * theme-correct in every theme with no raw colors and no extra token. Under OS
16
+ * reduced-motion the spotlight is suppressed entirely (`motion-reduce:hidden`);
17
+ * tiles still respond to hover/focus with their normal card states.
18
+ *
19
+ * Key design choices:
20
+ * - `BentoGridItem` composes `Card` (inherits card surface/border/radius) and
21
+ * then overrides the resting `shadow-sm` down to `shadow-none`.
22
+ * - Under reduced motion the travel is neutralized and the transition dropped —
23
+ * the shadow and the edge still state the hover ("reduced != none").
24
+ * - Spans are inline `grid-column/grid-row` and collapse to one cell on the
25
+ * 1-column mobile grid (the browser caps a span at the available tracks).
26
+ * - The tile is presentational; for a clickable tile, place an interactive inner
27
+ * element (e.g. a stretched `<a className="absolute inset-0">`) and set
28
+ * `interactive` so the tile shows a `focus-within` ring.
29
+ * - The spotlight overlay is `pointer-events-none` + `aria-hidden` (decorative).
30
+ * - Motion is gated via `duration-base` / `ease-standard` (token-backed).
31
+ */
32
+
33
+ import {
34
+ createContext,
35
+ forwardRef,
36
+ use,
37
+ useRef,
38
+ useCallback,
39
+ type CSSProperties,
40
+ type HTMLAttributes,
41
+ } from "react";
42
+ import { cva, type VariantProps } from "class-variance-authority";
43
+ import { cn } from "../../lib/cn";
44
+ import { cardVariants } from "../card/card";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Context — the grid hands its spotlight default down to every tile so a whole
48
+ // bento can opt in with ONE prop instead of repeating it on every child. A tile's
49
+ // own `spotlight` prop always wins.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const BentoGridContext = createContext<{ spotlight: boolean }>({ spotlight: false });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // BentoGrid
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export interface BentoGridProps extends HTMLAttributes<HTMLDivElement> {
59
+ /** Additional className merged via cn(). */
60
+ className?: string;
61
+ /**
62
+ * Enable the cursor-following spotlight on every tile in this grid. Individual
63
+ * tiles can still opt out (or in) with their own `spotlight` prop. Disabled
64
+ * entirely under OS `prefers-reduced-motion` regardless of this prop.
65
+ * @default false
66
+ */
67
+ spotlight?: boolean;
68
+ }
69
+
70
+ /**
71
+ * Responsive CSS-grid container.
72
+ * - 1 column on mobile, 2 on sm, 4 on lg.
73
+ * - `grid-auto-flow: dense` fills in gaps left by spanning tiles.
74
+ * - `auto-rows-[14rem]` gives a baseline row height (14rem ≈ 224px); tiles can
75
+ * span multiple rows via the `size` / `span.row` API.
76
+ * - Carries the grid-wide `spotlight` default for its tiles (opt-in).
77
+ */
78
+ export const BentoGrid = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
79
+ { className, spotlight = false, children, ...props },
80
+ ref,
81
+ ) {
82
+ return (
83
+ <BentoGridContext value={{ spotlight }}>
84
+ <div
85
+ ref={ref}
86
+ data-slot="bento-grid"
87
+ className={cn(
88
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
89
+ "[grid-auto-flow:dense]",
90
+ "auto-rows-[14rem]",
91
+ "gap-4",
92
+ className,
93
+ )}
94
+ {...props}
95
+ >
96
+ {children}
97
+ </div>
98
+ </BentoGridContext>
99
+ );
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // BentoGridItem — size / span presets
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Size preset → [colSpan, rowSpan].
108
+ *
109
+ * | size | cols | rows | purpose |
110
+ * |-------|------|------|------------------------------------------------------------|
111
+ * | sm | 1 | 1 | Supporting: compact metric or icon tile |
112
+ * | md | 2 | 1 | Supporting: wider but single-row |
113
+ * | lg | 2 | 2 | Featured: 2×2 block |
114
+ * | hero | 2 | 2 | Hero: 2×2 + gradient emphasis wash |
115
+ *
116
+ * On a 1-column layout (mobile) ALL col spans clamp to 1 automatically via
117
+ * CSS `min()` in `gridColumn`. Row spans are always honoured.
118
+ */
119
+ export type BentoGridSize = "sm" | "md" | "lg" | "hero";
120
+
121
+ const SIZE_SPANS: Record<BentoGridSize, { col: number; row: number }> = {
122
+ sm: { col: 1, row: 1 },
123
+ md: { col: 2, row: 1 },
124
+ lg: { col: 2, row: 2 },
125
+ hero: { col: 2, row: 2 },
126
+ };
127
+
128
+ // cva for the item's structural/visual axes (NOT size — span is applied via
129
+ // inline style so it collapses correctly on mobile without media-query gymnastics).
130
+ export const bentoGridItemVariants = cva(
131
+ [
132
+ // Base: compose Card surface (bg-card border rounded-lg shadow-sm).
133
+ cardVariants({ interactive: false }),
134
+ // Overflow hidden so content + spotlight overlay stay within rounded corners.
135
+ "relative overflow-hidden",
136
+ // Flex column layout for slot content.
137
+ "flex flex-col",
138
+ // Ensure long unbroken strings can't overflow the tile.
139
+ "min-w-0 break-words",
140
+ // ELEVATION IS THE HOVER GESTURE. The grid rests FLAT — `shadow-none` beats
141
+ // the `shadow-sm` inherited from `cardVariants` (tailwind-merge, later wins)
142
+ // so a bento sheet reads as one plane, not a field of lifted chips. On hover
143
+ // the single tile under the pointer rises to `shadow-lg`, a stacked rung of
144
+ // the ONE elevation ramp (never a hand-rolled box-shadow). The rung is
145
+ // `hover:`-prefixed, so pairing it with the card border is the sanctioned
146
+ // "bordered card, hover lift" shape, not the flagged double edge.
147
+ "shadow-none hover:shadow-xl",
148
+ // Shadow alone is a LIGHT-theme signal: the ramp's ink is black, so on a dark
149
+ // ground even `shadow-xl` is faint (measured on `theme:dark` — the hovered tile
150
+ // was hard to pick out). The hover EDGE carries the lift there. It is
151
+ // `ring/40`, the same tint `Card interactive` uses — NOT `border-strong`, which
152
+ // is a near-charcoal 0.65 L against a 0.88 L resting border and reads as a hard
153
+ // outline snapping on, not as a lift. At 40% the brand hue stays a soft warm
154
+ // edge on white and a clearly brighter one on charcoal.
155
+ "hover:border-ring/40",
156
+ // A lift you can SEE: ~4px of travel. Below ~16px a slide is a flicker, but a
157
+ // hover-lift is the exception the motion guidelines size at ≥4px — and the
158
+ // travel, not the duration, is what makes the elevation legible.
159
+ "hover:-translate-y-1 motion-reduce:hover:translate-y-0",
160
+ // `ease-entrance` (easeOutQuint) — a near-zero arrival velocity is the #1
161
+ // smoothness lever; `ease-standard` snaps at the end. Duration stays at the
162
+ // 260ms `base` rung: lengthening it does NOT fix abruptness. Under OS
163
+ // reduced-motion the travel is neutralized and the transition is dropped, so
164
+ // the shadow + edge still state the hover without any movement.
165
+ "transition-[translate,box-shadow,border-color] duration-base ease-entrance motion-reduce:transition-none",
166
+ ].join(" "),
167
+ {
168
+ variants: {
169
+ /** Hero tile gets a subtle gradient wash to distinguish it from supporting tiles. */
170
+ hero: {
171
+ true: "bg-gradient-to-br from-primary/8 to-card",
172
+ false: "",
173
+ },
174
+ /**
175
+ * Interactive tiles (those holding a clickable inner element, e.g. a
176
+ * stretched link) get a pointer cursor and a `focus-within` ring so keyboard
177
+ * focus on the inner control is visible on the whole tile. The hover lift
178
+ * (elevation + edge) is on the base — every tile lifts, clickable or not.
179
+ */
180
+ interactive: {
181
+ true: "cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-ring",
182
+ false: "",
183
+ },
184
+ },
185
+ defaultVariants: { hero: false, interactive: false },
186
+ },
187
+ );
188
+
189
+ export type BentoGridItemVariants = VariantProps<typeof bentoGridItemVariants>;
190
+
191
+ export interface BentoGridItemProps extends HTMLAttributes<HTMLDivElement>, BentoGridItemVariants {
192
+ /**
193
+ * Size preset. Resolves to a col + row span. Overridden by explicit `span`.
194
+ * @default "sm"
195
+ */
196
+ size?: BentoGridSize;
197
+ /**
198
+ * Explicit grid span. Wins over `size`. Spans clamp gracefully on narrow
199
+ * layouts — a `col: 4` on a 2-col grid becomes `span min(4, 2)`.
200
+ */
201
+ span?: { col?: number; row?: number };
202
+ /**
203
+ * Enable the cursor-following spotlight gradient on this tile. Omit to inherit
204
+ * the parent `BentoGrid`'s `spotlight` (itself `false` by default) — so the
205
+ * effect is opt-in, per grid or per tile. Disabled entirely under OS
206
+ * `prefers-reduced-motion` regardless of this prop.
207
+ * @default false (inherited from BentoGrid)
208
+ */
209
+ spotlight?: boolean;
210
+ }
211
+
212
+ /**
213
+ * A single bento tile. Composes `Card`, rests flat, lifts to `shadow-lg` on
214
+ * hover, and adds the OPT-IN spotlight overlay.
215
+ *
216
+ * Spotlight:
217
+ * - Sets `--bento-x` / `--bento-y` CSS custom properties on `onMouseMove`
218
+ * via a direct style set (no layout reads in render — uses
219
+ * `getBoundingClientRect` in the event handler only, never during paint).
220
+ * - An absolutely-positioned `pointer-events-none aria-hidden` overlay paints
221
+ * `radial-gradient(… at var(--bento-x) var(--bento-y), color-mix(--primary), transparent)`.
222
+ * - Revealed on hover via opacity transition gated by `duration-base ease-standard`.
223
+ * - Suppressed with `motion-reduce:hidden` — cursor-following IS motion.
224
+ */
225
+ export const BentoGridItem = forwardRef<HTMLDivElement, BentoGridItemProps>(function BentoGridItem(
226
+ { size = "sm", span, spotlight, hero, interactive, className, style, children, ...props },
227
+ ref,
228
+ ) {
229
+ const tileRef = useRef<HTMLDivElement>(null);
230
+ const overlayRef = useRef<HTMLDivElement>(null);
231
+
232
+ // Own prop wins; otherwise inherit the grid's opt-in (default false).
233
+ const spotlightOn = spotlight ?? use(BentoGridContext).spotlight;
234
+
235
+ // Resolve spans: explicit span wins over size preset.
236
+ const resolvedCol = span?.col ?? SIZE_SPANS[size].col;
237
+ const resolvedRow = span?.row ?? SIZE_SPANS[size].row;
238
+
239
+ const isInteractive = interactive ?? false;
240
+
241
+ // isHero: explicit prop or size === "hero".
242
+ const isHero = hero ?? size === "hero";
243
+
244
+ // rAF ref so we only schedule one frame at a time.
245
+ const rafRef = useRef<number>(0);
246
+
247
+ const handleMouseMove = useCallback(
248
+ (e: React.MouseEvent<HTMLDivElement>) => {
249
+ if (!spotlightOn || !tileRef.current || !overlayRef.current) return;
250
+ // Cancel pending rAF to debounce rapid moves.
251
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
252
+ rafRef.current = requestAnimationFrame(() => {
253
+ if (!tileRef.current || !overlayRef.current) return;
254
+ const rect = tileRef.current.getBoundingClientRect();
255
+ const x = e.clientX - rect.left;
256
+ const y = e.clientY - rect.top;
257
+ // Direct style mutation — no React render cycle.
258
+ tileRef.current.style.setProperty("--bento-x", `${x}px`);
259
+ tileRef.current.style.setProperty("--bento-y", `${y}px`);
260
+ overlayRef.current.style.opacity = "1";
261
+ });
262
+ },
263
+ [spotlightOn],
264
+ );
265
+
266
+ const handleMouseLeave = useCallback(() => {
267
+ if (!overlayRef.current) return;
268
+ overlayRef.current.style.opacity = "0";
269
+ }, []);
270
+
271
+ // Grid span via inline style. On mobile (grid-cols-1) a col-span of any value
272
+ // is effectively clamped to 1 by the grid itself — a 4-col tile in a 1-col
273
+ // grid still renders in one cell. Row spans are always honoured as-is.
274
+ // We use `gridColumn: "span N / span N"` which the browser safely caps at the
275
+ // available track count; no CSS min() with custom-property arithmetic needed.
276
+ const gridStyle: CSSProperties = {
277
+ gridColumn: resolvedCol > 1 ? `span ${resolvedCol} / span ${resolvedCol}` : undefined,
278
+ gridRow: resolvedRow > 1 ? `span ${resolvedRow} / span ${resolvedRow}` : undefined,
279
+ ...style,
280
+ };
281
+
282
+ // Merge the forwarded ref and the internal tileRef into one callback ref.
283
+ const mergedRef = useCallback(
284
+ (node: HTMLDivElement | null) => {
285
+ (tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
286
+ if (typeof ref === "function") {
287
+ ref(node);
288
+ } else if (ref) {
289
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
290
+ }
291
+ },
292
+ [ref],
293
+ );
294
+
295
+ return (
296
+ <div
297
+ ref={mergedRef as React.Ref<HTMLDivElement>}
298
+ className={cn(
299
+ bentoGridItemVariants({
300
+ hero: isHero,
301
+ interactive: isInteractive,
302
+ }),
303
+ className,
304
+ )}
305
+ style={gridStyle}
306
+ data-slot="bento-grid-item"
307
+ onMouseMove={handleMouseMove}
308
+ onMouseLeave={handleMouseLeave}
309
+ {...props}
310
+ >
311
+ {/* Spotlight overlay — OPT-IN, decorative, pointer-events-none, aria-hidden.
312
+ Hidden in full under reduced-motion (the cursor-following IS the motion).
313
+ Color is a color-mix tint of --primary (token-derived, no raw color). */}
314
+ {spotlightOn && (
315
+ <div
316
+ ref={overlayRef}
317
+ aria-hidden="true"
318
+ data-testid="bento-spotlight"
319
+ className={cn(
320
+ "pointer-events-none absolute inset-0 z-10",
321
+ "opacity-0 transition-opacity duration-base ease-standard",
322
+ // Suppress entirely under reduced-motion (cursor tracking = motion).
323
+ "motion-reduce:hidden",
324
+ )}
325
+ style={{
326
+ // Spotlight color is a DERIVED tint of the --primary token (not a raw
327
+ // literal), so it stays theme-correct in every theme without a new
328
+ // token. `color-mix` keeps it ~12% primary over transparent.
329
+ background:
330
+ "radial-gradient(400px circle at var(--bento-x, 50%) var(--bento-y, 50%), color-mix(in oklch, var(--primary) 12%, transparent), transparent 70%)",
331
+ }}
332
+ />
333
+ )}
334
+ {/* Tile content — min-w-0 on inner wrapper prevents flex children from
335
+ overflowing on long unbroken strings (the silent truncation culprit). */}
336
+ <div className="relative z-20 flex min-w-0 flex-1 flex-col">{children}</div>
337
+ </div>
338
+ );
339
+ });
@@ -0,0 +1,9 @@
1
+ export {
2
+ BentoGrid,
3
+ BentoGridItem,
4
+ bentoGridItemVariants,
5
+ type BentoGridProps,
6
+ type BentoGridItemProps,
7
+ type BentoGridItemVariants,
8
+ type BentoGridSize,
9
+ } from "./bento-grid";
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect } from "storybook/test";
3
+ import { useState } from "react";
4
+ import { BoundedNumber } from "./bounded-number";
5
+
6
+ const meta = {
7
+ title: "Forms/BoundedNumber",
8
+ component: BoundedNumber,
9
+ tags: ["autodocs"],
10
+ args: { min: 0, max: 1000, step: 10 },
11
+ argTypes: {
12
+ value: {
13
+ description: 'Controlled numeric value. Pass `null` (or leave empty) for "No limit".',
14
+ control: "number",
15
+ table: { category: "State" },
16
+ },
17
+ defaultValue: {
18
+ description: "Uncontrolled initial value.",
19
+ control: "number",
20
+ table: { category: "State" },
21
+ },
22
+ emptyLabel: {
23
+ description: 'Content shown instead of the value when empty (default "No limit").',
24
+ control: "text",
25
+ table: { category: "Content" },
26
+ },
27
+ min: {
28
+ description: "Minimum allowed value.",
29
+ control: "number",
30
+ table: { category: "Behavior" },
31
+ },
32
+ max: {
33
+ description: "Maximum allowed value.",
34
+ control: "number",
35
+ table: { category: "Behavior" },
36
+ },
37
+ step: {
38
+ description: "Increment/decrement amount per click or arrow key.",
39
+ control: "number",
40
+ table: { category: "Behavior" },
41
+ },
42
+ disabled: {
43
+ description: "Disables all interaction.",
44
+ control: "boolean",
45
+ table: { category: "State" },
46
+ },
47
+ onValueChange: {
48
+ description: "Called when the numeric value changes.",
49
+ control: false,
50
+ table: { category: "Behavior" },
51
+ },
52
+ className: {
53
+ description: "Additional CSS classes applied to the underlying NumberInput.",
54
+ control: "text",
55
+ table: { category: "Appearance" },
56
+ },
57
+ },
58
+ } satisfies Meta<typeof BoundedNumber>;
59
+ export default meta;
60
+ type Story = StoryObj<typeof meta>;
61
+
62
+ /** At rest (unfocused, empty) the field reads "No limit" instead of a blank box. */
63
+ export const Default: Story = {
64
+ args: { defaultValue: null, "aria-label": "Rate limit" },
65
+ play: async ({ canvas }) => {
66
+ await expect(canvas.getByText("No limit")).toBeInTheDocument();
67
+ },
68
+ };
69
+
70
+ export const WithValue: Story = {
71
+ args: { defaultValue: 250, "aria-label": "Rate limit" },
72
+ };
73
+
74
+ export const CustomEmptyLabel: Story = {
75
+ args: { defaultValue: null, emptyLabel: "Unlimited", "aria-label": "Rate limit" },
76
+ };
77
+
78
+ /**
79
+ * Typing a value and clearing it back: focusing hides the label so the field
80
+ * reads as truly empty while editing, and blurring an empty field restores
81
+ * the label (the value round-trips to `null`, never 0 or the max).
82
+ */
83
+ export const TypeThenClear: Story = {
84
+ render: () => {
85
+ // eslint-disable-next-line react-hooks/rules-of-hooks
86
+ const [value, setValue] = useState<number | null>(null);
87
+ return (
88
+ <div className="flex flex-col gap-2">
89
+ <BoundedNumber
90
+ aria-label="Rate limit"
91
+ value={value}
92
+ onValueChange={setValue}
93
+ min={0}
94
+ max={1000}
95
+ step={10}
96
+ />
97
+ <p className="text-body text-muted-foreground">Value: {value ?? "null"}</p>
98
+ </div>
99
+ );
100
+ },
101
+ play: async ({ canvas, userEvent }) => {
102
+ const input = canvas.getByRole("spinbutton", { name: "Rate limit" });
103
+ await userEvent.click(input);
104
+ await expect(canvas.queryByText("No limit")).not.toBeInTheDocument();
105
+ await userEvent.type(input, "500");
106
+ await userEvent.tab();
107
+ await expect(canvas.getByText("Value: 500")).toBeInTheDocument();
108
+ await userEvent.click(input);
109
+ await userEvent.clear(input);
110
+ await userEvent.tab();
111
+ await expect(canvas.getByText("Value: null")).toBeInTheDocument();
112
+ await expect(canvas.getByText("No limit")).toBeInTheDocument();
113
+ },
114
+ };
115
+
116
+ export const Disabled: Story = {
117
+ args: { defaultValue: null, disabled: true, "aria-label": "Rate limit" },
118
+ };
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { BoundedNumber } from "./bounded-number";
5
+
6
+ describe("BoundedNumber", () => {
7
+ it("renders the empty label instead of a blank box when value is null", () => {
8
+ render(<BoundedNumber aria-label="Limit" defaultValue={null} />);
9
+ expect(screen.getByText("No limit")).toBeInTheDocument();
10
+ });
11
+
12
+ it("supports a custom emptyLabel", () => {
13
+ render(<BoundedNumber aria-label="Limit" defaultValue={null} emptyLabel="Unlimited" />);
14
+ expect(screen.getByText("Unlimited")).toBeInTheDocument();
15
+ expect(screen.queryByText("No limit")).not.toBeInTheDocument();
16
+ });
17
+
18
+ it("hides the empty label once a value is set", () => {
19
+ render(<BoundedNumber aria-label="Limit" defaultValue={250} />);
20
+ expect(screen.queryByText("No limit")).not.toBeInTheDocument();
21
+ expect(screen.getByRole("spinbutton", { name: "Limit" })).toHaveValue(250);
22
+ });
23
+
24
+ it("hides the empty label while focused so the field reads as truly empty", async () => {
25
+ render(<BoundedNumber aria-label="Limit" defaultValue={null} />);
26
+ const input = screen.getByRole("spinbutton", { name: "Limit" });
27
+ await userEvent.click(input);
28
+ expect(screen.queryByText("No limit")).not.toBeInTheDocument();
29
+ });
30
+
31
+ it("round-trips to null (never 0 or max) when cleared and blurred", async () => {
32
+ const onValueChange = vi.fn();
33
+ render(
34
+ <BoundedNumber
35
+ aria-label="Limit"
36
+ defaultValue={100}
37
+ max={1000}
38
+ onValueChange={onValueChange}
39
+ />,
40
+ );
41
+ const input = screen.getByRole("spinbutton", { name: "Limit" });
42
+ await userEvent.clear(input);
43
+ await userEvent.tab();
44
+ expect(onValueChange).toHaveBeenLastCalledWith(null);
45
+ expect(screen.getByText("No limit")).toBeInTheDocument();
46
+ });
47
+
48
+ it("supports the controlled mode via value/onValueChange", async () => {
49
+ const onValueChange = vi.fn();
50
+ render(<BoundedNumber aria-label="Limit" value={null} onValueChange={onValueChange} />);
51
+ const input = screen.getByRole("spinbutton", { name: "Limit" });
52
+ await userEvent.click(input);
53
+ await userEvent.type(input, "42");
54
+ await userEvent.tab();
55
+ expect(onValueChange).toHaveBeenLastCalledWith(42);
56
+ });
57
+
58
+ it("is disabled when disabled prop is set", () => {
59
+ render(<BoundedNumber aria-label="Limit" defaultValue={null} disabled />);
60
+ expect(screen.getByRole("spinbutton", { name: "Limit" })).toBeDisabled();
61
+ });
62
+
63
+ it("merges className onto the ROOT so a caller can size the field", () => {
64
+ const { container } = render(
65
+ <BoundedNumber aria-label="Limit" defaultValue={null} className="w-full" />,
66
+ );
67
+ const root = container.querySelector('[data-slot="bounded-number"]');
68
+ // A width utility on the inner control would resolve against a
69
+ // shrink-to-fit wrapper and do nothing — it has to land on the root.
70
+ expect(root).toHaveClass("w-full");
71
+ expect(root).not.toHaveClass("w-36");
72
+ });
73
+ });
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useCallback, useState, type FocusEvent, type ReactNode } from "react";
4
+ import { cn } from "../../lib/cn";
5
+ import { NumberInput, type NumberInputProps } from "../number-input";
6
+ import { useLocale } from "../locale-provider";
7
+
8
+ export interface BoundedNumberProps extends NumberInputProps {
9
+ /**
10
+ * Content shown in place of the value when the field is empty (`null`) and
11
+ * not focused — e.g. "No limit" for a capped setting with no cap set.
12
+ * Defaults to "No limit".
13
+ */
14
+ emptyLabel?: ReactNode;
15
+ }
16
+
17
+ /**
18
+ * BoundedNumber — a `NumberInput` preset for a bounded numeric where "empty"
19
+ * has a semantic meaning ("No limit") instead of reading as a blank box.
20
+ *
21
+ * Written reuse verdict (see #373): this wraps `NumberInput` rather than
22
+ * reimplementing anything. `NumberInput` already does everything the value
23
+ * needs — blur-only clamping (`clamp`, default true) and `null` as a real,
24
+ * distinct empty state (`commit(null)` on empty blur). The only missing
25
+ * behaviour is rendering that empty state as a meaningful label instead of a
26
+ * blank input, which is exactly what this preset adds: an absolutely
27
+ * positioned, `pointer-events-none` overlay shown only while the value is
28
+ * `null` AND the field is not focused, so the underlying input stays fully
29
+ * clickable/typable and still round-trips to `null` on an empty blur.
30
+ */
31
+ export const BoundedNumber = forwardRef<HTMLDivElement, BoundedNumberProps>(function BoundedNumber(
32
+ {
33
+ value: valueProp,
34
+ defaultValue,
35
+ onValueChange,
36
+ emptyLabel,
37
+ onFocus,
38
+ onBlur,
39
+ className,
40
+ ...props
41
+ },
42
+ ref,
43
+ ) {
44
+ const { t } = useLocale();
45
+ const resolvedEmptyLabel = emptyLabel ?? t("ui.boundedNumber.emptyLabel");
46
+ const isControlled = valueProp !== undefined;
47
+ const [internalValue, setInternalValue] = useState<number | null>(
48
+ defaultValue !== undefined ? defaultValue : null,
49
+ );
50
+ const [focused, setFocused] = useState(false);
51
+ const currentValue = isControlled ? valueProp : internalValue;
52
+
53
+ const handleValueChange = useCallback(
54
+ (next: number | null) => {
55
+ if (!isControlled) setInternalValue(next);
56
+ onValueChange?.(next);
57
+ },
58
+ [isControlled, onValueChange],
59
+ );
60
+
61
+ const handleFocus = useCallback(
62
+ (event: FocusEvent<HTMLDivElement>) => {
63
+ setFocused(true);
64
+ onFocus?.(event);
65
+ },
66
+ [onFocus],
67
+ );
68
+
69
+ const handleBlur = useCallback(
70
+ (event: FocusEvent<HTMLDivElement>) => {
71
+ setFocused(false);
72
+ onBlur?.(event);
73
+ },
74
+ [onBlur],
75
+ );
76
+
77
+ const showEmptyLabel = currentValue == null && !focused;
78
+
79
+ return (
80
+ // `className` lands on the ROOT (the positioning context), not on the
81
+ // inner `NumberInput`: a width utility on the inner control would resolve
82
+ // against a shrink-to-fit wrapper and silently do nothing, so
83
+ // `className="w-full"` could never widen the field. `w-36` here is
84
+ // `NumberInput`'s own default width, re-declared so `cn()` lets the caller
85
+ // override it; the input then simply fills the root.
86
+ <div className={cn("relative inline-block w-36", className)} data-slot="bounded-number">
87
+ <NumberInput
88
+ ref={ref}
89
+ value={currentValue}
90
+ onValueChange={handleValueChange}
91
+ onFocus={handleFocus}
92
+ onBlur={handleBlur}
93
+ className="w-full"
94
+ {...props}
95
+ />
96
+ {showEmptyLabel && (
97
+ <span
98
+ aria-hidden="true"
99
+ data-slot="bounded-number-empty-label"
100
+ className={cn(
101
+ "pointer-events-none absolute inset-0 flex items-center justify-center",
102
+ "text-body text-muted-foreground",
103
+ )}
104
+ >
105
+ {resolvedEmptyLabel}
106
+ </span>
107
+ )}
108
+ </div>
109
+ );
110
+ });
@@ -0,0 +1 @@
1
+ export { BoundedNumber, type BoundedNumberProps } from "./bounded-number";