@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,241 @@
1
+ import { cleanup, render, screen, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ModelPicker } from "./model-picker";
5
+ import { countItems, modelPickerBody, showsInlineError } from "./model-picker-state";
6
+ import type { ModelPickerGroup } from "./model-picker-state";
7
+
8
+ afterEach(cleanup);
9
+
10
+ // cmdk calls Element.scrollIntoView to keep the auto-highlighted item visible.
11
+ // jsdom doesn't implement it and this package's vitest setup deliberately ships
12
+ // no global stub (a stub there would only fix @elabs-ai/components-ui's
13
+ // own tests while every other jsdom consumer still crashed), so third-party
14
+ // libraries that don't feature-detect get a local, scoped stub — the same
15
+ // pattern as combobox.test.tsx and tree.test.tsx.
16
+ let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
17
+ beforeEach(() => {
18
+ originalScrollIntoView = Element.prototype.scrollIntoView;
19
+ Element.prototype.scrollIntoView = vi.fn();
20
+ });
21
+ afterEach(() => {
22
+ Element.prototype.scrollIntoView = originalScrollIntoView;
23
+ });
24
+
25
+ const groups: ModelPickerGroup[] = [
26
+ {
27
+ key: "apps-northwind",
28
+ label: "Apps · northwind",
29
+ items: [
30
+ {
31
+ id: "app-1",
32
+ label: "Sales overview",
33
+ description: "Updated 2 days ago",
34
+ // The visible label never says "northwind" — the keyword is what finds it.
35
+ keywords: ["northwind", "app-1"],
36
+ meta: ["not provisioned"],
37
+ },
38
+ ],
39
+ },
40
+ {
41
+ key: "assistants-personal",
42
+ label: "Assistants · personal",
43
+ items: [{ id: "asst-1", label: "Research helper", keywords: ["personal"] }],
44
+ },
45
+ ];
46
+
47
+ const open = async () => {
48
+ await userEvent.click(screen.getAllByRole("combobox")[0]!);
49
+ };
50
+
51
+ // The two decisions that were unreachable inline ternaries in the app this came
52
+ // from. Tested directly — that is why they are exported functions.
53
+ describe("modelPickerBody / showsInlineError", () => {
54
+ it("lets a non-empty list win over every non-ready status", () => {
55
+ expect(modelPickerBody("error", 3)).toBe("list");
56
+ expect(modelPickerBody("stale", 3)).toBe("list");
57
+ expect(modelPickerBody("loading", 3)).toBe("list");
58
+ });
59
+
60
+ it("shows loading only while there is nothing to show", () => {
61
+ expect(modelPickerBody("loading", 0)).toBe("loading");
62
+ expect(modelPickerBody("idle", 0)).toBe("loading");
63
+ });
64
+
65
+ it("separates a failed load from a genuinely empty list", () => {
66
+ expect(modelPickerBody("error", 0)).toBe("error");
67
+ expect(modelPickerBody("stale", 0)).toBe("error");
68
+ expect(modelPickerBody("empty", 0)).toBe("empty");
69
+ expect(modelPickerBody("ready", 0)).toBe("empty");
70
+ });
71
+
72
+ it("shows the inline strip only over a list that still works", () => {
73
+ expect(showsInlineError("stale", 3)).toBe(true);
74
+ expect(showsInlineError("error", 3)).toBe(true);
75
+ // No list to preserve → the panel takes over instead.
76
+ expect(showsInlineError("error", 0)).toBe(false);
77
+ expect(showsInlineError("ready", 3)).toBe(false);
78
+ });
79
+
80
+ it("counts items across groups", () => {
81
+ expect(countItems(groups)).toBe(2);
82
+ });
83
+ });
84
+
85
+ describe("ModelPicker", () => {
86
+ it("renders grouped rows under caller-supplied headings", async () => {
87
+ render(<ModelPicker groups={groups} triggerLabel="Pick a target" />);
88
+ await open();
89
+ expect(screen.getByText("Apps · northwind")).toBeInTheDocument();
90
+ expect(screen.getByText("Assistants · personal")).toBeInTheDocument();
91
+ // Scoped to the list — the trigger label would otherwise match too.
92
+ const list = document.querySelector('[data-slot="model-picker-content"]')!;
93
+ expect(within(list as HTMLElement).getByText("Sales overview")).toBeInTheDocument();
94
+ });
95
+
96
+ it("matches hidden keywords, not just the visible label", async () => {
97
+ render(<ModelPicker groups={groups} triggerLabel="Pick" />);
98
+ await open();
99
+ await userEvent.type(screen.getByPlaceholderText("Search…"), "northwind");
100
+
101
+ // "Sales overview" contains no "northwind" anywhere on screen.
102
+ expect(await screen.findByText("Sales overview")).toBeInTheDocument();
103
+ expect(screen.queryByText("Research helper")).not.toBeInTheDocument();
104
+ });
105
+
106
+ it("marks the pinned row with data-picked, NOT data-selected", async () => {
107
+ render(<ModelPicker groups={groups} value="asst-1" triggerLabel="Research helper" />);
108
+ await open();
109
+
110
+ const picked = document.querySelector('[data-model-id="asst-1"]')!;
111
+ const other = document.querySelector('[data-model-id="app-1"]')!;
112
+ expect(picked).toHaveAttribute("data-picked", "true");
113
+ expect(other).toHaveAttribute("data-picked", "false");
114
+
115
+ // cmdk owns data-selected as the keyboard HIGHLIGHT. Asserting on it would
116
+ // report the highlighted row, not the pinned one — the trap this locks.
117
+ expect(picked.getAttribute("data-picked")).not.toBe(picked.getAttribute("data-selected"));
118
+ });
119
+
120
+ it("selects a row and closes", async () => {
121
+ const onSelect = vi.fn();
122
+ render(<ModelPicker groups={groups} triggerLabel="Pick" onSelect={onSelect} />);
123
+ await open();
124
+ await userEvent.click(screen.getByText("Research helper"));
125
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "asst-1" }));
126
+ });
127
+
128
+ it("keeps a usable list and shows an inline error when a refresh fails", async () => {
129
+ const onRetry = vi.fn();
130
+ render(<ModelPicker groups={groups} status="stale" triggerLabel="Pick" onRetry={onRetry} />);
131
+ await open();
132
+
133
+ // The list is still there — the failure must not take it over.
134
+ expect(screen.getByText("Sales overview")).toBeInTheDocument();
135
+ const strip = document.querySelector('[data-slot="model-picker-stale"]')!;
136
+ expect(within(strip as HTMLElement).getByText("Couldn't refresh the list")).toBeInTheDocument();
137
+
138
+ await userEvent.click(within(strip as HTMLElement).getByRole("button", { name: "Retry" }));
139
+ expect(onRetry).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("shows skeletons while loading with nothing to show", async () => {
143
+ render(<ModelPicker groups={[]} status="loading" triggerLabel="Pick" />);
144
+ await open();
145
+ expect(document.querySelector('[data-slot="model-picker-loading"]')).not.toBeNull();
146
+ expect(screen.getByRole("status")).toHaveTextContent("Loading…");
147
+ });
148
+
149
+ it("shows a retryable error panel when the load failed with no data", async () => {
150
+ const onRetry = vi.fn();
151
+ render(<ModelPicker groups={[]} status="error" triggerLabel="Pick" onRetry={onRetry} />);
152
+ await open();
153
+ expect(screen.getByText("Couldn't load the list")).toBeInTheDocument();
154
+ await userEvent.click(screen.getByRole("button", { name: "Retry" }));
155
+ expect(onRetry).toHaveBeenCalledTimes(1);
156
+ });
157
+
158
+ it("distinguishes empty from failed", async () => {
159
+ render(<ModelPicker groups={[]} status="empty" triggerLabel="Pick" />);
160
+ await open();
161
+ expect(screen.getByText("Nothing to show yet")).toBeInTheDocument();
162
+ expect(screen.queryByText("Couldn't load the list")).not.toBeInTheDocument();
163
+ });
164
+
165
+ it("names the trigger from its label, and lets aria-label name the CONTROL", async () => {
166
+ const { unmount } = render(<ModelPicker groups={groups} triggerLabel="Sales overview" />);
167
+ // Default: the name tells AT what is currently CHOSEN.
168
+ expect(screen.getAllByRole("combobox")[0]).toHaveAccessibleName("Sales overview");
169
+ unmount();
170
+
171
+ render(
172
+ <ModelPicker groups={groups} triggerLabel="Sales overview" aria-label="Target picker" />,
173
+ );
174
+ // Explicit: the name tells AT what the control is FOR.
175
+ expect(screen.getAllByRole("combobox")[0]).toHaveAccessibleName("Target picker");
176
+ });
177
+
178
+ it("renders caller-supplied meta badges verbatim", async () => {
179
+ render(<ModelPicker groups={groups} triggerLabel="Pick" />);
180
+ await open();
181
+ // Caller-built array, never derived from a flag — an absent flag means
182
+ // "undecided", and deriving from !flag mislabels every row.
183
+ expect(screen.getByText("not provisioned")).toBeInTheDocument();
184
+ expect(screen.queryByText("no modes")).not.toBeInTheDocument();
185
+ });
186
+
187
+ describe("controlled open / onOpenChange", () => {
188
+ it("shows the popover content with no interaction when open=true", () => {
189
+ render(<ModelPicker groups={groups} triggerLabel="Pick" open onOpenChange={vi.fn()} />);
190
+ // No click on the trigger — the content must already be there.
191
+ expect(screen.getByText("Apps · northwind")).toBeInTheDocument();
192
+ });
193
+
194
+ it("does not open on trigger click when open=false is held by the parent", async () => {
195
+ const onOpenChange = vi.fn();
196
+ render(
197
+ <ModelPicker
198
+ groups={groups}
199
+ triggerLabel="Pick"
200
+ open={false}
201
+ onOpenChange={onOpenChange}
202
+ />,
203
+ );
204
+ await open();
205
+ // The parent never updated `open`, so the popover must stay closed —
206
+ // this is the controlled contract, not a convenience default.
207
+ expect(screen.queryByText("Apps · northwind")).not.toBeInTheDocument();
208
+ expect(onOpenChange).toHaveBeenCalledWith(true);
209
+ });
210
+
211
+ it("calls onOpenChange(false) on row select instead of closing itself", async () => {
212
+ const onOpenChange = vi.fn();
213
+ const onSelect = vi.fn();
214
+ render(
215
+ <ModelPicker
216
+ groups={groups}
217
+ triggerLabel="Pick"
218
+ open
219
+ onOpenChange={onOpenChange}
220
+ onSelect={onSelect}
221
+ />,
222
+ );
223
+ await userEvent.click(screen.getByText("Research helper"));
224
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "asst-1" }));
225
+ expect(onOpenChange).toHaveBeenCalledWith(false);
226
+ // Controlled: the parent hasn't updated `open`, so content stays mounted.
227
+ expect(screen.getByText("Apps · northwind")).toBeInTheDocument();
228
+ });
229
+ });
230
+
231
+ it("uncontrolled mode: trigger click still opens and closes on its own (regression)", async () => {
232
+ const onSelect = vi.fn();
233
+ render(<ModelPicker groups={groups} triggerLabel="Pick" onSelect={onSelect} />);
234
+ expect(screen.queryByText("Apps · northwind")).not.toBeInTheDocument();
235
+ await open();
236
+ expect(screen.getByText("Apps · northwind")).toBeInTheDocument();
237
+ await userEvent.click(screen.getByText("Research helper"));
238
+ expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "asst-1" }));
239
+ expect(screen.queryByText("Apps · northwind")).not.toBeInTheDocument();
240
+ });
241
+ });
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useCallback, useState, type HTMLAttributes, type ReactNode } from "react";
4
+ import { Check, ChevronsUpDown } from "lucide-react";
5
+ import { cn } from "../../lib/cn";
6
+ import { Button } from "../button";
7
+ import {
8
+ Command,
9
+ CommandEmpty,
10
+ CommandGroup,
11
+ CommandInput,
12
+ CommandItem,
13
+ CommandList,
14
+ } from "../command";
15
+ import { useLocale } from "../locale-provider";
16
+ import { Popover, PopoverContent, PopoverTrigger } from "../popover";
17
+ import { Skeleton } from "../skeleton";
18
+ import {
19
+ countItems,
20
+ modelPickerBody,
21
+ showsInlineError,
22
+ type ModelPickerGroup,
23
+ type ModelPickerItem,
24
+ type ModelPickerStatus,
25
+ } from "./model-picker-state";
26
+
27
+ export interface ModelPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
28
+ /** Grouped rows. Grouping and heading text are the caller's, never derived. */
29
+ groups: readonly ModelPickerGroup[];
30
+ /** The currently pinned row's id. */
31
+ value?: string;
32
+ /** Fires with the chosen row; the popover closes first. */
33
+ onSelect?: (item: ModelPickerItem) => void;
34
+ /** Drives the four bodies — see `modelPickerBody`. Default `"ready"`. */
35
+ status?: ModelPickerStatus;
36
+ /** Trigger text — usually the pinned row's label. */
37
+ triggerLabel: ReactNode;
38
+ /** Retry action for the error strip and the empty panel. Omit to hide it. */
39
+ onRetry?: () => void;
40
+ /** Search field placeholder. */
41
+ searchPlaceholder?: string;
42
+ disabled?: boolean;
43
+ /** Extra classes for the popover panel (default width `w-96`). */
44
+ contentClassName?: string;
45
+ /** Accessible name for the trigger — what the control is FOR, not what is chosen. */
46
+ "aria-label"?: string;
47
+ /**
48
+ * Controlled popover open state. Omit for the uncontrolled default (internal
49
+ * `useState(false)`, unchanged). `isControlled = open !== undefined` and the
50
+ * mode never flips mid-life — see `component-api.md` "Controlled/uncontrolled".
51
+ */
52
+ open?: boolean;
53
+ /** Fires whenever the popover would open/close — a trigger click, or a row select. */
54
+ onOpenChange?: (open: boolean) => void;
55
+ }
56
+
57
+ function Row({
58
+ item,
59
+ picked,
60
+ onChoose,
61
+ }: {
62
+ item: ModelPickerItem;
63
+ picked: boolean;
64
+ onChoose: () => void;
65
+ }) {
66
+ return (
67
+ <CommandItem
68
+ value={item.id}
69
+ // cmdk filters on value + keywords, so a hidden keyword ("northwind") finds
70
+ // a row whose visible label never mentions it.
71
+ keywords={item.keywords ?? []}
72
+ disabled={item.disabled}
73
+ onSelect={onChoose}
74
+ data-model-id={item.id}
75
+ // `data-picked`, NOT `data-selected`. cmdk OWNS `data-selected` on a
76
+ // CommandItem — it is the keyboard HIGHLIGHT, set on whichever row the
77
+ // arrow keys are currently on. Writing our own would be silently
78
+ // overwritten, and a test reading it would report the highlighted row as
79
+ // the pinned one.
80
+ data-picked={picked ? "true" : "false"}
81
+ className="gap-2"
82
+ >
83
+ {item.icon}
84
+ <span className="flex min-w-0 flex-1 flex-col">
85
+ <span className="truncate">{item.label}</span>
86
+ {item.description === undefined ? null : (
87
+ <span className="text-meta text-muted-foreground truncate">{item.description}</span>
88
+ )}
89
+ </span>
90
+ {(item.meta ?? []).map((m) => (
91
+ <span key={m} className="text-meta text-muted-foreground shrink-0">
92
+ {m}
93
+ </span>
94
+ ))}
95
+ {picked ? <Check className="size-3.5 shrink-0" aria-hidden="true" /> : null}
96
+ </CommandItem>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * A compact pill that opens a grouped, searchable target list anchored under
102
+ * itself — sized to sit in a composer footer.
103
+ *
104
+ * The inline sibling of `@elabs-ai/components-ai`'s `ModelSelector`,
105
+ * which is the same `Command` internals in a modal `Dialog`. Reach for that one
106
+ * for a full command palette; reach for this one for an in-place picker.
107
+ *
108
+ * **`Command` lives inside a `Popover`, never a `DropdownMenu`, and that is not
109
+ * a stylistic choice.** `DropdownMenuContent` owns roving tabindex and its own
110
+ * typeahead, both of which fight a real `<input>` in its subtree: arrow keys move
111
+ * the menu's focus instead of cmdk's highlight, and keystrokes are swallowed
112
+ * before they reach `CommandInput`. `Popover` contributes positioning and
113
+ * dismissal and claims no keyboard, which is why every combobox is built on it.
114
+ * Do not "simplify" this back.
115
+ */
116
+ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function ModelPicker(
117
+ {
118
+ groups,
119
+ value,
120
+ onSelect,
121
+ status = "ready",
122
+ triggerLabel,
123
+ onRetry,
124
+ searchPlaceholder,
125
+ disabled,
126
+ contentClassName,
127
+ className,
128
+ open: openProp,
129
+ onOpenChange,
130
+ ...props
131
+ },
132
+ ref,
133
+ ) {
134
+ const { t } = useLocale();
135
+ // Controlled/uncontrolled per component-api.md: controlled iff `open` is
136
+ // provided; the mode never flips mid-life. Uncontrolled fallback is
137
+ // byte-identical to the old internal `useState(false)`.
138
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
139
+ const isControlled = openProp !== undefined;
140
+ const open = isControlled ? openProp : uncontrolledOpen;
141
+ const setOpen = useCallback(
142
+ (next: boolean) => {
143
+ if (!isControlled) setUncontrolledOpen(next);
144
+ onOpenChange?.(next);
145
+ },
146
+ [isControlled, onOpenChange],
147
+ );
148
+ const count = countItems(groups);
149
+ const body = modelPickerBody(status, count);
150
+
151
+ return (
152
+ <div ref={ref} data-slot="model-picker" className={cn("inline-flex", className)} {...props}>
153
+ <Popover open={open} onOpenChange={setOpen}>
154
+ <PopoverTrigger asChild>
155
+ <Button
156
+ variant="outline"
157
+ size="sm"
158
+ role="combobox"
159
+ aria-expanded={open}
160
+ // `role="combobox"` does NOT support name-from-content, so the
161
+ // visible label never becomes the accessible name on its own — the
162
+ // control would ship unnamed. Default to the label (telling AT what
163
+ // is currently CHOSEN, matching `Combobox`); an explicit `aria-label`
164
+ // wins and names what the control is FOR.
165
+ aria-label={
166
+ props["aria-label"] ??
167
+ (typeof triggerLabel === "string" ? triggerLabel : t("ui.modelPicker.label"))
168
+ }
169
+ disabled={disabled}
170
+ data-slot="model-picker-trigger"
171
+ className="max-w-88 justify-between gap-2"
172
+ >
173
+ <span className="truncate">{triggerLabel}</span>
174
+ <ChevronsUpDown className="size-3.5 shrink-0 opacity-50" aria-hidden="true" />
175
+ </Button>
176
+ </PopoverTrigger>
177
+
178
+ <PopoverContent
179
+ align="start"
180
+ data-slot="model-picker-content"
181
+ // Radix Popover.Content renders role="dialog"; without a name axe flags it.
182
+ aria-label={props["aria-label"] ?? t("ui.modelPicker.label")}
183
+ className={cn("w-96 p-0", contentClassName)}
184
+ >
185
+ <Command>
186
+ <CommandInput
187
+ placeholder={searchPlaceholder ?? t("ui.modelPicker.searchPlaceholder")}
188
+ />
189
+
190
+ {showsInlineError(status, count) ? (
191
+ // Above the list, never instead of it — the list still works.
192
+ <div
193
+ data-slot="model-picker-stale"
194
+ role="status"
195
+ className="border-border flex items-center justify-between gap-2 border-b px-3 py-2"
196
+ >
197
+ <span className="text-meta text-muted-foreground">
198
+ {t("ui.modelPicker.refreshFailed")}
199
+ </span>
200
+ {onRetry ? (
201
+ <Button variant="ghost" size="sm" onClick={onRetry}>
202
+ {t("ui.modelPicker.retry")}
203
+ </Button>
204
+ ) : null}
205
+ </div>
206
+ ) : null}
207
+
208
+ <CommandList>
209
+ {body === "loading" ? (
210
+ <div className="space-y-2 p-3" data-slot="model-picker-loading">
211
+ <span className="sr-only" role="status">
212
+ {t("loading")}
213
+ </span>
214
+ <Skeleton className="h-6 w-full" />
215
+ <Skeleton className="h-6 w-4/5" />
216
+ <Skeleton className="h-6 w-3/5" />
217
+ </div>
218
+ ) : null}
219
+
220
+ {body === "error" || body === "empty" ? (
221
+ <div className="space-y-2 p-4 text-center" data-slot="model-picker-state">
222
+ <p className="text-body">
223
+ {body === "error"
224
+ ? t("ui.modelPicker.loadFailed")
225
+ : t("ui.modelPicker.nothingYet")}
226
+ </p>
227
+ {onRetry ? (
228
+ <Button variant="outline" size="sm" onClick={onRetry}>
229
+ {t("ui.modelPicker.retry")}
230
+ </Button>
231
+ ) : null}
232
+ </div>
233
+ ) : null}
234
+
235
+ {body === "list" ? (
236
+ <>
237
+ <CommandEmpty>{t("noResults")}</CommandEmpty>
238
+ {groups.map((group) => (
239
+ <CommandGroup key={group.key} heading={group.label}>
240
+ {group.items.map((item) => (
241
+ <Row
242
+ key={item.id}
243
+ item={item}
244
+ picked={item.id === value}
245
+ onChoose={() => {
246
+ setOpen(false);
247
+ onSelect?.(item);
248
+ }}
249
+ />
250
+ ))}
251
+ </CommandGroup>
252
+ ))}
253
+ </>
254
+ ) : null}
255
+ </CommandList>
256
+ </Command>
257
+ </PopoverContent>
258
+ </Popover>
259
+ </div>
260
+ );
261
+ });
262
+
263
+ export {
264
+ countItems,
265
+ modelPickerBody,
266
+ showsInlineError,
267
+ type ModelPickerGroup,
268
+ type ModelPickerItem,
269
+ type ModelPickerStatus,
270
+ };
@@ -0,0 +1 @@
1
+ export { NavMain, type NavMainProps, type NavMainRoute, type NavMainSubItem } from "./nav-main";
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Shared collapsible sidebar navigation primitive (issue #99).
3
+ * Promoted from `packages/ui/src/blocks/sidebar-02/nav-main.tsx`.
4
+ *
5
+ * Renders a `SidebarMenu` of routes; each route with sub-items uses a
6
+ * `Collapsible` expand/collapse affordance. Collapses icon-only when the
7
+ * sidebar is in the `"collapsed"` state.
8
+ */
9
+ "use client";
10
+
11
+ import type React from "react";
12
+ import { useState } from "react";
13
+ import { ChevronDown, ChevronUp } from "lucide-react";
14
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../collapsible";
15
+ import {
16
+ SidebarMenu,
17
+ SidebarMenuButton,
18
+ SidebarMenuItem,
19
+ SidebarMenuSub,
20
+ SidebarMenuSubButton,
21
+ SidebarMenuSubItem,
22
+ useSidebar,
23
+ } from "../sidebar";
24
+ import { cn } from "../../lib/cn";
25
+
26
+ export type NavMainSubItem = {
27
+ title: string;
28
+ link: string;
29
+ icon?: React.ReactNode;
30
+ };
31
+
32
+ export type NavMainRoute = {
33
+ id: string;
34
+ title: string;
35
+ icon?: React.ReactNode;
36
+ link: string;
37
+ subs?: NavMainSubItem[];
38
+ };
39
+
40
+ export interface NavMainProps {
41
+ routes: NavMainRoute[];
42
+ className?: string;
43
+ }
44
+
45
+ /**
46
+ * Collapsible primary navigation list for sidebars.
47
+ *
48
+ * - Routes with `subs` render as an expandable group (one open at a time).
49
+ * - Routes without `subs` render as a simple link button.
50
+ * - Collapses to icon-only when the enclosing `Sidebar` is in `"collapsed"` state.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * <NavMain routes={[
55
+ * { id: "home", title: "Home", icon: <Home />, link: "#" },
56
+ * { id: "settings", title: "Settings", icon: <Settings />, link: "#",
57
+ * subs: [{ title: "General", link: "#" }] },
58
+ * ]} />
59
+ * ```
60
+ */
61
+ export function NavMain({ routes, className }: NavMainProps) {
62
+ const { state } = useSidebar();
63
+ const isCollapsed = state === "collapsed";
64
+ const [openCollapsible, setOpenCollapsible] = useState<string | null>(null);
65
+
66
+ return (
67
+ <SidebarMenu className={className}>
68
+ {routes.map((route) => {
69
+ const isOpen = !isCollapsed && openCollapsible === route.id;
70
+ const hasSubRoutes = !!route.subs?.length;
71
+
72
+ return (
73
+ <SidebarMenuItem key={route.id}>
74
+ {hasSubRoutes ? (
75
+ <Collapsible
76
+ open={isOpen}
77
+ onOpenChange={(open) => setOpenCollapsible(open ? route.id : null)}
78
+ className="w-full"
79
+ >
80
+ <CollapsibleTrigger asChild>
81
+ <SidebarMenuButton
82
+ tooltip={route.title}
83
+ className={cn(
84
+ "flex w-full items-center rounded-lg px-2 transition-colors",
85
+ isOpen
86
+ ? "bg-sidebar-accent text-sidebar-accent-foreground"
87
+ : "text-sidebar-muted-foreground",
88
+ isCollapsed && "justify-center",
89
+ )}
90
+ >
91
+ {route.icon}
92
+ {!isCollapsed && (
93
+ <span className="ms-2 flex-1 text-sm font-medium">{route.title}</span>
94
+ )}
95
+ {!isCollapsed && (
96
+ <span className="ms-auto">
97
+ {isOpen ? (
98
+ <ChevronUp className="size-4" />
99
+ ) : (
100
+ <ChevronDown className="size-4" />
101
+ )}
102
+ </span>
103
+ )}
104
+ </SidebarMenuButton>
105
+ </CollapsibleTrigger>
106
+ {!isCollapsed && (
107
+ <CollapsibleContent>
108
+ <SidebarMenuSub className="my-1 ms-3.5">
109
+ {route.subs?.map((subRoute) => (
110
+ <SidebarMenuSubItem
111
+ key={`${route.id}-${subRoute.title}`}
112
+ className="h-auto"
113
+ >
114
+ <SidebarMenuSubButton asChild>
115
+ <a
116
+ href={subRoute.link}
117
+ className="flex items-center rounded-md px-4 py-1.5 text-sm font-medium text-sidebar-muted-foreground"
118
+ >
119
+ {subRoute.title}
120
+ </a>
121
+ </SidebarMenuSubButton>
122
+ </SidebarMenuSubItem>
123
+ ))}
124
+ </SidebarMenuSub>
125
+ </CollapsibleContent>
126
+ )}
127
+ </Collapsible>
128
+ ) : (
129
+ <SidebarMenuButton tooltip={route.title} asChild>
130
+ <a
131
+ href={route.link}
132
+ className={cn(
133
+ "flex items-center rounded-lg px-2 text-sidebar-muted-foreground",
134
+ isCollapsed && "justify-center",
135
+ )}
136
+ >
137
+ {route.icon}
138
+ {!isCollapsed && <span className="ms-2 text-sm font-medium">{route.title}</span>}
139
+ </a>
140
+ </SidebarMenuButton>
141
+ )}
142
+ </SidebarMenuItem>
143
+ );
144
+ })}
145
+ </SidebarMenu>
146
+ );
147
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ NavNotifications,
3
+ type NavNotificationsProps,
4
+ type NavNotification,
5
+ } from "./nav-notifications";
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { NavNotifications } from "./nav-notifications";
4
+
5
+ describe("NavNotifications", () => {
6
+ const notifications = [
7
+ { id: "1", fallback: "AB", text: "New order received.", time: "10m ago" },
8
+ { id: "2", fallback: "CD", text: "Server upgrade completed.", time: "1h ago" },
9
+ ];
10
+
11
+ it("renders the notifications trigger button", () => {
12
+ render(<NavNotifications notifications={notifications} />);
13
+ expect(screen.getByRole("button", { name: "Open notifications" })).toBeInTheDocument();
14
+ });
15
+ });