@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,301 @@
1
+ import {
2
+ createContext,
3
+ forwardRef,
4
+ use,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ComponentPropsWithoutRef,
10
+ type ElementRef,
11
+ type HTMLAttributes,
12
+ type ReactNode,
13
+ type RefObject,
14
+ } from "react";
15
+ import { Command as CommandPrimitive, useCommandState } from "cmdk";
16
+ import { Search } from "lucide-react";
17
+ import { cn } from "../../lib/cn";
18
+ import { mergeRefs } from "../../lib/merge-refs";
19
+ import { Dialog, DialogContent } from "../dialog";
20
+
21
+ /** cmdk's own marker attribute on an item row — the scope for the id lookup. */
22
+ const CMDK_ITEM_SELECTOR = '[cmdk-item=""][aria-selected="true"]';
23
+
24
+ /** Distinguishes "no `<Command>` ancestor" from "nothing is highlighted". */
25
+ const NO_COMMAND_PROVIDER = Symbol("no-command-provider");
26
+
27
+ const CommandActiveItemIdContext = createContext<string | undefined | typeof NO_COMMAND_PROVIDER>(
28
+ NO_COMMAND_PROVIDER,
29
+ );
30
+
31
+ /**
32
+ * Resolves the DOM `id` of the currently highlighted `CommandItem`, publishes
33
+ * it on a context and reports it to `Command`'s `onActiveItemIdChange`.
34
+ *
35
+ * It renders no DOM of its own, and it deliberately does NOT simply forward
36
+ * cmdk's `state.selectedItemId`. cmdk only recomputes that field inside a
37
+ * `schedule(7, …)` callback queued from `setState("value", …)`, and its own
38
+ * `selectFirstItem()` runs INSIDE the layout-effect flush that drains that
39
+ * queue — so the callback executes before React has committed the new
40
+ * `aria-selected` attribute and cmdk's `getSelectedItem()` DOM query finds
41
+ * nothing. The field therefore stays `undefined` for BOTH cases that matter
42
+ * here: the item auto-highlighted on mount, and the item re-highlighted after
43
+ * every filter keystroke. (cmdk's own `Command.Input` inherits the same hole —
44
+ * it renders no `aria-activedescendant` in either state. Verified against
45
+ * cmdk 1.1.1 in jsdom and Chromium.)
46
+ *
47
+ * Reading the committed DOM from a post-commit `useEffect` — the same query
48
+ * cmdk itself uses, just at a point where the attribute exists — closes both
49
+ * gaps. `state.selectedItemId` is kept as a fallback and as one of the change
50
+ * triggers.
51
+ */
52
+ function CommandActiveItemProvider({
53
+ rootRef,
54
+ onChange,
55
+ children,
56
+ }: {
57
+ rootRef: RefObject<HTMLDivElement | null>;
58
+ onChange?: (id: string | undefined) => void;
59
+ children: ReactNode;
60
+ }) {
61
+ const value = useCommandState((state) => state.value);
62
+ const selectedItemId = useCommandState((state) => state.selectedItemId);
63
+ const filteredCount = useCommandState((state) => state.filtered.count);
64
+
65
+ const [activeId, setActiveId] = useState<string | undefined>(undefined);
66
+
67
+ // The callback lives in a ref so an inline arrow prop doesn't re-fire the
68
+ // effect on every render.
69
+ const onChangeRef = useRef(onChange);
70
+ useEffect(() => {
71
+ onChangeRef.current = onChange;
72
+ }, [onChange]);
73
+
74
+ // `undefined` is a legitimate reported value ("nothing highlighted"), so the
75
+ // "not reported yet" sentinel has to be something else.
76
+ const lastReportedRef = useRef<string | undefined | typeof NO_COMMAND_PROVIDER>(
77
+ NO_COMMAND_PROVIDER,
78
+ );
79
+
80
+ useEffect(() => {
81
+ const element = rootRef.current?.querySelector<HTMLElement>(CMDK_ITEM_SELECTOR);
82
+ const next = element?.id ?? selectedItemId ?? undefined;
83
+
84
+ setActiveId(next);
85
+ if (lastReportedRef.current !== next) {
86
+ lastReportedRef.current = next;
87
+ onChangeRef.current?.(next);
88
+ }
89
+ }, [value, selectedItemId, filteredCount, rootRef]);
90
+
91
+ return <CommandActiveItemIdContext value={activeId}>{children}</CommandActiveItemIdContext>;
92
+ }
93
+
94
+ export interface CommandProps extends ComponentPropsWithoutRef<typeof CommandPrimitive> {
95
+ /**
96
+ * Reports the DOM `id` cmdk assigned to the currently highlighted item, so
97
+ * an input rendered OUTSIDE this `Command` tree (a composer textarea, a
98
+ * combobox trigger) can set `aria-activedescendant` without querying
99
+ * `[role="option"]` positionally. Fires with `undefined` when nothing is
100
+ * highlighted.
101
+ *
102
+ * cmdk owns each item's `id`/`role`/`aria-selected` and applies them AFTER
103
+ * the consumer spread, so a consumer-supplied `id` on `CommandItem` is
104
+ * silently dropped (`CommandItem` warns about this in development) — this
105
+ * callback is the supported way to obtain it. See also the
106
+ * `useCommandActiveItemId` hook for reading the same value from a
107
+ * descendant already inside the `<Command>` tree.
108
+ *
109
+ * It fires for EVERY highlight change, including the item cmdk
110
+ * auto-highlights on mount and the one it re-highlights after each filter
111
+ * keystroke — the two states cmdk's own `state.selectedItemId` misses.
112
+ */
113
+ onActiveItemIdChange?: (id: string | undefined) => void;
114
+ }
115
+
116
+ export const Command = forwardRef<ElementRef<typeof CommandPrimitive>, CommandProps>(
117
+ function Command({ className, children, onActiveItemIdChange, ...props }, ref) {
118
+ const innerRef = useRef<HTMLDivElement>(null);
119
+ const mergedRef = useMemo(() => mergeRefs<HTMLDivElement>(ref, innerRef), [ref]);
120
+
121
+ return (
122
+ <CommandPrimitive
123
+ ref={mergedRef}
124
+ data-slot="command"
125
+ className={cn(
126
+ "flex size-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
127
+ className,
128
+ )}
129
+ {...props}
130
+ >
131
+ <CommandActiveItemProvider rootRef={innerRef} onChange={onActiveItemIdChange}>
132
+ {children}
133
+ </CommandActiveItemProvider>
134
+ </CommandPrimitive>
135
+ );
136
+ },
137
+ );
138
+
139
+ /**
140
+ * The DOM `id` of the currently highlighted `CommandItem`. Must be called
141
+ * from a component rendered inside a `<Command>` tree; returns `undefined`
142
+ * when nothing is highlighted (or when there is no `<Command>` ancestor).
143
+ * Reports exactly the same value as `Command`'s `onActiveItemIdChange`
144
+ * callback, for a consumer that is already a descendant (rather than an
145
+ * external input above the tree).
146
+ */
147
+ export function useCommandActiveItemId(): string | undefined {
148
+ const id = use(CommandActiveItemIdContext);
149
+ return id === NO_COMMAND_PROVIDER ? undefined : id;
150
+ }
151
+
152
+ export function CommandDialog({
153
+ children,
154
+ filter,
155
+ ...props
156
+ }: ComponentPropsWithoutRef<typeof Dialog> & {
157
+ /** Custom cmdk ranking (e.g. strict substring instead of fuzzy). */
158
+ filter?: ComponentPropsWithoutRef<typeof CommandPrimitive>["filter"];
159
+ }) {
160
+ return (
161
+ <Dialog {...props}>
162
+ <DialogContent className="overflow-hidden p-0">
163
+ <Command
164
+ filter={filter}
165
+ className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3"
166
+ >
167
+ {children}
168
+ </Command>
169
+ </DialogContent>
170
+ </Dialog>
171
+ );
172
+ }
173
+
174
+ export const CommandInput = forwardRef<
175
+ ElementRef<typeof CommandPrimitive.Input>,
176
+ ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
177
+ >(function CommandInput({ className, ...props }, ref) {
178
+ return (
179
+ <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
180
+ <Search className="me-2 size-4 shrink-0 opacity-50" />
181
+ <CommandPrimitive.Input
182
+ ref={ref}
183
+ data-slot="command-input"
184
+ className={cn(
185
+ "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
186
+ className,
187
+ )}
188
+ {...props}
189
+ />
190
+ </div>
191
+ );
192
+ });
193
+
194
+ export const CommandList = forwardRef<
195
+ ElementRef<typeof CommandPrimitive.List>,
196
+ ComponentPropsWithoutRef<typeof CommandPrimitive.List>
197
+ >(function CommandList({ className, ...props }, ref) {
198
+ return (
199
+ <CommandPrimitive.List
200
+ ref={ref}
201
+ data-slot="command-list"
202
+ className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
203
+ {...props}
204
+ />
205
+ );
206
+ });
207
+
208
+ export const CommandEmpty = forwardRef<
209
+ ElementRef<typeof CommandPrimitive.Empty>,
210
+ ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
211
+ >(function CommandEmpty(props, ref) {
212
+ return (
213
+ <CommandPrimitive.Empty
214
+ ref={ref}
215
+ data-slot="command-empty"
216
+ className="py-6 text-center text-sm"
217
+ {...props}
218
+ />
219
+ );
220
+ });
221
+
222
+ export const CommandGroup = forwardRef<
223
+ ElementRef<typeof CommandPrimitive.Group>,
224
+ ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
225
+ >(function CommandGroup({ className, ...props }, ref) {
226
+ return (
227
+ <CommandPrimitive.Group
228
+ ref={ref}
229
+ data-slot="command-group"
230
+ className={cn(
231
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
232
+ className,
233
+ )}
234
+ {...props}
235
+ />
236
+ );
237
+ });
238
+
239
+ export const CommandSeparator = forwardRef<
240
+ ElementRef<typeof CommandPrimitive.Separator>,
241
+ ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
242
+ >(function CommandSeparator({ className, ...props }, ref) {
243
+ return (
244
+ <CommandPrimitive.Separator
245
+ ref={ref}
246
+ data-slot="command-separator"
247
+ className={cn("-mx-1 h-px bg-border", className)}
248
+ {...props}
249
+ />
250
+ );
251
+ });
252
+
253
+ /**
254
+ * Props cmdk owns and applies AFTER a consumer spread — see the `Command`
255
+ * `onActiveItemIdChange` docblock. Passing any of these is a silent no-op;
256
+ * `CommandItem` warns once per key in development.
257
+ */
258
+ const CMDK_OVERRIDDEN_ITEM_PROPS = ["id", "role", "aria-selected"] as const;
259
+ const warnedAboutOverriddenItemProps = new Set<string>();
260
+
261
+ export const CommandItem = forwardRef<
262
+ ElementRef<typeof CommandPrimitive.Item>,
263
+ ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
264
+ >(function CommandItem({ className, ...props }, ref) {
265
+ if (process.env.NODE_ENV !== "production") {
266
+ for (const key of CMDK_OVERRIDDEN_ITEM_PROPS) {
267
+ if (key in props && !warnedAboutOverriddenItemProps.has(key)) {
268
+ warnedAboutOverriddenItemProps.add(key);
269
+ // oxlint-disable-next-line no-console
270
+ console.warn(
271
+ `CommandItem: "${key}" is assigned internally by cmdk, so the value you passed is ` +
272
+ 'ignored. Use <Command onActiveItemIdChange> (or the "useCommandActiveItemId" hook) ' +
273
+ "to read the DOM id of the highlighted item instead — see " +
274
+ "packages/ui/src/components/command/command.tsx.",
275
+ );
276
+ }
277
+ }
278
+ }
279
+
280
+ return (
281
+ <CommandPrimitive.Item
282
+ ref={ref}
283
+ data-slot="command-item"
284
+ className={cn(
285
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4",
286
+ className,
287
+ )}
288
+ {...props}
289
+ />
290
+ );
291
+ });
292
+
293
+ export function CommandShortcut({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
294
+ return (
295
+ <span
296
+ data-slot="command-shortcut"
297
+ className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
298
+ {...props}
299
+ />
300
+ );
301
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ Command,
3
+ CommandDialog,
4
+ CommandInput,
5
+ CommandList,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandSeparator,
9
+ CommandItem,
10
+ CommandShortcut,
11
+ useCommandActiveItemId,
12
+ type CommandProps,
13
+ } from "./command";
@@ -0,0 +1,183 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, waitFor, within } from "storybook/test";
4
+ import { ThemeProvider } from "@elabs-ai/components-tokens";
5
+ import { Button } from "../button";
6
+ import { Text } from "../typography";
7
+ import { ConfirmDialog } from "./confirm-dialog";
8
+
9
+ const meta = {
10
+ title: "Overlays/ConfirmDialog",
11
+ component: ConfirmDialog,
12
+ tags: ["autodocs"],
13
+ // Every story drives its own `open` state through a `render`; these args exist
14
+ // so the autodocs props table has real values to describe.
15
+ args: {
16
+ open: false,
17
+ onOpenChange: () => {},
18
+ title: "Delete this skill?",
19
+ description: "Removing it also deletes its 42 saved runs. This cannot be undone.",
20
+ confirmLabel: "Delete skill",
21
+ onConfirm: () => {},
22
+ },
23
+ } satisfies Meta<typeof ConfirmDialog>;
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ function DestructiveConfirm({ loading = false }: { loading?: boolean }) {
28
+ const [open, setOpen] = useState(false);
29
+ const [deleted, setDeleted] = useState(false);
30
+ return (
31
+ <>
32
+ <Button variant="destructive" onClick={() => setOpen(true)}>
33
+ Delete skill
34
+ </Button>
35
+ {deleted ? <Text variant="caption">Skill deleted</Text> : null}
36
+ <ConfirmDialog
37
+ open={open}
38
+ onOpenChange={setOpen}
39
+ tone="destructive"
40
+ loading={loading}
41
+ title="Delete this skill?"
42
+ description="Removing it also deletes its 42 saved runs. This cannot be undone."
43
+ confirmLabel="Delete skill"
44
+ onConfirm={() => {
45
+ setDeleted(true);
46
+ setOpen(false);
47
+ }}
48
+ />
49
+ </>
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The confirm tier. Initial focus lands on **Cancel**, not on the destructive
55
+ * action — asserted below rather than eyeballed, because that is the whole
56
+ * reason this component exists instead of a hand-built `Dialog`.
57
+ */
58
+ export const Default: Story = {
59
+ render: () => <DestructiveConfirm />,
60
+ play: async ({ canvas, canvasElement, userEvent }) => {
61
+ await userEvent.click(canvas.getByRole("button", { name: "Delete skill" }));
62
+ const doc = within(canvasElement.ownerDocument.body);
63
+ const dialog = await doc.findByRole("alertdialog");
64
+ await waitFor(() => expect(within(dialog).getByText("Delete this skill?")).toBeVisible());
65
+
66
+ const cancel = within(dialog).getByRole("button", { name: "Cancel" });
67
+ const confirm = within(dialog).getByRole("button", { name: "Delete skill" });
68
+ await waitFor(() => expect(document.activeElement).toBe(cancel));
69
+ expect(document.activeElement).not.toBe(confirm);
70
+
71
+ // Cancel is FIRST in the DOM (so AT reads the safe action first) while the
72
+ // footer still paints the confirm action to its inline end.
73
+ expect(cancel.compareDocumentPosition(confirm) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
74
+ expect(confirm.getBoundingClientRect().left).toBeGreaterThan(
75
+ cancel.getBoundingClientRect().left,
76
+ );
77
+
78
+ await userEvent.click(confirm);
79
+ await waitFor(() => expect(canvas.getByText("Skill deleted")).toBeVisible());
80
+ },
81
+ };
82
+
83
+ /** Escape cancels — the keyboard route to the safe outcome. */
84
+ export const EscapeCancels: Story = {
85
+ render: () => <DestructiveConfirm />,
86
+ play: async ({ canvas, canvasElement, userEvent }) => {
87
+ await userEvent.click(canvas.getByRole("button", { name: "Delete skill" }));
88
+ const doc = within(canvasElement.ownerDocument.body);
89
+ await doc.findByRole("alertdialog");
90
+ await userEvent.keyboard("{Escape}");
91
+ await waitFor(() => expect(doc.queryByRole("alertdialog")).toBeNull());
92
+ expect(canvas.queryByText("Skill deleted")).toBeNull();
93
+ },
94
+ };
95
+
96
+ /**
97
+ * The request is in flight. The action swaps in a spinner and reports
98
+ * `aria-busy`, but it is never pre-disabled — and repeat activations are
99
+ * swallowed, so a double-click cannot fire the deletion twice.
100
+ */
101
+ export const Loading: Story = {
102
+ render: () => <DestructiveConfirm loading />,
103
+ play: async ({ canvas, canvasElement, userEvent }) => {
104
+ await userEvent.click(canvas.getByRole("button", { name: "Delete skill" }));
105
+ const doc = within(canvasElement.ownerDocument.body);
106
+ const dialog = await doc.findByRole("alertdialog");
107
+ const confirm = within(dialog).getByRole("button", { name: /delete skill/i });
108
+
109
+ await waitFor(() => expect(confirm).toHaveAttribute("aria-busy", "true"));
110
+ expect(confirm).not.toBeDisabled();
111
+ expect(within(dialog).getByRole("button", { name: "Cancel" })).not.toBeDisabled();
112
+
113
+ await userEvent.click(confirm);
114
+ expect(canvas.queryByText("Skill deleted")).toBeNull();
115
+ },
116
+ };
117
+
118
+ function NeutralConfirm() {
119
+ const [open, setOpen] = useState(false);
120
+ const [published, setPublished] = useState(false);
121
+ return (
122
+ <>
123
+ <Button onClick={() => setOpen(true)}>Publish version</Button>
124
+ {published ? <Text variant="caption">Version 4 published</Text> : null}
125
+ <ConfirmDialog
126
+ open={open}
127
+ onOpenChange={setOpen}
128
+ title="Publish version 4?"
129
+ description="Everyone in the workspace gets the new prompt on their next run."
130
+ confirmLabel="Publish version"
131
+ onConfirm={() => {
132
+ setPublished(true);
133
+ setOpen(false);
134
+ }}
135
+ />
136
+ </>
137
+ );
138
+ }
139
+
140
+ const openNeutral: Story["play"] = async ({ canvas, canvasElement, userEvent }) => {
141
+ await userEvent.click(canvas.getByRole("button", { name: "Publish version" }));
142
+ const doc = within(canvasElement.ownerDocument.body);
143
+ const dialog = await doc.findByRole("alertdialog");
144
+ const cancel = within(dialog).getByRole("button", { name: "Cancel" });
145
+ await waitFor(() => expect(document.activeElement).toBe(cancel));
146
+ };
147
+
148
+ /** A neutral, non-destructive confirmation — same contract, calmer action. */
149
+ export const NeutralTone: Story = {
150
+ render: () => <NeutralConfirm />,
151
+ play: openNeutral,
152
+ };
153
+
154
+ /**
155
+ * The DESTRUCTIVE tone in dark — the second theme of the sweep, alongside
156
+ * light (`Default`).
157
+ *
158
+ * This story used to sweep the *neutral* tone here on purpose, because
159
+ * `--destructive-foreground` on `--destructive` measured 3.02:1 in dark —
160
+ * a token defect every `variant="destructive"` control in the theme shared,
161
+ * which this component could not fix. That token is now the theme's own
162
+ * warm-dark ink at 5.50:1 (#321), so the destructive tone finally gets a real
163
+ * dark sweep and the workaround is retired.
164
+ */
165
+ export const DestructiveDark: Story = {
166
+ name: "Destructive — dark",
167
+ decorators: [
168
+ (Story) => (
169
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
170
+ <Story />
171
+ </ThemeProvider>
172
+ ),
173
+ ],
174
+ render: () => <DestructiveConfirm />,
175
+ play: Default.play,
176
+ };
177
+
178
+ export const DestructiveHighDecoration: Story = {
179
+ name: "Destructive — high decoration",
180
+ globals: { decoration: "10" },
181
+ render: () => <DestructiveConfirm />,
182
+ play: Default.play,
183
+ };
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { ConfirmDialog } from "./confirm-dialog";
5
+ import { Button } from "../button";
6
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from "../dialog";
7
+
8
+ function open(props: Partial<React.ComponentProps<typeof ConfirmDialog>> = {}) {
9
+ const onConfirm = vi.fn();
10
+ const onOpenChange = vi.fn();
11
+ render(
12
+ <ConfirmDialog
13
+ open
14
+ onOpenChange={onOpenChange}
15
+ title="Delete this skill?"
16
+ description="Removing it also deletes its saved runs. This cannot be undone."
17
+ confirmLabel="Delete skill"
18
+ tone="destructive"
19
+ onConfirm={onConfirm}
20
+ {...props}
21
+ />,
22
+ );
23
+ return { onConfirm, onOpenChange };
24
+ }
25
+
26
+ describe("ConfirmDialog initial focus", () => {
27
+ // The headline AC: opening a confirmation must NOT put the keyboard on the
28
+ // destructive action. `AlertDialogContent` prevents its own open-autofocus and
29
+ // focuses the mounted Cancel — which is why ConfirmDialog always renders one.
30
+ it("lands focus on the safe action, not the destructive one", async () => {
31
+ open();
32
+ const cancel = await screen.findByRole("button", { name: "Cancel" });
33
+ await waitFor(() => expect(document.activeElement).toBe(cancel));
34
+ expect(document.activeElement).not.toBe(screen.getByRole("button", { name: "Delete skill" }));
35
+ });
36
+
37
+ // The negative twin — this documents WHY the named component exists. A plain
38
+ // `Dialog` focuses whatever comes first in the DOM, so a confirmation built
39
+ // from one is only ever safe by accident: reorder the footer and the keyboard
40
+ // now starts on the destructive action.
41
+ it("a plain Dialog focuses by DOM order — the destructive action when it is first", async () => {
42
+ render(
43
+ <Dialog open>
44
+ <DialogContent>
45
+ <DialogTitle>Delete this skill?</DialogTitle>
46
+ <DialogDescription>This cannot be undone.</DialogDescription>
47
+ <DialogFooter>
48
+ <Button variant="destructive">Delete skill</Button>
49
+ <Button variant="outline">Cancel</Button>
50
+ </DialogFooter>
51
+ </DialogContent>
52
+ </Dialog>,
53
+ );
54
+ const destructive = await screen.findByRole("button", { name: "Delete skill" });
55
+ await waitFor(() => expect(document.activeElement).toBe(destructive));
56
+ });
57
+
58
+ // …whereas ConfirmDialog cannot be mis-ordered: Cancel is always rendered, and
59
+ // Radix focuses IT rather than the first tabbable child.
60
+ it("keeps focus on Cancel even with extra focusable content above it", async () => {
61
+ open({
62
+ children: (
63
+ <a href="#docs" data-testid="learn-more">
64
+ What happens to my runs?
65
+ </a>
66
+ ),
67
+ });
68
+ const cancel = await screen.findByRole("button", { name: "Cancel" });
69
+ await waitFor(() => expect(document.activeElement).toBe(cancel));
70
+ });
71
+
72
+ it("uses role=alertdialog and is described by its description", async () => {
73
+ open();
74
+ const dialog = await screen.findByRole("alertdialog");
75
+ const describedBy = dialog.getAttribute("aria-describedby");
76
+ expect(describedBy).toBeTruthy();
77
+ expect(document.getElementById(describedBy!)?.textContent).toContain("cannot be undone");
78
+ });
79
+ });
80
+
81
+ describe("ConfirmDialog actions", () => {
82
+ it("fires onConfirm once and leaves closing to the app", async () => {
83
+ const user = userEvent.setup();
84
+ const { onConfirm, onOpenChange } = open();
85
+
86
+ await user.click(await screen.findByRole("button", { name: "Delete skill" }));
87
+
88
+ expect(onConfirm).toHaveBeenCalledTimes(1);
89
+ // The app owns `open` — confirming must not close behind its back, which is
90
+ // what makes an async `loading` state possible.
91
+ expect(onOpenChange).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it("cancel closes through onOpenChange(false) and calls onCancel", async () => {
95
+ const user = userEvent.setup();
96
+ const onCancel = vi.fn();
97
+ const { onOpenChange, onConfirm } = open({ onCancel });
98
+
99
+ await user.click(await screen.findByRole("button", { name: "Cancel" }));
100
+
101
+ expect(onCancel).toHaveBeenCalledTimes(1);
102
+ expect(onOpenChange).toHaveBeenCalledWith(false);
103
+ expect(onConfirm).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("Escape cancels", async () => {
107
+ const user = userEvent.setup();
108
+ const { onOpenChange, onConfirm } = open();
109
+ await screen.findByRole("alertdialog");
110
+
111
+ await user.keyboard("{Escape}");
112
+
113
+ expect(onOpenChange).toHaveBeenCalledWith(false);
114
+ expect(onConfirm).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("labels the confirm action with the consequence, defaulting to Confirm", async () => {
118
+ open({ confirmLabel: undefined });
119
+ expect(await screen.findByRole("button", { name: "Confirm" })).toBeInTheDocument();
120
+ });
121
+ });
122
+
123
+ describe("ConfirmDialog loading", () => {
124
+ it("shows a spinner, reports aria-busy and stays ENABLED", async () => {
125
+ open({ loading: true });
126
+ const confirm = await screen.findByRole("button", { name: /delete skill/i });
127
+
128
+ expect(confirm).toHaveAttribute("aria-busy", "true");
129
+ // interaction-guidelines (Forms): a submit is never pre-disabled.
130
+ expect(confirm).not.toBeDisabled();
131
+ expect(screen.getByRole("status", { name: "Loading…" })).toBeInTheDocument();
132
+ });
133
+
134
+ it("ignores repeat activations while the request is in flight", async () => {
135
+ const user = userEvent.setup();
136
+ const { onConfirm } = open({ loading: true });
137
+
138
+ await user.click(await screen.findByRole("button", { name: /delete skill/i }));
139
+
140
+ expect(onConfirm).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("never disables the safe action", async () => {
144
+ open({ loading: true });
145
+ expect(await screen.findByRole("button", { name: "Cancel" })).not.toBeDisabled();
146
+ });
147
+ });