@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,728 @@
1
+ import { useState, type ReactNode } 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 { ConfirmDialog } from "../confirm-dialog";
7
+ import { Input } from "../input";
8
+ import { Label } from "../label";
9
+ import { Textarea } from "../textarea";
10
+ import { Text } from "../typography";
11
+ import {
12
+ Dialog,
13
+ DialogBody,
14
+ DialogClose,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogSection,
20
+ DialogTitle,
21
+ DialogTrigger,
22
+ } from "./dialog";
23
+ import { useDialogDismissGuard } from "./use-dialog-dismiss-guard";
24
+
25
+ const meta = { title: "Overlays/Dialog", component: Dialog, tags: ["autodocs"] } satisfies Meta<
26
+ typeof Dialog
27
+ >;
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ export const Default: Story = {
32
+ render: () => (
33
+ <Dialog>
34
+ <DialogTrigger asChild>
35
+ <Button>Open dialog</Button>
36
+ </DialogTrigger>
37
+ <DialogContent>
38
+ <DialogHeader>
39
+ <DialogTitle>Delete project</DialogTitle>
40
+ <DialogDescription>This action cannot be undone.</DialogDescription>
41
+ </DialogHeader>
42
+ <DialogFooter>
43
+ <DialogClose asChild>
44
+ <Button variant="outline">Cancel</Button>
45
+ </DialogClose>
46
+ <Button variant="destructive">Delete</Button>
47
+ </DialogFooter>
48
+ </DialogContent>
49
+ </Dialog>
50
+ ),
51
+ // Proves the trigger opens the Radix dialog into its portal (mounted on
52
+ // document.body, outside the story canvas) with the right title.
53
+ play: async ({ canvas, canvasElement, userEvent }) => {
54
+ await userEvent.click(canvas.getByRole("button", { name: /open dialog/i }));
55
+ const body = within(canvasElement.ownerDocument.body);
56
+ const dialog = await body.findByRole("dialog");
57
+ // waitFor rides out the entrance animation (opacity starts at 0).
58
+ await waitFor(() => expect(within(dialog).getByText("Delete project")).toBeVisible());
59
+ },
60
+ };
61
+
62
+ /**
63
+ * Overflow guard (regression): a `max-w-md` dialog holding a long, unbreakable
64
+ * path inside a `truncate` row. The `minmax(0,1fr)` grid column lets the row
65
+ * shrink and truncate, so the dialog must stay at `max-w-md` and never scroll
66
+ * horizontally. Before the fix the implicit `auto` column blew past `max-w-md`.
67
+ */
68
+ export const OverflowGuard: Story = {
69
+ render: () => (
70
+ <Dialog>
71
+ <DialogTrigger asChild>
72
+ <Button>Open settings</Button>
73
+ </DialogTrigger>
74
+ <DialogContent size="sm" className="max-w-md">
75
+ <DialogHeader>
76
+ <DialogTitle>Repository path</DialogTitle>
77
+ <DialogDescription>The dialog must not widen to fit this path.</DialogDescription>
78
+ </DialogHeader>
79
+ <div className="truncate rounded-md border border-border bg-surface-muted px-3 py-2 font-mono text-caption">
80
+ ~/Developer/workspaces/monorepo/packages/very-long-package-name/src/features/extremely-deeply-nested/configuration/settings.local.json
81
+ </div>
82
+ <DialogFooter>
83
+ <DialogClose asChild>
84
+ <Button variant="outline">Close</Button>
85
+ </DialogClose>
86
+ </DialogFooter>
87
+ </DialogContent>
88
+ </Dialog>
89
+ ),
90
+ // Real-surface proof: open the dialog, then assert it never overflows
91
+ // horizontally (scrollWidth must not exceed clientWidth). This is the check
92
+ // jsdom can't do — it needs a real layout engine.
93
+ play: async ({ canvas, canvasElement, userEvent }) => {
94
+ await userEvent.click(canvas.getByRole("button", { name: /open settings/i }));
95
+ const body = within(canvasElement.ownerDocument.body);
96
+ const dialog = await body.findByRole("dialog");
97
+ await waitFor(() => expect(within(dialog).getByText("Repository path")).toBeVisible());
98
+ // No horizontal overflow: the long path truncated instead of widening it.
99
+ expect(dialog.scrollWidth).toBeLessThanOrEqual(dialog.clientWidth + 1);
100
+ },
101
+ };
102
+
103
+ /**
104
+ * Opening focus, when the body's first selector match cannot actually take it.
105
+ * A `hidden` file input matches `input:not([disabled])` but swallows `.focus()`,
106
+ * so a redirect that trusted the selector would suppress Radix's own autofocus
107
+ * and leave the keyboard on the TRIGGER — outside the modal. jsdom cannot show
108
+ * this (it does no layout and focuses hidden inputs happily), so the lock lives
109
+ * here, in a real browser.
110
+ */
111
+ export const HiddenFirstControl: Story = {
112
+ render: () => (
113
+ <Dialog>
114
+ <DialogTrigger asChild>
115
+ <Button>Attach evidence</Button>
116
+ </DialogTrigger>
117
+ <DialogContent>
118
+ <DialogHeader>
119
+ <DialogTitle>Attach evidence</DialogTitle>
120
+ <DialogDescription>The file picker is opened from the button below.</DialogDescription>
121
+ </DialogHeader>
122
+ <DialogBody className="flex flex-col gap-4">
123
+ <input id="hidden-upload" name="hidden-upload" type="file" hidden aria-hidden="true" />
124
+ <Field id="evidence-note" label="Note" />
125
+ </DialogBody>
126
+ <DialogFooter>
127
+ <DialogClose asChild>
128
+ <Button variant="outline">Cancel</Button>
129
+ </DialogClose>
130
+ <Button>Attach</Button>
131
+ </DialogFooter>
132
+ </DialogContent>
133
+ </Dialog>
134
+ ),
135
+ play: async ({ canvas, canvasElement, userEvent }) => {
136
+ await userEvent.click(canvas.getByRole("button", { name: /attach evidence/i }));
137
+ const doc = within(canvasElement.ownerDocument.body);
138
+ const dialog = await doc.findByRole("dialog");
139
+ const note = await within(dialog).findByLabelText("Note");
140
+ await waitFor(() => expect(document.activeElement).toBe(note));
141
+ // The invariant that matters even if the target ever changes: opening focus
142
+ // never escapes the modal.
143
+ expect(dialog.contains(document.activeElement)).toBe(true);
144
+ },
145
+ };
146
+
147
+ // ───────────────────────────── shared fixtures ───────────────────────────────
148
+
149
+ function Field({ id, label, defaultValue }: { id: string; label: string; defaultValue?: string }) {
150
+ return (
151
+ <div className="flex flex-col gap-1.5">
152
+ <Label htmlFor={id}>{label}</Label>
153
+ <Input id={id} name={id} autoComplete="off" defaultValue={defaultValue} />
154
+ </div>
155
+ );
156
+ }
157
+
158
+ /** Enough fields that the body outgrows any plausible viewport. */
159
+ function RetentionFields({ prefix }: { prefix: string }) {
160
+ return (
161
+ <div className="flex flex-col gap-4">
162
+ {Array.from({ length: 6 }, (_, i) => (
163
+ <Field key={i} id={`${prefix}-${i}`} label={`Keep runs for stage ${i + 1} (days)`} />
164
+ ))}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function LongSettingsBody({ prefix }: { prefix: string }) {
170
+ return (
171
+ <>
172
+ <DialogSection title="Identity" description="How this pipeline is addressed by other jobs.">
173
+ <div className="flex flex-col gap-4">
174
+ <Field id={`${prefix}-name`} label="Pipeline name" defaultValue="nightly-ingest" />
175
+ <Field id={`${prefix}-owner`} label="Owner" defaultValue="data-platform" />
176
+ </div>
177
+ </DialogSection>
178
+ <DialogSection title="Retention" description="Storage cost is billed per stage.">
179
+ <RetentionFields prefix={`${prefix}-retention`} />
180
+ </DialogSection>
181
+ <DialogSection title="Alerting" description="Who hears about a failed run.">
182
+ <div className="flex flex-col gap-4">
183
+ {Array.from({ length: 6 }, (_, i) => (
184
+ <Field key={i} id={`${prefix}-alert-${i}`} label={`Escalation contact ${i + 1}`} />
185
+ ))}
186
+ </div>
187
+ </DialogSection>
188
+ </>
189
+ );
190
+ }
191
+
192
+ function WideSettingsDialog({
193
+ contentClassName,
194
+ prefix,
195
+ }: {
196
+ contentClassName?: string;
197
+ prefix: string;
198
+ }) {
199
+ return (
200
+ <Dialog>
201
+ <DialogTrigger asChild>
202
+ <Button>Edit pipeline</Button>
203
+ </DialogTrigger>
204
+ <DialogContent size="xl" className={contentClassName}>
205
+ <DialogHeader>
206
+ <DialogTitle>Pipeline settings</DialogTitle>
207
+ <DialogDescription>
208
+ Changes apply to the next scheduled run, not the one in progress.
209
+ </DialogDescription>
210
+ </DialogHeader>
211
+ <DialogBody className="flex flex-col gap-6">
212
+ <LongSettingsBody prefix={prefix} />
213
+ </DialogBody>
214
+ <DialogFooter>
215
+ <DialogClose asChild>
216
+ <Button variant="outline">Cancel</Button>
217
+ </DialogClose>
218
+ <Button>Save changes</Button>
219
+ </DialogFooter>
220
+ </DialogContent>
221
+ </Dialog>
222
+ );
223
+ }
224
+
225
+ const px = (el: Element) => Number.parseFloat(getComputedStyle(el).fontSize);
226
+
227
+ /**
228
+ * The entrance animation scales the content box (`zoom-in-95`), so a
229
+ * `getBoundingClientRect()` taken mid-flight is a TRANSFORMED rect and drifts by
230
+ * a couple of pixels as the animation lands. Wait it out before measuring in
231
+ * viewport space; the fixity assertions below additionally use `offsetTop`,
232
+ * which is layout and therefore transform-independent.
233
+ */
234
+ async function settled(el: HTMLElement) {
235
+ await waitFor(() =>
236
+ expect(el.getAnimations().filter((a) => a.playState === "running")).toHaveLength(0),
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Room between a focused child's border box and its scroll-container ancestor's
242
+ * SCROLLPORT (not its border box — `clientLeft`/`clientTop`/`clientWidth`/
243
+ * `clientHeight` exclude the border and a classic scrollbar, so a scrollbar's
244
+ * width is never mistaken for clearance). Regression fixture for #425.
245
+ */
246
+ const room = (child: HTMLElement, box: HTMLElement) => {
247
+ const c = child.getBoundingClientRect();
248
+ const b = box.getBoundingClientRect();
249
+ const l = b.left + box.clientLeft; // scrollport, so a classic
250
+ const t = b.top + box.clientTop; // scrollbar is not counted as room
251
+ return {
252
+ left: c.left - l,
253
+ right: l + box.clientWidth - c.right,
254
+ top: c.top - t,
255
+ bottom: t + box.clientHeight - c.bottom,
256
+ };
257
+ };
258
+
259
+ /**
260
+ * Regression lock for #425 — `DialogBody` clipping a flush child's outward
261
+ * focus ring. The body is a scroll container with no padding of its own, so
262
+ * its padding box IS its content box; a full-width child's border box then
263
+ * coincides with the scrollport edge, leaving no room for a `box-shadow` ring
264
+ * (ink overflow — never part of the scrollable region, so it is simply cut).
265
+ *
266
+ * `input` is the FIRST child with no label above it — the top/left/right worst
267
+ * case (identical to the true sole-child case for those three edges, since
268
+ * clipping is a property of the scrollport, not of what else shares the
269
+ * column). `button` below it exercises the library's widest outward paint
270
+ * (`ring-2 ring-offset-2` = 4px). The filler forces a real scrollbar, and
271
+ * scrolling to the very end before focusing `lastButton` exercises the
272
+ * BOTTOM edge at the opposite extreme of the scroll range.
273
+ *
274
+ * The `3.5` threshold: `comfortable` density yields exactly 4px of gutter
275
+ * (`p-1` = `calc(var(--spacing) * 1)`, `--spacing: 0.25rem`); `compact`
276
+ * density yields 3.55px (`--spacing: 0.222rem`, `density.css:97`) — a known,
277
+ * accepted sub-pixel shortfall on the outermost edge of a 2px stroke. The
278
+ * library's widest outward focus paint is `ring-2 ring-offset-2` = 4px, so
279
+ * 3.5 clears both densities with margin while still failing hard against the
280
+ * unfixed 0px baseline.
281
+ *
282
+ * Verified CO-RESIDENT with `DirtyGuard` below, on purpose: a prior attempt to
283
+ * isolate this story in its own file (on a since-refuted suspicion that it
284
+ * destabilized `DirtyGuard`) did not hold up under a clean re-test — 4/4
285
+ * consecutive runs green, `DirtyGuard` included. See
286
+ * `.claude/rules/component-api.md` ("Regression locks with observable side
287
+ * effects must be verified co-resident"), which this story now satisfies.
288
+ */
289
+ export const FocusRingClearance: Story = {
290
+ render: () => (
291
+ <Dialog>
292
+ <DialogTrigger asChild>
293
+ <Button>Open form</Button>
294
+ </DialogTrigger>
295
+ <DialogContent>
296
+ <DialogHeader>
297
+ <DialogTitle>Focus ring clearance</DialogTitle>
298
+ <DialogDescription>
299
+ Regression lock for #425 — a flush child&rsquo;s focus ring must never be clipped by the
300
+ body&rsquo;s scrollport.
301
+ </DialogDescription>
302
+ </DialogHeader>
303
+ <DialogBody className="flex flex-col gap-4">
304
+ <Input
305
+ id="ring-clearance-input"
306
+ name="ring-clearance-input"
307
+ aria-label="Sole-child input"
308
+ autoComplete="off"
309
+ className="w-full"
310
+ />
311
+ <Button className="w-full">Full-width action</Button>
312
+ {Array.from({ length: 20 }, (_, i) => (
313
+ <Text key={i} variant="caption">
314
+ Filler line {i + 1} — forces the body to scroll.
315
+ </Text>
316
+ ))}
317
+ <Button className="w-full">Last control</Button>
318
+ </DialogBody>
319
+ <DialogFooter>
320
+ <DialogClose asChild>
321
+ <Button variant="outline">Close</Button>
322
+ </DialogClose>
323
+ </DialogFooter>
324
+ </DialogContent>
325
+ </Dialog>
326
+ ),
327
+ play: async ({ canvas, canvasElement, userEvent }) => {
328
+ await userEvent.click(canvas.getByRole("button", { name: /open form/i }));
329
+ const doc = within(canvasElement.ownerDocument.body);
330
+ const dialog = await doc.findByRole("dialog");
331
+ await waitFor(() => expect(within(dialog).getByText("Focus ring clearance")).toBeVisible());
332
+ await settled(dialog);
333
+
334
+ const body = dialog.querySelector<HTMLElement>('[data-slot="dialog-body"]')!;
335
+ const header = dialog.querySelector<HTMLElement>('[data-slot="dialog-header"]')!;
336
+ const input = within(dialog).getByLabelText("Sole-child input") as HTMLInputElement;
337
+ const button = within(dialog).getByRole("button", { name: "Full-width action" });
338
+ const lastButton = within(dialog).getByRole("button", { name: "Last control" });
339
+
340
+ // 1. Sole-child-equivalent worst case (top/left/right).
341
+ input.focus();
342
+ await waitFor(() => expect(document.activeElement).toBe(input));
343
+ const inputRoom = room(input, body);
344
+ expect(inputRoom.left).toBeGreaterThanOrEqual(3.5);
345
+ expect(inputRoom.right).toBeGreaterThanOrEqual(3.5);
346
+ expect(inputRoom.top).toBeGreaterThanOrEqual(3.5);
347
+ expect(inputRoom.bottom).toBeGreaterThanOrEqual(3.5);
348
+
349
+ // 2. The library's widest outward paint (`ring-2 ring-offset-2` = 4px).
350
+ button.focus();
351
+ await waitFor(() => expect(document.activeElement).toBe(button));
352
+ const buttonRoom = room(button, body);
353
+ expect(buttonRoom.left).toBeGreaterThanOrEqual(3.5);
354
+ expect(buttonRoom.right).toBeGreaterThanOrEqual(3.5);
355
+ expect(buttonRoom.top).toBeGreaterThanOrEqual(3.5);
356
+ expect(buttonRoom.bottom).toBeGreaterThanOrEqual(3.5);
357
+
358
+ // 3. The scrolled arm (`scroll-p-1`): scroll to the very end, then focus
359
+ // the last control and re-assert clearance on the now-flush bottom edge.
360
+ body.scrollTop = body.scrollHeight;
361
+ await waitFor(() => expect(body.scrollTop).toBeGreaterThan(0));
362
+ lastButton.focus();
363
+ await waitFor(() => expect(document.activeElement).toBe(lastButton));
364
+ const lastRoom = room(lastButton, body);
365
+ expect(lastRoom.left).toBeGreaterThanOrEqual(3.5);
366
+ expect(lastRoom.right).toBeGreaterThanOrEqual(3.5);
367
+ expect(lastRoom.top).toBeGreaterThanOrEqual(3.5);
368
+ expect(lastRoom.bottom).toBeGreaterThanOrEqual(3.5);
369
+
370
+ // 4. Body content stays flush with the header despite the border-box
371
+ // pullback (the guard against re-doing this fix as padding-only).
372
+ expect(
373
+ Math.abs(input.getBoundingClientRect().left - header.getBoundingClientRect().left),
374
+ ).toBeLessThanOrEqual(0.5);
375
+
376
+ // 5. No new horizontal scrollbar introduced by the gutter.
377
+ expect(body.scrollWidth).toBeLessThanOrEqual(body.clientWidth + 1);
378
+ },
379
+ };
380
+
381
+ /**
382
+ * The clip is geometric and theme-invariant (it is a property of the box model,
383
+ * not of any token value) — but the ring's colour IS a token (`--ring`), so the
384
+ * acceptance criterion asks for the rendered result in both shipped themes.
385
+ * Re-runs `FocusRingClearance`'s own geometry assertions under `dark`,
386
+ * applied the way Storybook actually resolves a theme — a per-story `globals`
387
+ * override (`DecoratorHelpers.pluckThemeFromContext`, read by the root
388
+ * `withTheme` decorator in `preview.tsx`) — rather than a nested
389
+ * `ThemeProvider`, which does not win against that decorator and silently
390
+ * left an earlier version of this story rendering `light` under a
391
+ * "dark" name. `document.documentElement`'s `data-theme` is read back in
392
+ * `play` so a future regression of THIS story is caught rather than repeated.
393
+ */
394
+ export const FocusRingClearanceDark: Story = {
395
+ name: "Focus ring clearance — dark",
396
+ globals: { theme: "dark" },
397
+ render: FocusRingClearance.render,
398
+ play: async (context) => {
399
+ await waitFor(() => expect(document.documentElement.getAttribute("data-theme")).toBe("dark"));
400
+ await FocusRingClearance.play!(context);
401
+ },
402
+ };
403
+
404
+ /**
405
+ * The fixed-header/footer proof. Opens a deliberately long form, scrolls the
406
+ * `DialogBody` to the bottom and asserts that the header and footer have not
407
+ * moved — the primary action stays reachable without scrolling it out of view.
408
+ * It also compares the RENDERED font sizes of the three type rungs, so a section
409
+ * heading can never silently collapse onto the field-label rung.
410
+ */
411
+ export const WideWithSections: Story = {
412
+ render: () => <WideSettingsDialog prefix="wide" />,
413
+ play: async ({ canvas, canvasElement, userEvent }) => {
414
+ await userEvent.click(canvas.getByRole("button", { name: /edit pipeline/i }));
415
+ const doc = within(canvasElement.ownerDocument.body);
416
+ const dialog = await doc.findByRole("dialog");
417
+ await waitFor(() => expect(within(dialog).getByText("Pipeline settings")).toBeVisible());
418
+
419
+ const body = dialog.querySelector<HTMLElement>('[data-slot="dialog-body"]')!;
420
+ const header = dialog.querySelector<HTMLElement>('[data-slot="dialog-header"]')!;
421
+ const footer = dialog.querySelector<HTMLElement>('[data-slot="dialog-footer"]')!;
422
+
423
+ // The body — not the dialog — owns the overflow.
424
+ await waitFor(() => expect(body.scrollHeight).toBeGreaterThan(body.clientHeight + 8));
425
+ await settled(dialog);
426
+
427
+ const headerTop = header.offsetTop;
428
+ const footerTop = footer.offsetTop;
429
+ // The whole dialog, footer included, is inside the viewport before anyone
430
+ // scrolls anything.
431
+ expect(footer.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight + 1);
432
+ expect(footer.offsetTop + footer.offsetHeight).toBeLessThanOrEqual(dialog.clientHeight + 1);
433
+
434
+ body.scrollTop = body.scrollHeight;
435
+ await waitFor(() => expect(body.scrollTop).toBeGreaterThan(0));
436
+
437
+ expect(header.offsetTop).toBe(headerTop);
438
+ expect(footer.offsetTop).toBe(footerTop);
439
+ expect(dialog.scrollTop).toBe(0);
440
+ // Primary action still on screen after scrolling to the very bottom.
441
+ expect(within(footer).getByRole("button", { name: "Save changes" })).toBeVisible();
442
+
443
+ // Type rungs, measured — not inferred from class names (#341 AC).
444
+ const title = within(dialog).getByText("Pipeline settings");
445
+ const section = within(dialog).getByRole("heading", { name: "Retention" });
446
+ const label = within(dialog).getByText("Owner");
447
+ expect(px(title)).toBeGreaterThan(px(section));
448
+ expect(px(section)).toBeGreaterThan(px(label));
449
+ },
450
+ };
451
+
452
+ export const WideWithSectionsDark: Story = {
453
+ name: "Wide with sections — dark",
454
+ decorators: [
455
+ (Story) => (
456
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
457
+ <Story />
458
+ </ThemeProvider>
459
+ ),
460
+ ],
461
+ render: () => <WideSettingsDialog prefix="wide-dark" />,
462
+ play: WideWithSections.play,
463
+ };
464
+
465
+ export const WideWithSectionsHighDecoration: Story = {
466
+ name: "Wide with sections — high decoration",
467
+ globals: { decoration: "10" },
468
+ render: () => <WideSettingsDialog prefix="wide-decorated" />,
469
+ play: WideWithSections.play,
470
+ };
471
+
472
+ /**
473
+ * The same dialog at a phone-height viewport, simulated deterministically by
474
+ * capping the content box — the runner's window size then cannot flatter the
475
+ * result. Header and footer must still hold their ground.
476
+ */
477
+ export const ShortViewport: Story = {
478
+ render: () => <WideSettingsDialog prefix="short" contentClassName="max-h-[360px]" />,
479
+ play: async ({ canvas, canvasElement, userEvent }) => {
480
+ await userEvent.click(canvas.getByRole("button", { name: /edit pipeline/i }));
481
+ const doc = within(canvasElement.ownerDocument.body);
482
+ const dialog = await doc.findByRole("dialog");
483
+ await waitFor(() => expect(within(dialog).getByText("Pipeline settings")).toBeVisible());
484
+
485
+ const body = dialog.querySelector<HTMLElement>('[data-slot="dialog-body"]')!;
486
+ const footer = dialog.querySelector<HTMLElement>('[data-slot="dialog-footer"]')!;
487
+
488
+ await settled(dialog);
489
+ expect(dialog.getBoundingClientRect().height).toBeLessThanOrEqual(361);
490
+ await waitFor(() => expect(body.scrollHeight).toBeGreaterThan(body.clientHeight + 8));
491
+
492
+ const footerTop = footer.offsetTop;
493
+ expect(footer.offsetTop + footer.offsetHeight).toBeLessThanOrEqual(dialog.clientHeight + 1);
494
+ body.scrollTop = body.scrollHeight;
495
+ await waitFor(() => expect(body.scrollTop).toBeGreaterThan(0));
496
+ expect(footer.offsetTop).toBe(footerTop);
497
+ expect(dialog.scrollTop).toBe(0);
498
+ },
499
+ };
500
+
501
+ // ─────────────────────────────── form recipe ─────────────────────────────────
502
+
503
+ function RenameForm() {
504
+ const [saved, setSaved] = useState<string | null>(null);
505
+ return (
506
+ <Dialog>
507
+ <DialogTrigger asChild>
508
+ <Button>Rename dataset</Button>
509
+ </DialogTrigger>
510
+ <DialogContent>
511
+ {/* The recipe: the body IS a real <form>, so Enter submits and the
512
+ browser's own validation applies. No wrapper component needed — a
513
+ short form has nothing to scroll, so it takes no DialogBody. */}
514
+ <form
515
+ className="grid gap-4"
516
+ onSubmit={(event) => {
517
+ event.preventDefault();
518
+ setSaved(new FormData(event.currentTarget).get("dataset-name") as string);
519
+ }}
520
+ >
521
+ <DialogHeader>
522
+ <DialogTitle>Rename dataset</DialogTitle>
523
+ <DialogDescription>
524
+ References to the old name keep working for 30 days.
525
+ </DialogDescription>
526
+ </DialogHeader>
527
+ <Field id="dataset-name" label="Dataset name" defaultValue="orders_raw" />
528
+ {saved ? <Text variant="caption">Saved as {saved}</Text> : null}
529
+ <DialogFooter>
530
+ <DialogClose asChild>
531
+ <Button variant="outline" type="button">
532
+ Cancel
533
+ </Button>
534
+ </DialogClose>
535
+ <Button type="submit">Rename</Button>
536
+ </DialogFooter>
537
+ </form>
538
+ </DialogContent>
539
+ </Dialog>
540
+ );
541
+ }
542
+
543
+ /** A short form: no internal scroll, and Enter submits because the body is a real `<form>`. */
544
+ export const FormRecipe: Story = {
545
+ render: () => <RenameForm />,
546
+ play: async ({ canvas, canvasElement, userEvent }) => {
547
+ await userEvent.click(canvas.getByRole("button", { name: /rename dataset/i }));
548
+ const doc = within(canvasElement.ownerDocument.body);
549
+ const dialog = await doc.findByRole("dialog");
550
+ const input = await within(dialog).findByLabelText("Dataset name");
551
+
552
+ await userEvent.clear(input);
553
+ await userEvent.type(input, "orders_clean{Enter}");
554
+
555
+ await waitFor(() => expect(within(dialog).getByText("Saved as orders_clean")).toBeVisible());
556
+ // A short form never grows its own scrollbar.
557
+ expect(dialog.scrollHeight).toBeLessThanOrEqual(dialog.clientHeight + 1);
558
+ },
559
+ };
560
+
561
+ // ──────────────────────────────── workbench ──────────────────────────────────
562
+
563
+ /** `size="full"` with the body owning a split pane — the editor + result shape. */
564
+ export const WorkbenchShell: Story = {
565
+ render: () => (
566
+ <Dialog>
567
+ <DialogTrigger asChild>
568
+ <Button>Open query workbench</Button>
569
+ </DialogTrigger>
570
+ <DialogContent size="full">
571
+ <DialogHeader>
572
+ <DialogTitle>Query workbench</DialogTitle>
573
+ <DialogDescription>Draft a query and preview its result before saving.</DialogDescription>
574
+ </DialogHeader>
575
+ <DialogBody className="grid gap-4 md:grid-cols-2">
576
+ <DialogSection title="Query" description="SQL runs against the staging warehouse.">
577
+ <Label htmlFor="workbench-sql">Statement</Label>
578
+ <Textarea
579
+ id="workbench-sql"
580
+ name="workbench-sql"
581
+ rows={12}
582
+ spellCheck={false}
583
+ className="font-mono text-code"
584
+ defaultValue={"select order_id, total\nfrom orders_raw\nwhere total > 100"}
585
+ />
586
+ </DialogSection>
587
+ <DialogSection title="Preview" description="First 3 rows, refreshed on run.">
588
+ <div className="flex flex-col gap-2 font-mono text-code text-muted-foreground">
589
+ <span>1041 · 128.00</span>
590
+ <span>1042 · 210.50</span>
591
+ <span>1043 · 104.25</span>
592
+ </div>
593
+ </DialogSection>
594
+ </DialogBody>
595
+ <DialogFooter>
596
+ <DialogClose asChild>
597
+ <Button variant="outline">Discard</Button>
598
+ </DialogClose>
599
+ <Button>Save query</Button>
600
+ </DialogFooter>
601
+ </DialogContent>
602
+ </Dialog>
603
+ ),
604
+ play: async ({ canvas, canvasElement, userEvent }) => {
605
+ await userEvent.click(canvas.getByRole("button", { name: /open query workbench/i }));
606
+ const doc = within(canvasElement.ownerDocument.body);
607
+ const dialog = await doc.findByRole("dialog");
608
+ await waitFor(() => expect(within(dialog).getByText("Query workbench")).toBeVisible());
609
+ const footer = dialog.querySelector<HTMLElement>('[data-slot="dialog-footer"]')!;
610
+ // 90vh tall, and the footer is inside it rather than off the bottom.
611
+ expect(footer.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight + 1);
612
+ expect(dialog.scrollTop).toBe(0);
613
+ },
614
+ };
615
+
616
+ // ─────────────────────────────── dirty guard ─────────────────────────────────
617
+
618
+ function GuardedDialog({ children }: { children?: ReactNode }) {
619
+ const [open, setOpen] = useState(false);
620
+ const [name, setName] = useState("orders_raw");
621
+ const dirty = name !== "orders_raw";
622
+
623
+ const guard = useDialogDismissGuard({
624
+ dirty,
625
+ onClose: () => setOpen(false),
626
+ onOpen: () => setOpen(true),
627
+ });
628
+
629
+ function save() {
630
+ // The save path closes the dialog itself, so it never reaches the guard —
631
+ // that asymmetry is structural, not a flag.
632
+ setName("orders_raw");
633
+ setOpen(false);
634
+ }
635
+
636
+ return (
637
+ <>
638
+ <Dialog open={open} onOpenChange={guard.onOpenChange}>
639
+ <DialogTrigger asChild>
640
+ <Button>Edit dataset</Button>
641
+ </DialogTrigger>
642
+ <DialogContent>
643
+ <DialogHeader>
644
+ <DialogTitle>Edit dataset</DialogTitle>
645
+ <DialogDescription>Escape closes — unless there is unsaved work.</DialogDescription>
646
+ </DialogHeader>
647
+ <div className="flex flex-col gap-1.5">
648
+ <Label htmlFor="guarded-name">Dataset name</Label>
649
+ <Input
650
+ id="guarded-name"
651
+ name="guarded-name"
652
+ autoComplete="off"
653
+ value={name}
654
+ onChange={(event) => setName(event.target.value)}
655
+ />
656
+ </div>
657
+ {children}
658
+ <DialogFooter>
659
+ <Button variant="outline" onClick={() => guard.onOpenChange(false)}>
660
+ Cancel
661
+ </Button>
662
+ <Button onClick={save}>Save</Button>
663
+ </DialogFooter>
664
+ </DialogContent>
665
+ </Dialog>
666
+ <ConfirmDialog
667
+ open={guard.confirmOpen}
668
+ onOpenChange={guard.onConfirmOpenChange}
669
+ tone="destructive"
670
+ title="Discard changes?"
671
+ description="Your edits to this dataset have not been saved."
672
+ confirmLabel="Discard"
673
+ onConfirm={() => {
674
+ setName("orders_raw"); // discarding reverts the edits…
675
+ guard.confirmDiscard(); // …and the guard closes both surfaces.
676
+ }}
677
+ />
678
+ </>
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Escape with unsaved changes raises a discard confirmation; a programmatic
684
+ * close after a successful save does not. The dirty guard is a hook, not a prop,
685
+ * because the component must not own app state (D5).
686
+ */
687
+ export const DirtyGuard: Story = {
688
+ render: () => <GuardedDialog />,
689
+ play: async ({ canvas, canvasElement, userEvent }) => {
690
+ const doc = within(canvasElement.ownerDocument.body);
691
+
692
+ // 1. Dirty + Escape → the discard confirmation, dialog stays open.
693
+ await userEvent.click(canvas.getByRole("button", { name: /edit dataset/i }));
694
+ const dialog = await doc.findByRole("dialog");
695
+ const input = await within(dialog).findByLabelText("Dataset name");
696
+ await userEvent.type(input, "_v2");
697
+ await userEvent.keyboard("{Escape}");
698
+
699
+ const confirm = await doc.findByRole("alertdialog");
700
+ await waitFor(() => expect(within(confirm).getByText("Discard changes?")).toBeVisible());
701
+ // The guarded dialog is still mounted underneath. It is queried by slot, not
702
+ // by role, because Radix marks everything outside the top layer
703
+ // `aria-hidden` while the alert dialog owns the screen.
704
+ expect(
705
+ canvasElement.ownerDocument.querySelector('[data-slot="dialog-content"]'),
706
+ ).toBeInTheDocument();
707
+ // Nested focus scopes: the confirmation still lands on its safe action.
708
+ const discardCancel = within(confirm).getByRole("button", { name: "Cancel" });
709
+ await waitFor(() => expect(document.activeElement).toBe(discardCancel));
710
+
711
+ // 2. Confirming the discard closes both surfaces.
712
+ await userEvent.click(within(confirm).getByRole("button", { name: "Discard" }));
713
+ await waitFor(() => expect(doc.queryByRole("alertdialog")).toBeNull());
714
+ await waitFor(() =>
715
+ expect(canvasElement.ownerDocument.querySelector('[data-slot="dialog-content"]')).toBeNull(),
716
+ );
717
+
718
+ // 3. Save → closes with NO discard confirmation.
719
+ await userEvent.click(canvas.getByRole("button", { name: /edit dataset/i }));
720
+ const reopened = await doc.findByRole("dialog");
721
+ await userEvent.type(await within(reopened).findByLabelText("Dataset name"), "_v3");
722
+ await userEvent.click(within(reopened).getByRole("button", { name: "Save" }));
723
+ await waitFor(() =>
724
+ expect(canvasElement.ownerDocument.querySelector('[data-slot="dialog-content"]')).toBeNull(),
725
+ );
726
+ expect(doc.queryByRole("alertdialog")).toBeNull();
727
+ },
728
+ };