@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,80 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { useDialogDismissGuard } from "./use-dialog-dismiss-guard";
4
+
5
+ describe("useDialogDismissGuard", () => {
6
+ it("holds the dialog open and raises the confirmation when dirty", () => {
7
+ const onClose = vi.fn();
8
+ const { result } = renderHook(() => useDialogDismissGuard({ dirty: true, onClose }));
9
+
10
+ act(() => result.current.onOpenChange(false));
11
+
12
+ expect(result.current.confirmOpen).toBe(true);
13
+ expect(onClose).not.toHaveBeenCalled();
14
+ });
15
+
16
+ it("confirmDiscard() closes both the confirmation and the dialog", () => {
17
+ const onClose = vi.fn();
18
+ const { result } = renderHook(() => useDialogDismissGuard({ dirty: true, onClose }));
19
+
20
+ act(() => result.current.onOpenChange(false));
21
+ act(() => result.current.confirmDiscard());
22
+
23
+ expect(result.current.confirmOpen).toBe(false);
24
+ expect(onClose).toHaveBeenCalledTimes(1);
25
+ });
26
+
27
+ it("closes immediately and never confirms when not dirty", () => {
28
+ const onClose = vi.fn();
29
+ const { result } = renderHook(() => useDialogDismissGuard({ dirty: false, onClose }));
30
+
31
+ act(() => result.current.onOpenChange(false));
32
+
33
+ expect(onClose).toHaveBeenCalledTimes(1);
34
+ expect(result.current.confirmOpen).toBe(false);
35
+ });
36
+
37
+ it("never treats opening as a dismissal", () => {
38
+ const onClose = vi.fn();
39
+ const onOpen = vi.fn();
40
+ const { result } = renderHook(() => useDialogDismissGuard({ dirty: true, onClose, onOpen }));
41
+
42
+ act(() => result.current.onOpenChange(true));
43
+
44
+ expect(onOpen).toHaveBeenCalledTimes(1);
45
+ expect(onClose).not.toHaveBeenCalled();
46
+ expect(result.current.confirmOpen).toBe(false);
47
+ });
48
+
49
+ // The AC's asymmetry: a save path that closes the dialog by setting the app's
50
+ // own `open` to false never reaches the guard, so it cannot prompt. This test
51
+ // documents that it is structural — there is no "skip the guard" flag to get
52
+ // wrong — by asserting the hook stays inert across a dirty rerender.
53
+ it("is unobservable to a programmatic close (the save path)", () => {
54
+ const onClose = vi.fn();
55
+ const { result, rerender } = renderHook(
56
+ ({ dirty }) => useDialogDismissGuard({ dirty, onClose }),
57
+ { initialProps: { dirty: true } },
58
+ );
59
+
60
+ // The app saved, cleared its dirty flag and closed the dialog itself.
61
+ rerender({ dirty: false });
62
+
63
+ expect(onClose).not.toHaveBeenCalled();
64
+ expect(result.current.confirmOpen).toBe(false);
65
+ });
66
+
67
+ it("reads the CURRENT dirty value, not the one captured at mount", () => {
68
+ const onClose = vi.fn();
69
+ const { result, rerender } = renderHook(
70
+ ({ dirty }) => useDialogDismissGuard({ dirty, onClose }),
71
+ { initialProps: { dirty: true } },
72
+ );
73
+
74
+ rerender({ dirty: false });
75
+ act(() => result.current.onOpenChange(false));
76
+
77
+ expect(onClose).toHaveBeenCalledTimes(1);
78
+ expect(result.current.confirmOpen).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+
5
+ export interface UseDialogDismissGuardOptions {
6
+ /**
7
+ * Does the dialog currently hold unsaved changes? **App-owned** — brand-ui is
8
+ * a presentation layer (`docs/DECISIONS.md` §D5) and cannot know what "dirty"
9
+ * means for your form. Compute it (a `react-hook-form` `isDirty`, a deep
10
+ * compare, a store flag) and pass it in.
11
+ */
12
+ dirty: boolean;
13
+ /** Close the dialog — the app sets its own `open` state to `false`. */
14
+ onClose: () => void;
15
+ /**
16
+ * Open the dialog. Only needed when a `DialogTrigger` (rather than the app)
17
+ * opens it, because Radix routes that through the same `onOpenChange`.
18
+ */
19
+ onOpen?: () => void;
20
+ }
21
+
22
+ export interface DialogDismissGuard {
23
+ /**
24
+ * Spread onto `<Dialog onOpenChange={…}>`. Intercepts EVERY user dismissal —
25
+ * Escape, overlay click, the built-in close ✕ and any `DialogClose` — because
26
+ * Radix funnels all four through `onOpenChange(false)`.
27
+ */
28
+ onOpenChange: (open: boolean) => void;
29
+ /** Controlled `open` for the discard confirmation. */
30
+ confirmOpen: boolean;
31
+ /** Controlled `onOpenChange` for the discard confirmation. */
32
+ onConfirmOpenChange: (open: boolean) => void;
33
+ /** Confirm the discard: closes the confirmation AND calls `onClose()`. */
34
+ confirmDiscard: () => void;
35
+ }
36
+
37
+ /**
38
+ * Warn before a dialog with unsaved changes is dismissed (#341).
39
+ *
40
+ * `.claude/rules/interaction-guidelines.md` (Forms) requires warning before
41
+ * navigating away from unsaved changes; this is that rule for dialogs, as a HOOK
42
+ * rather than a prop because the component must not own app state — the app owns
43
+ * `open` and decides what `dirty` means (D5).
44
+ *
45
+ * ## The asymmetry is structural, not a flag
46
+ *
47
+ * A **user dismissal** reaches Radix's `onOpenChange`, so the guard sees it and
48
+ * can hold the dialog open. A **programmatic close** (the app setting its own
49
+ * `open` to `false` after a successful save) never touches the guard, so it is
50
+ * unobservable here and closes silently. That is exactly the wanted behaviour,
51
+ * and it falls out of the wiring — there is no "skip the guard" option to get
52
+ * wrong.
53
+ *
54
+ * The name is literal: it guards DISMISSALS routed through the returned
55
+ * `onOpenChange`. It is not a navigation blocker and it cannot intercept a tab
56
+ * close, a router navigation, or an `open` prop that the app flips itself.
57
+ *
58
+ * ```tsx
59
+ * const [open, setOpen] = useState(false);
60
+ * const guard = useDialogDismissGuard({
61
+ * dirty,
62
+ * onClose: () => setOpen(false),
63
+ * onOpen: () => setOpen(true),
64
+ * });
65
+ *
66
+ * <Dialog open={open} onOpenChange={guard.onOpenChange}>…</Dialog>
67
+ * <ConfirmDialog
68
+ * open={guard.confirmOpen}
69
+ * onOpenChange={guard.onConfirmOpenChange}
70
+ * onConfirm={guard.confirmDiscard}
71
+ * tone="destructive"
72
+ * title="Discard changes?"
73
+ * description="Your edits to this project have not been saved."
74
+ * confirmLabel="Discard"
75
+ * />
76
+ * ```
77
+ *
78
+ * The `ConfirmDialog` is a SIBLING of the guarded dialog, not a child of it.
79
+ */
80
+ export function useDialogDismissGuard({
81
+ dirty,
82
+ onClose,
83
+ onOpen,
84
+ }: UseDialogDismissGuardOptions): DialogDismissGuard {
85
+ const [confirmOpen, setConfirmOpen] = useState(false);
86
+
87
+ const onOpenChange = useCallback(
88
+ (open: boolean) => {
89
+ // Opening is never guarded; a `DialogTrigger` routes through here too.
90
+ if (open) {
91
+ onOpen?.();
92
+ return;
93
+ }
94
+ if (dirty) {
95
+ setConfirmOpen(true);
96
+ return;
97
+ }
98
+ onClose();
99
+ },
100
+ [dirty, onClose, onOpen],
101
+ );
102
+
103
+ const confirmDiscard = useCallback(() => {
104
+ setConfirmOpen(false);
105
+ onClose();
106
+ }, [onClose]);
107
+
108
+ return { onOpenChange, confirmOpen, onConfirmOpenChange: setConfirmOpen, confirmDiscard };
109
+ }
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor, within } from "storybook/test";
3
+ import { Button } from "../button";
4
+ import {
5
+ Drawer,
6
+ DrawerContent,
7
+ DrawerDescription,
8
+ DrawerHeader,
9
+ DrawerTitle,
10
+ DrawerTrigger,
11
+ } from "./drawer";
12
+ const meta = {
13
+ title: "Overlays/Drawer",
14
+ component: Drawer,
15
+ tags: ["autodocs"],
16
+ argTypes: {
17
+ open: {
18
+ description: "Controlled open state of the drawer.",
19
+ control: "boolean",
20
+ table: { category: "State" },
21
+ },
22
+ defaultOpen: {
23
+ description: "Uncontrolled initial open state.",
24
+ control: "boolean",
25
+ table: { category: "State" },
26
+ },
27
+ onOpenChange: {
28
+ description: "Callback when the open state changes.",
29
+ control: false,
30
+ table: { category: "Events" },
31
+ },
32
+ shouldScaleBackground: {
33
+ description: "Scale the background element behind the drawer when open. Default true.",
34
+ control: "boolean",
35
+ table: { category: "Behaviour" },
36
+ },
37
+ modal: {
38
+ description: "Whether the drawer is modal (disables interaction outside). Default true.",
39
+ control: "boolean",
40
+ table: { category: "Behaviour" },
41
+ },
42
+ direction: {
43
+ description: "Which edge the drawer slides in from.",
44
+ control: { type: "select" },
45
+ options: ["bottom", "top", "left", "right"],
46
+ table: { category: "Behaviour" },
47
+ },
48
+ },
49
+ } satisfies Meta<typeof Drawer>;
50
+ export default meta;
51
+ type Story = StoryObj<typeof meta>;
52
+ export const Default: Story = {
53
+ render: () => (
54
+ <Drawer>
55
+ <DrawerTrigger asChild>
56
+ <Button variant="outline">Open drawer</Button>
57
+ </DrawerTrigger>
58
+ <DrawerContent>
59
+ <DrawerHeader>
60
+ <DrawerTitle>Confirm</DrawerTitle>
61
+ <DrawerDescription>Swipe down to dismiss.</DrawerDescription>
62
+ </DrawerHeader>
63
+ </DrawerContent>
64
+ </Drawer>
65
+ ),
66
+ // Opens the drawer (portalled to body), confirms the title and description
67
+ // are visible, then presses Escape to dismiss it.
68
+ play: async ({ canvas, canvasElement, userEvent }) => {
69
+ await userEvent.click(canvas.getByRole("button", { name: /open drawer/i }));
70
+ const body = within(canvasElement.ownerDocument.body);
71
+ const drawer = await body.findByRole("dialog");
72
+ await waitFor(() => expect(within(drawer).getByText("Confirm")).toBeVisible());
73
+ await waitFor(() => expect(within(drawer).getByText("Swipe down to dismiss.")).toBeVisible());
74
+ await userEvent.keyboard("{Escape}");
75
+ },
76
+ };
@@ -0,0 +1,85 @@
1
+ import {
2
+ forwardRef,
3
+ type ComponentProps,
4
+ type ComponentPropsWithoutRef,
5
+ type ElementRef,
6
+ type HTMLAttributes,
7
+ } from "react";
8
+ import { Drawer as DrawerPrimitive } from "vaul";
9
+ import { cn } from "../../lib/cn";
10
+
11
+ export function Drawer({
12
+ shouldScaleBackground = true,
13
+ ...props
14
+ }: ComponentProps<typeof DrawerPrimitive.Root>) {
15
+ return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
16
+ }
17
+ export const DrawerTrigger = DrawerPrimitive.Trigger;
18
+ export const DrawerPortal = DrawerPrimitive.Portal;
19
+ export const DrawerClose = DrawerPrimitive.Close;
20
+
21
+ const DrawerOverlay = forwardRef<
22
+ ElementRef<typeof DrawerPrimitive.Overlay>,
23
+ ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
24
+ >(function DrawerOverlay({ className, ...props }, ref) {
25
+ return (
26
+ <DrawerPrimitive.Overlay
27
+ ref={ref}
28
+ className={cn("fixed inset-0 z-50 bg-foreground/50", className)}
29
+ {...props}
30
+ />
31
+ );
32
+ });
33
+
34
+ export const DrawerContent = forwardRef<
35
+ ElementRef<typeof DrawerPrimitive.Content>,
36
+ ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
37
+ >(function DrawerContent({ className, children, ...props }, ref) {
38
+ return (
39
+ <DrawerPortal>
40
+ <DrawerOverlay />
41
+ <DrawerPrimitive.Content
42
+ ref={ref}
43
+ className={cn(
44
+ "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col overscroll-contain rounded-t-xl border bg-card text-card-foreground",
45
+ className,
46
+ )}
47
+ {...props}
48
+ >
49
+ <div className="mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted" />
50
+ {children}
51
+ </DrawerPrimitive.Content>
52
+ </DrawerPortal>
53
+ );
54
+ });
55
+
56
+ export function DrawerHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
57
+ return <div className={cn("grid gap-1.5 p-4 text-center sm:text-start", className)} {...props} />;
58
+ }
59
+ export function DrawerFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
60
+ return <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
61
+ }
62
+ export const DrawerTitle = forwardRef<
63
+ ElementRef<typeof DrawerPrimitive.Title>,
64
+ ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
65
+ >(function DrawerTitle({ className, ...props }, ref) {
66
+ return (
67
+ <DrawerPrimitive.Title
68
+ ref={ref}
69
+ className={cn("text-lg font-semibold", className)}
70
+ {...props}
71
+ />
72
+ );
73
+ });
74
+ export const DrawerDescription = forwardRef<
75
+ ElementRef<typeof DrawerPrimitive.Description>,
76
+ ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
77
+ >(function DrawerDescription({ className, ...props }, ref) {
78
+ return (
79
+ <DrawerPrimitive.Description
80
+ ref={ref}
81
+ className={cn("text-sm text-muted-foreground", className)}
82
+ {...props}
83
+ />
84
+ );
85
+ });
@@ -0,0 +1,11 @@
1
+ export {
2
+ Drawer,
3
+ DrawerTrigger,
4
+ DrawerPortal,
5
+ DrawerClose,
6
+ DrawerContent,
7
+ DrawerHeader,
8
+ DrawerFooter,
9
+ DrawerTitle,
10
+ DrawerDescription,
11
+ } from "./drawer";
@@ -0,0 +1,176 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor, within } from "storybook/test";
3
+ import { useState } from "react";
4
+ import { ThemeProvider } from "@elabs-ai/components-tokens";
5
+ import { Button } from "../button";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuCheckboxItem,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuRadioGroup,
13
+ DropdownMenuRadioItem,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuSub,
16
+ DropdownMenuSubContent,
17
+ DropdownMenuSubTrigger,
18
+ DropdownMenuTrigger,
19
+ } from "./dropdown-menu";
20
+
21
+ const meta = {
22
+ title: "Overlays/DropdownMenu",
23
+ component: DropdownMenu,
24
+ tags: ["autodocs"],
25
+ } satisfies Meta<typeof DropdownMenu>;
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ export const Default: Story = {
30
+ render: () => (
31
+ <DropdownMenu>
32
+ <DropdownMenuTrigger asChild>
33
+ <Button variant="outline">Open menu</Button>
34
+ </DropdownMenuTrigger>
35
+ <DropdownMenuContent>
36
+ <DropdownMenuLabel>Account</DropdownMenuLabel>
37
+ <DropdownMenuItem>Profile</DropdownMenuItem>
38
+ <DropdownMenuItem>Billing</DropdownMenuItem>
39
+ <DropdownMenuSeparator />
40
+ <DropdownMenuItem>Sign out</DropdownMenuItem>
41
+ </DropdownMenuContent>
42
+ </DropdownMenu>
43
+ ),
44
+ // Opening the menu sets the trigger aria-expanded and mounts the menu items
45
+ // into the Radix portal (on document.body, outside the canvas).
46
+ play: async ({ canvas, canvasElement, userEvent }) => {
47
+ const trigger = canvas.getByRole("button", { name: /open menu/i });
48
+ await userEvent.click(trigger);
49
+ await expect(trigger).toHaveAttribute("aria-expanded", "true");
50
+ const body = within(canvasElement.ownerDocument.body);
51
+ const profile = await body.findByRole("menuitem", { name: "Profile" });
52
+ // waitFor rides out the entrance animation (opacity starts at 0).
53
+ await waitFor(() => expect(profile).toBeVisible());
54
+ },
55
+ };
56
+
57
+ /**
58
+ * The submenu / radio-group / checkbox-item parts (`DropdownMenuSub` +
59
+ * `DropdownMenuSubTrigger` + `DropdownMenuSubContent`, `DropdownMenuRadioGroup` +
60
+ * `DropdownMenuRadioItem`, `DropdownMenuCheckboxItem`) — the same building blocks
61
+ * `ContextMenu` already ships, mirrored here so `DropdownMenu` has them too (the
62
+ * iteration node-menu's "Change layout" submenu renders into either surface from
63
+ * one shared item list).
64
+ */
65
+ export const SubmenuRadioAndCheckbox: Story = {
66
+ name: "Submenu, radio group and checkbox item",
67
+ render: function SubmenuStory() {
68
+ const [bold, setBold] = useState(false);
69
+ const [align, setAlign] = useState("left");
70
+ return (
71
+ <DropdownMenu>
72
+ <DropdownMenuTrigger asChild>
73
+ <Button variant="outline">Format</Button>
74
+ </DropdownMenuTrigger>
75
+ <DropdownMenuContent>
76
+ <DropdownMenuCheckboxItem checked={bold} onCheckedChange={setBold}>
77
+ Bold
78
+ </DropdownMenuCheckboxItem>
79
+ <DropdownMenuSeparator />
80
+ <DropdownMenuSub>
81
+ <DropdownMenuSubTrigger>Align</DropdownMenuSubTrigger>
82
+ <DropdownMenuSubContent>
83
+ <DropdownMenuRadioGroup value={align} onValueChange={setAlign}>
84
+ <DropdownMenuRadioItem value="left">Left</DropdownMenuRadioItem>
85
+ <DropdownMenuRadioItem value="center">Center</DropdownMenuRadioItem>
86
+ <DropdownMenuRadioItem value="right">Right</DropdownMenuRadioItem>
87
+ </DropdownMenuRadioGroup>
88
+ </DropdownMenuSubContent>
89
+ </DropdownMenuSub>
90
+ </DropdownMenuContent>
91
+ </DropdownMenu>
92
+ );
93
+ },
94
+ // Opens the menu, toggles the checkbox item, reopens (selecting an item
95
+ // closes the menu by default), then hovers into the submenu (Radix's pointer
96
+ // path — the same precedent as the iteration node-menu's "Change layout"
97
+ // story) and picks a radio option.
98
+ play: async ({ canvas, canvasElement, userEvent }) => {
99
+ const trigger = canvas.getByRole("button", { name: /format/i });
100
+ const body = within(canvasElement.ownerDocument.body);
101
+
102
+ await userEvent.click(trigger);
103
+ const boldItem = await body.findByRole("menuitemcheckbox", { name: "Bold" });
104
+ await waitFor(() => expect(boldItem).toBeVisible());
105
+ await userEvent.click(boldItem);
106
+ // Selecting a checkbox item closes the menu (Radix default).
107
+ await waitFor(() => expect(body.queryByRole("menu")).toBeNull());
108
+
109
+ await userEvent.click(trigger);
110
+ const boldItem2 = await body.findByRole("menuitemcheckbox", { name: "Bold" });
111
+ await waitFor(() => expect(boldItem2).toHaveAttribute("aria-checked", "true"));
112
+
113
+ await userEvent.hover(body.getByText("Align"));
114
+ const centerOption = await body.findByRole(
115
+ "menuitemradio",
116
+ { name: "Center" },
117
+ { timeout: 8000 },
118
+ );
119
+ await waitFor(() => expect(centerOption).toBeVisible());
120
+ await userEvent.click(centerOption);
121
+ await waitFor(() => expect(body.queryByRole("menu")).toBeNull());
122
+
123
+ // Reopen once more to confirm the radio selection persisted.
124
+ await userEvent.click(trigger);
125
+ await userEvent.hover(body.getByText("Align"));
126
+ const centerOption2 = await body.findByRole(
127
+ "menuitemradio",
128
+ { name: "Center" },
129
+ { timeout: 8000 },
130
+ );
131
+ await waitFor(() => expect(centerOption2).toHaveAttribute("aria-checked", "true"));
132
+
133
+ // Escape dismisses the whole menu — a real behavioural assertion AND the
134
+ // reason this story is green (#386). A Radix MODAL menu marks the rest of
135
+ // the document `aria-hidden` while it is open; leaving it open meant axe
136
+ // saw focusable content inside an `aria-hidden` subtree
137
+ // (`aria-hidden-focus`), and the marker survived into the next story in
138
+ // this file, breaking its `queryByRole` lookups. Every menu story must
139
+ // hand the document back unmarked.
140
+ await userEvent.keyboard("{Escape}"); // submenu
141
+ await userEvent.keyboard("{Escape}"); // root menu
142
+ await waitFor(() => expect(body.queryByRole("menu")).toBeNull());
143
+ await waitFor(() =>
144
+ expect(canvasElement.ownerDocument.querySelector('[data-aria-hidden="true"]')).toBeNull(),
145
+ );
146
+ },
147
+ };
148
+
149
+ /**
150
+ * Three-theme sweep (a durable, `test-storybook`-run artifact rather than a
151
+ * one-off manual `preview.tsx` toggle). `<ThemeProvider>` (not a plain
152
+ * `data-theme` wrapper `<div>`) because it writes the attribute onto the
153
+ * DOCUMENT ROOT — this menu's Radix `Portal` mounts its content on
154
+ * `document.body`, outside any local wrapper, so only a document-level
155
+ * attribute actually themes it. `storageKey={null}` keeps the override from
156
+ * persisting to localStorage across runs.
157
+ */
158
+ export const SubmenuRadioAndCheckboxDark: Story = {
159
+ name: "Submenu, radio group and checkbox item — dark",
160
+ decorators: [
161
+ (Story) => (
162
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
163
+ <Story />
164
+ </ThemeProvider>
165
+ ),
166
+ ],
167
+ render: SubmenuRadioAndCheckbox.render,
168
+ play: SubmenuRadioAndCheckbox.play,
169
+ };
170
+
171
+ export const SubmenuRadioAndCheckboxHighDecoration: Story = {
172
+ name: "Submenu, radio group and checkbox item — high decoration",
173
+ globals: { decoration: "10" },
174
+ render: SubmenuRadioAndCheckbox.render,
175
+ play: SubmenuRadioAndCheckbox.play,
176
+ };
@@ -0,0 +1,109 @@
1
+ import { useState } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuCheckboxItem,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuRadioGroup,
11
+ DropdownMenuRadioItem,
12
+ DropdownMenuSub,
13
+ DropdownMenuSubContent,
14
+ DropdownMenuSubTrigger,
15
+ DropdownMenuTrigger,
16
+ } from "./dropdown-menu";
17
+
18
+ describe("DropdownMenu", () => {
19
+ it("opens content on trigger click and fires onSelect", async () => {
20
+ const onSelect = vi.fn();
21
+ render(
22
+ <DropdownMenu>
23
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
24
+ <DropdownMenuContent>
25
+ <DropdownMenuItem onSelect={onSelect}>Profile</DropdownMenuItem>
26
+ </DropdownMenuContent>
27
+ </DropdownMenu>,
28
+ );
29
+ await userEvent.click(screen.getByText("Open"));
30
+ const item = await screen.findByText("Profile");
31
+ await userEvent.click(item);
32
+ expect(onSelect).toHaveBeenCalled();
33
+ });
34
+
35
+ it("toggles a checkbox item's checked state (DropdownMenuCheckboxItem)", async () => {
36
+ function Harness() {
37
+ const [checked, setChecked] = useState(false);
38
+ return (
39
+ <DropdownMenu>
40
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
41
+ <DropdownMenuContent>
42
+ <DropdownMenuCheckboxItem checked={checked} onCheckedChange={setChecked}>
43
+ Bold
44
+ </DropdownMenuCheckboxItem>
45
+ </DropdownMenuContent>
46
+ </DropdownMenu>
47
+ );
48
+ }
49
+ render(<Harness />);
50
+
51
+ await userEvent.click(screen.getByText("Open"));
52
+ const item = await screen.findByRole("menuitemcheckbox", { name: "Bold" });
53
+ expect(item).toHaveAttribute("aria-checked", "false");
54
+
55
+ await userEvent.click(item);
56
+ // Selecting an item closes the menu (Radix default) — reopen to observe state.
57
+ await userEvent.click(screen.getByText("Open"));
58
+ const reopened = await screen.findByRole("menuitemcheckbox", { name: "Bold" });
59
+ expect(reopened).toHaveAttribute("aria-checked", "true");
60
+ });
61
+
62
+ it("a radio group (DropdownMenuRadioGroup + DropdownMenuRadioItem) selects one option", async () => {
63
+ function Harness() {
64
+ const [value, setValue] = useState("left");
65
+ return (
66
+ <DropdownMenu>
67
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
68
+ <DropdownMenuContent>
69
+ <DropdownMenuRadioGroup value={value} onValueChange={setValue}>
70
+ <DropdownMenuRadioItem value="left">Left</DropdownMenuRadioItem>
71
+ <DropdownMenuRadioItem value="center">Center</DropdownMenuRadioItem>
72
+ </DropdownMenuRadioGroup>
73
+ </DropdownMenuContent>
74
+ </DropdownMenu>
75
+ );
76
+ }
77
+ render(<Harness />);
78
+
79
+ await userEvent.click(screen.getByText("Open"));
80
+ const centerOption = await screen.findByRole("menuitemradio", { name: "Center" });
81
+ expect(centerOption).toHaveAttribute("aria-checked", "false");
82
+ await userEvent.click(centerOption);
83
+
84
+ await userEvent.click(screen.getByText("Open"));
85
+ const reopened = await screen.findByRole("menuitemradio", { name: "Center" });
86
+ expect(reopened).toHaveAttribute("aria-checked", "true");
87
+ });
88
+
89
+ it("a submenu (DropdownMenuSub + DropdownMenuSubTrigger + DropdownMenuSubContent) opens on hover", async () => {
90
+ render(
91
+ <DropdownMenu>
92
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
93
+ <DropdownMenuContent>
94
+ <DropdownMenuSub>
95
+ <DropdownMenuSubTrigger>More</DropdownMenuSubTrigger>
96
+ <DropdownMenuSubContent>
97
+ <DropdownMenuItem>Nested action</DropdownMenuItem>
98
+ </DropdownMenuSubContent>
99
+ </DropdownMenuSub>
100
+ </DropdownMenuContent>
101
+ </DropdownMenu>,
102
+ );
103
+
104
+ await userEvent.click(screen.getByText("Open"));
105
+ const subTrigger = await screen.findByText("More");
106
+ await userEvent.hover(subTrigger);
107
+ expect(await screen.findByText("Nested action")).toBeInTheDocument();
108
+ });
109
+ });