@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 @@
1
+ export { ScrollArea, ScrollBar } from "./scroll-area";
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { ScrollArea } from "./scroll-area";
3
+ const meta = {
4
+ title: "Display/ScrollArea",
5
+ component: ScrollArea,
6
+ tags: ["autodocs"],
7
+ argTypes: {
8
+ type: {
9
+ description: "Controls scrollbar visibility: `auto` (default), `always`, `scroll`, `hover`.",
10
+ control: { type: "select" },
11
+ options: ["auto", "always", "scroll", "hover"],
12
+ table: { category: "Behavior" },
13
+ },
14
+ scrollHideDelay: {
15
+ description:
16
+ "Duration (ms) before the scrollbar hides (applicable to `scroll` and `hover` types).",
17
+ control: "number",
18
+ table: { category: "Behavior" },
19
+ },
20
+ dir: {
21
+ description: "Reading direction — affects scrollbar placement.",
22
+ control: { type: "radio" },
23
+ options: ["ltr", "rtl"],
24
+ table: { category: "Behavior" },
25
+ },
26
+ className: {
27
+ description: "Extra Tailwind classes merged via cn() on the root.",
28
+ control: "text",
29
+ table: { category: "Appearance" },
30
+ },
31
+ },
32
+ } satisfies Meta<typeof ScrollArea>;
33
+ export default meta;
34
+ type Story = StoryObj<typeof meta>;
35
+ export const Default: Story = {
36
+ render: () => (
37
+ <ScrollArea className="h-40 w-56 rounded-md border p-3">
38
+ <div className="space-y-2 text-body">
39
+ {Array.from({ length: 20 }).map((_, i) => (
40
+ <p key={i}>Row {i + 1}</p>
41
+ ))}
42
+ </div>
43
+ </ScrollArea>
44
+ ),
45
+ };
@@ -0,0 +1,43 @@
1
+ import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
2
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3
+ import { cn } from "../../lib/cn";
4
+
5
+ export const ScrollArea = forwardRef<
6
+ ElementRef<typeof ScrollAreaPrimitive.Root>,
7
+ ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
8
+ >(function ScrollArea({ className, children, ...props }, ref) {
9
+ return (
10
+ <ScrollAreaPrimitive.Root
11
+ ref={ref}
12
+ className={cn("relative overflow-hidden", className)}
13
+ {...props}
14
+ >
15
+ <ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
16
+ {children}
17
+ </ScrollAreaPrimitive.Viewport>
18
+ <ScrollBar />
19
+ <ScrollAreaPrimitive.Corner />
20
+ </ScrollAreaPrimitive.Root>
21
+ );
22
+ });
23
+
24
+ export const ScrollBar = forwardRef<
25
+ ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
26
+ ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
27
+ >(function ScrollBar({ className, orientation = "vertical", ...props }, ref) {
28
+ return (
29
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
30
+ ref={ref}
31
+ orientation={orientation}
32
+ className={cn(
33
+ "flex touch-none select-none transition-colors",
34
+ orientation === "vertical" && "h-full w-2.5 border-s border-s-transparent p-px",
35
+ orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-px",
36
+ className,
37
+ )}
38
+ {...props}
39
+ >
40
+ <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
41
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
42
+ );
43
+ });
@@ -0,0 +1 @@
1
+ export { SectionHeader, type SectionHeaderProps } from "./section-header";
@@ -0,0 +1,20 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Button } from "../button";
3
+ import { SectionHeader } from "./section-header";
4
+
5
+ const meta = {
6
+ title: "Layout/SectionHeader",
7
+ component: SectionHeader,
8
+ tags: ["autodocs"],
9
+ } satisfies Meta<typeof SectionHeader>;
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ eyebrow: "Workspace",
16
+ title: "Projects",
17
+ description: "Everything your team is working on.",
18
+ actions: <Button size="sm">New project</Button>,
19
+ },
20
+ };
@@ -0,0 +1,36 @@
1
+ import { type ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export interface SectionHeaderProps {
5
+ title: ReactNode;
6
+ description?: ReactNode;
7
+ /** Right-aligned actions (buttons, menus). */
8
+ actions?: ReactNode;
9
+ /** Optional element rendered above the title (eyebrow / breadcrumbs). */
10
+ eyebrow?: ReactNode;
11
+ className?: string;
12
+ }
13
+
14
+ /** A consistent page/section heading row with optional actions. */
15
+ export function SectionHeader({
16
+ title,
17
+ description,
18
+ actions,
19
+ eyebrow,
20
+ className,
21
+ }: SectionHeaderProps) {
22
+ return (
23
+ <div className={cn("flex flex-wrap items-end justify-between gap-4", className)}>
24
+ <div className="space-y-1">
25
+ {eyebrow ? (
26
+ <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
27
+ {eyebrow}
28
+ </div>
29
+ ) : null}
30
+ <h2 className="text-title text-foreground">{title}</h2>
31
+ {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
32
+ </div>
33
+ {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ SegmentedField,
3
+ type SegmentedFieldOption,
4
+ type SegmentedFieldProps,
5
+ } from "./segmented-field";
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect } from "storybook/test";
3
+ import { useState } from "react";
4
+ import { SegmentedField } from "./segmented-field";
5
+
6
+ const PRIORITY_OPTIONS = [
7
+ { value: "low", label: "Low" },
8
+ { value: "medium", label: "Medium" },
9
+ { value: "high", label: "High" },
10
+ ];
11
+
12
+ const meta = {
13
+ title: "Forms/SegmentedField",
14
+ component: SegmentedField,
15
+ tags: ["autodocs"],
16
+ args: {
17
+ label: "Priority",
18
+ options: PRIORITY_OPTIONS,
19
+ defaultValue: "medium",
20
+ },
21
+ argTypes: {
22
+ label: {
23
+ description: "Visible label describing the field.",
24
+ control: "text",
25
+ table: { category: "Content" },
26
+ },
27
+ options: {
28
+ description: "Segment options ({ value, label, disabled? }[]).",
29
+ control: false,
30
+ table: { category: "Content" },
31
+ },
32
+ value: {
33
+ description: "Controlled selected value.",
34
+ control: false,
35
+ table: { category: "State" },
36
+ },
37
+ defaultValue: {
38
+ description: "Uncontrolled initial selected value.",
39
+ control: false,
40
+ table: { category: "State" },
41
+ },
42
+ size: {
43
+ description: "Size applied to every segment.",
44
+ control: { type: "radio" },
45
+ options: ["default", "sm", "lg"],
46
+ table: { category: "Appearance" },
47
+ },
48
+ disabled: {
49
+ description: "Disables every segment.",
50
+ control: "boolean",
51
+ table: { category: "State" },
52
+ },
53
+ onValueChange: {
54
+ description: 'Called when the selected value changes. Never called with "".',
55
+ control: false,
56
+ table: { category: "Behavior" },
57
+ },
58
+ className: {
59
+ description: "Additional CSS classes applied to the root element.",
60
+ control: "text",
61
+ table: { category: "Appearance" },
62
+ },
63
+ },
64
+ } satisfies Meta<typeof SegmentedField>;
65
+ export default meta;
66
+ type Story = StoryObj<typeof meta>;
67
+
68
+ export const Default: Story = {};
69
+
70
+ export const Controlled: Story = {
71
+ render: () => {
72
+ // eslint-disable-next-line react-hooks/rules-of-hooks
73
+ const [value, setValue] = useState("medium");
74
+ return (
75
+ <div className="flex flex-col gap-2">
76
+ <SegmentedField
77
+ label="Priority"
78
+ options={PRIORITY_OPTIONS}
79
+ value={value}
80
+ onValueChange={setValue}
81
+ />
82
+ <p className="text-body text-muted-foreground">Value: {value}</p>
83
+ </div>
84
+ );
85
+ },
86
+ };
87
+
88
+ /**
89
+ * Locks the whole reason this component exists: re-clicking the already
90
+ * active segment is a no-op — the value never becomes "" or undefined.
91
+ */
92
+ export const ClickingActiveSegmentIsANoOp: Story = {
93
+ args: { defaultValue: "medium" },
94
+ play: async ({ canvas, userEvent }) => {
95
+ const mediumSegment = canvas.getByRole("radio", { name: "Medium" });
96
+ await expect(mediumSegment).toHaveAttribute("aria-checked", "true");
97
+ await userEvent.click(mediumSegment);
98
+ // Still checked — the empty-emission was swallowed, not applied.
99
+ await expect(mediumSegment).toHaveAttribute("aria-checked", "true");
100
+ await expect(canvas.getByRole("radio", { name: "Low" })).toHaveAttribute(
101
+ "aria-checked",
102
+ "false",
103
+ );
104
+ },
105
+ };
106
+
107
+ export const WithDisabledOption: Story = {
108
+ args: {
109
+ label: "Rollout stage",
110
+ options: [
111
+ { value: "dev", label: "Dev" },
112
+ { value: "staging", label: "Staging" },
113
+ { value: "prod", label: "Prod", disabled: true },
114
+ ],
115
+ defaultValue: "dev",
116
+ },
117
+ };
118
+
119
+ export const Disabled: Story = {
120
+ args: { defaultValue: "medium", disabled: true },
121
+ };
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { act, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { SegmentedField } from "./segmented-field";
5
+
6
+ const OPTIONS = [
7
+ { value: "low", label: "Low" },
8
+ { value: "medium", label: "Medium" },
9
+ { value: "high", label: "High" },
10
+ ];
11
+
12
+ describe("SegmentedField", () => {
13
+ it("renders the label and every segment", () => {
14
+ render(<SegmentedField label="Priority" options={OPTIONS} defaultValue="low" />);
15
+ expect(screen.getByText("Priority")).toBeInTheDocument();
16
+ expect(screen.getByRole("radio", { name: "Low" })).toBeInTheDocument();
17
+ expect(screen.getByRole("radio", { name: "Medium" })).toBeInTheDocument();
18
+ expect(screen.getByRole("radio", { name: "High" })).toBeInTheDocument();
19
+ });
20
+
21
+ it("always reports exactly one selected value (uncontrolled)", async () => {
22
+ const onValueChange = vi.fn();
23
+ render(
24
+ <SegmentedField
25
+ label="Priority"
26
+ options={OPTIONS}
27
+ defaultValue="low"
28
+ onValueChange={onValueChange}
29
+ />,
30
+ );
31
+ await userEvent.click(screen.getByRole("radio", { name: "High" }));
32
+ expect(onValueChange).toHaveBeenCalledWith("high");
33
+ expect(screen.getByRole("radio", { name: "High" })).toHaveAttribute("aria-checked", "true");
34
+ });
35
+
36
+ it("clicking the already-active segment is a no-op (never emits '')", async () => {
37
+ const onValueChange = vi.fn();
38
+ render(
39
+ <SegmentedField
40
+ label="Priority"
41
+ options={OPTIONS}
42
+ value="medium"
43
+ onValueChange={onValueChange}
44
+ />,
45
+ );
46
+ const mediumSegment = screen.getByRole("radio", { name: "Medium" });
47
+ await userEvent.click(mediumSegment);
48
+ expect(onValueChange).not.toHaveBeenCalled();
49
+ expect(mediumSegment).toHaveAttribute("aria-checked", "true");
50
+ });
51
+
52
+ it("never calls onValueChange with an empty string", async () => {
53
+ const onValueChange = vi.fn();
54
+ render(
55
+ <SegmentedField
56
+ label="Priority"
57
+ options={OPTIONS}
58
+ defaultValue="low"
59
+ onValueChange={onValueChange}
60
+ />,
61
+ );
62
+ await userEvent.click(screen.getByRole("radio", { name: "Low" }));
63
+ await userEvent.click(screen.getByRole("radio", { name: "Low" }));
64
+ for (const call of onValueChange.mock.calls) {
65
+ expect(call[0]).not.toBe("");
66
+ }
67
+ });
68
+
69
+ it("moves selection with arrow-key navigation (selection follows focus)", async () => {
70
+ const onValueChange = vi.fn();
71
+ render(
72
+ <SegmentedField
73
+ label="Priority"
74
+ options={OPTIONS}
75
+ defaultValue="low"
76
+ onValueChange={onValueChange}
77
+ />,
78
+ );
79
+ const lowSegment = screen.getByRole("radio", { name: "Low" });
80
+ act(() => {
81
+ lowSegment.focus();
82
+ });
83
+ await userEvent.keyboard("{ArrowRight}");
84
+ expect(onValueChange).toHaveBeenCalledWith("medium");
85
+ expect(screen.getByRole("radio", { name: "Medium" })).toHaveAttribute("aria-checked", "true");
86
+ });
87
+
88
+ it("clicking the label focuses the SELECTED segment without changing the value", async () => {
89
+ const onValueChange = vi.fn();
90
+ render(
91
+ <SegmentedField
92
+ label="Priority"
93
+ options={OPTIONS}
94
+ defaultValue="medium"
95
+ onValueChange={onValueChange}
96
+ />,
97
+ );
98
+ await userEvent.click(screen.getByText("Priority"));
99
+ // A `<label>` moves focus into its field — it never mutates it.
100
+ expect(screen.getByRole("radio", { name: "Medium" })).toHaveFocus();
101
+ expect(onValueChange).not.toHaveBeenCalled();
102
+ expect(screen.getByRole("radio", { name: "Medium" })).toHaveAttribute("aria-checked", "true");
103
+ });
104
+
105
+ it("tabbing into a group with nothing selected does not auto-select", async () => {
106
+ const onValueChange = vi.fn();
107
+ render(
108
+ <>
109
+ <button>before</button>
110
+ <SegmentedField label="Priority" options={OPTIONS} onValueChange={onValueChange} />
111
+ </>,
112
+ );
113
+ screen.getByRole("button", { name: "before" }).focus();
114
+ await userEvent.tab();
115
+ expect(onValueChange).not.toHaveBeenCalled();
116
+ expect(screen.queryByRole("radio", { checked: true })).not.toBeInTheDocument();
117
+ });
118
+
119
+ it("stays controlled for Radix even when nothing is selected yet", async () => {
120
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
121
+ render(<SegmentedField label="Priority" options={OPTIONS} />);
122
+ await userEvent.click(screen.getByRole("radio", { name: "High" }));
123
+ // A `value` that starts `undefined` and becomes a string is a mode flip;
124
+ // Radix reports it on console.error.
125
+ expect(warn.mock.calls.flat().join(" ")).not.toMatch(/uncontrolled to controlled/i);
126
+ warn.mockRestore();
127
+ });
128
+
129
+ it("disables every segment when disabled is set", () => {
130
+ render(<SegmentedField label="Priority" options={OPTIONS} defaultValue="low" disabled />);
131
+ expect(screen.getByRole("radio", { name: "Low" })).toBeDisabled();
132
+ expect(screen.getByRole("radio", { name: "Medium" })).toBeDisabled();
133
+ });
134
+
135
+ it("disables just the option marked disabled", () => {
136
+ render(
137
+ <SegmentedField
138
+ label="Stage"
139
+ options={[
140
+ { value: "dev", label: "Dev" },
141
+ { value: "prod", label: "Prod", disabled: true },
142
+ ]}
143
+ defaultValue="dev"
144
+ />,
145
+ );
146
+ expect(screen.getByRole("radio", { name: "Prod" })).toBeDisabled();
147
+ expect(screen.getByRole("radio", { name: "Dev" })).not.toBeDisabled();
148
+ });
149
+
150
+ it("supports the controlled mode via value/onValueChange", async () => {
151
+ const onValueChange = vi.fn();
152
+ render(
153
+ <SegmentedField
154
+ label="Priority"
155
+ options={OPTIONS}
156
+ value="low"
157
+ onValueChange={onValueChange}
158
+ />,
159
+ );
160
+ await userEvent.click(screen.getByRole("radio", { name: "High" }));
161
+ expect(onValueChange).toHaveBeenCalledWith("high");
162
+ });
163
+ });
@@ -0,0 +1,235 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useId,
7
+ useRef,
8
+ useState,
9
+ type FocusEvent as ReactFocusEvent,
10
+ type HTMLAttributes,
11
+ type KeyboardEvent as ReactKeyboardEvent,
12
+ type PointerEvent as ReactPointerEvent,
13
+ type ReactNode,
14
+ } from "react";
15
+ import { cn } from "../../lib/cn";
16
+ import { Label } from "../label";
17
+ import { ToggleGroup, ToggleGroupItem } from "../toggle-group";
18
+
19
+ /** Keys whose focus move must also move the selection (WAI-ARIA radiogroup). */
20
+ const NAV_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]);
21
+
22
+ export interface SegmentedFieldOption {
23
+ /** The option's value, reported to `onValueChange`. */
24
+ value: string;
25
+ /** Visible content for the segment. */
26
+ label: ReactNode;
27
+ /** Disables just this segment. */
28
+ disabled?: boolean;
29
+ }
30
+
31
+ export interface SegmentedFieldProps extends Omit<
32
+ HTMLAttributes<HTMLDivElement>,
33
+ "onChange" | "defaultValue"
34
+ > {
35
+ /** Visible label describing the field. */
36
+ label: ReactNode;
37
+ /**
38
+ * Segment options. Omit and render `ToggleGroupItem` children directly for
39
+ * the compound-component escape hatch (component-api.md) when a plain
40
+ * `{ value, label }` shape isn't expressive enough.
41
+ */
42
+ options?: SegmentedFieldOption[];
43
+ /** Controlled selected value. */
44
+ value?: string;
45
+ /** Uncontrolled initial selected value. */
46
+ defaultValue?: string;
47
+ /**
48
+ * Called when the selected value changes. Never called with `""` —
49
+ * re-clicking the active segment (Radix `ToggleGroup`'s `type="single"`
50
+ * empty-emission on re-click) is swallowed as a no-op instead of clearing
51
+ * the field.
52
+ */
53
+ onValueChange?: (value: string) => void;
54
+ /** Size applied to every segment. Defaults to "sm", matching the compact segmented look. */
55
+ size?: "default" | "sm" | "lg";
56
+ /** Disables every segment. */
57
+ disabled?: boolean;
58
+ /** `ToggleGroupItem` children — the compound escape hatch (used instead of `options`). */
59
+ children?: ReactNode;
60
+ }
61
+
62
+ /**
63
+ * SegmentedField — a labelled segmented control with STICKY selection.
64
+ *
65
+ * Composes the existing `Label` + `ToggleGroup`/`ToggleGroupItem`
66
+ * (`variant="segmented"`) — no new visual style. The whole reason this
67
+ * component exists: Radix's `ToggleGroup` in `type="single"` mode emits `""`
68
+ * when the already-active segment is re-clicked, silently clearing a field
69
+ * that must always hold exactly one value. `SegmentedField` swallows that
70
+ * emission so re-clicking the active segment is a true no-op.
71
+ *
72
+ * Per the WAI-ARIA radiogroup pattern (each segment renders `role="radio"`
73
+ * for `type="single"`), ARROW-KEY navigation also SELECTS the newly focused
74
+ * segment (selection follows focus), matching native radio-button behavior —
75
+ * Radix's roving-tabindex only moves focus, so this component adds that
76
+ * activation on top of it.
77
+ *
78
+ * Selection follows focus ONLY for arrow/Home/End keys, never for arbitrary
79
+ * focus. A bare `onFocus` commit would make three non-navigation paths mutate
80
+ * the field: clicking the `<Label>` (which moves focus into the group),
81
+ * tabbing into a group with nothing selected yet, and any programmatic
82
+ * `.focus()` a consumer performs. The keyboard intent is recorded on the
83
+ * wrapper in the CAPTURE phase (it must land before Radix's own roving-focus
84
+ * keydown handler runs) and is cleared by the focus it explains, by a pointer
85
+ * press, by any non-navigation key, or by focus leaving the field — so it can
86
+ * never leak into a later, unrelated focus.
87
+ */
88
+ export const SegmentedField = forwardRef<HTMLDivElement, SegmentedFieldProps>(
89
+ function SegmentedField(
90
+ {
91
+ label,
92
+ options,
93
+ value: valueProp,
94
+ defaultValue,
95
+ onValueChange,
96
+ size = "sm",
97
+ disabled,
98
+ id,
99
+ className,
100
+ children,
101
+ ...props
102
+ },
103
+ ref,
104
+ ) {
105
+ const generatedId = useId();
106
+ const labelId = `${id ?? generatedId}-label`;
107
+ const groupRef = useRef<HTMLDivElement>(null);
108
+
109
+ const isControlled = valueProp !== undefined;
110
+ const [internalValue, setInternalValue] = useState(defaultValue);
111
+ const currentValue = isControlled ? valueProp : internalValue;
112
+
113
+ // Synced every render (mirrors the ref-during-render idiom in
114
+ // NumberInput) so a same-event double-dispatch (focus-follows-selection
115
+ // AND the click's own onPressedChange) commits at most once.
116
+ const lastCommittedRef = useRef(currentValue);
117
+ lastCommittedRef.current = currentValue;
118
+
119
+ const commit = useCallback(
120
+ (next: string) => {
121
+ // Radix emits "" when the active segment is re-clicked — swallow it.
122
+ if (next === "" || next === lastCommittedRef.current) return;
123
+ lastCommittedRef.current = next;
124
+ if (!isControlled) setInternalValue(next);
125
+ onValueChange?.(next);
126
+ },
127
+ [isControlled, onValueChange],
128
+ );
129
+
130
+ // Armed by an arrow/Home/End key, DISARMED by anything else — a pointer
131
+ // press, any other key, or focus leaving the field. It is deliberately not
132
+ // a timer: `@radix-ui/react-roving-focus` moves focus in a
133
+ // `setTimeout(() => focusFirst(...))`, so a microtask/`setTimeout` reset
134
+ // queued from the capture phase would always fire first and the arm would
135
+ // be gone before the focus it exists to classify.
136
+ const keyboardNavRef = useRef(false);
137
+
138
+ const handleKeyDownCapture = useCallback(
139
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
140
+ keyboardNavRef.current = NAV_KEYS.has(event.key);
141
+ props.onKeyDownCapture?.(event);
142
+ },
143
+ [props],
144
+ );
145
+
146
+ const handlePointerDownCapture = useCallback(
147
+ (event: ReactPointerEvent<HTMLDivElement>) => {
148
+ keyboardNavRef.current = false;
149
+ props.onPointerDownCapture?.(event);
150
+ },
151
+ [props],
152
+ );
153
+
154
+ const handleBlurCapture = useCallback(
155
+ (event: ReactFocusEvent<HTMLDivElement>) => {
156
+ // Focus moving between segments keeps the arm; leaving the field drops
157
+ // it, so a stale arm can never fire on a later tab-in.
158
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
159
+ keyboardNavRef.current = false;
160
+ }
161
+ props.onBlurCapture?.(event);
162
+ },
163
+ [props],
164
+ );
165
+
166
+ const handleItemFocus = useCallback(
167
+ (next: string) => {
168
+ if (!keyboardNavRef.current) return;
169
+ keyboardNavRef.current = false;
170
+ commit(next);
171
+ },
172
+ [commit],
173
+ );
174
+
175
+ // Match a native `<label>`: move focus into the field without changing it.
176
+ // Focusing the SELECTED segment (not blindly the first) keeps the visible
177
+ // selection and the focus ring in agreement.
178
+ const handleLabelClick = useCallback(() => {
179
+ const group = groupRef.current;
180
+ if (!group) return;
181
+ const selected = group.querySelector<HTMLButtonElement>(
182
+ '[aria-checked="true"]:not(:disabled),[data-state="on"]:not(:disabled)',
183
+ );
184
+ (selected ?? group.querySelector<HTMLButtonElement>("button:not(:disabled)"))?.focus();
185
+ }, []);
186
+
187
+ return (
188
+ <div
189
+ ref={ref}
190
+ data-slot="segmented-field"
191
+ className={cn("flex flex-col items-start gap-1.5", className)}
192
+ {...props}
193
+ // After the spread: these three are load-bearing (they classify a
194
+ // focus as arrow-navigation or not) and each re-invokes the consumer's
195
+ // handler, so composing beats being silently replaced.
196
+ onKeyDownCapture={handleKeyDownCapture}
197
+ onPointerDownCapture={handlePointerDownCapture}
198
+ onBlurCapture={handleBlurCapture}
199
+ >
200
+ <Label id={labelId} data-slot="segmented-field-label" onClick={handleLabelClick}>
201
+ {label}
202
+ </Label>
203
+ <ToggleGroup
204
+ ref={groupRef}
205
+ type="single"
206
+ variant="segmented"
207
+ size={size}
208
+ // Always a string, never `undefined` — an unselected field would
209
+ // otherwise start UNcontrolled and flip to controlled on the first
210
+ // selection (React/Radix warn, and the mode flip is exactly what
211
+ // component-api.md forbids). `""` is Radix's "nothing selected".
212
+ value={currentValue ?? ""}
213
+ onValueChange={commit}
214
+ disabled={disabled}
215
+ aria-labelledby={labelId}
216
+ data-slot="segmented-field-options"
217
+ >
218
+ {options
219
+ ? options.map((option) => (
220
+ <ToggleGroupItem
221
+ key={option.value}
222
+ data-slot="segmented-field-segment"
223
+ value={option.value}
224
+ disabled={option.disabled}
225
+ onFocus={() => handleItemFocus(option.value)}
226
+ >
227
+ {option.label}
228
+ </ToggleGroupItem>
229
+ ))
230
+ : children}
231
+ </ToggleGroup>
232
+ </div>
233
+ );
234
+ },
235
+ );