@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,285 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import {
4
+ Dialog,
5
+ DialogBody,
6
+ DialogContent,
7
+ DialogSection,
8
+ DialogTitle,
9
+ DialogDescription,
10
+ } from "./dialog";
11
+ import { Label } from "../label";
12
+
13
+ function Wrapper({ size }: { size?: "sm" | "lg" | "xl" | "full" }) {
14
+ return (
15
+ <Dialog open>
16
+ <DialogContent size={size}>
17
+ <DialogTitle>Test dialog</DialogTitle>
18
+ <DialogDescription>Test description</DialogDescription>
19
+ Content
20
+ </DialogContent>
21
+ </Dialog>
22
+ );
23
+ }
24
+
25
+ describe("DialogContent size variants", () => {
26
+ it("default (no size prop) renders max-w-lg — regression lock", () => {
27
+ render(<Wrapper />);
28
+ const content = screen.getByRole("dialog");
29
+ expect(content.className).toContain("max-w-lg");
30
+ });
31
+
32
+ it("size='lg' renders max-w-lg", () => {
33
+ render(<Wrapper size="lg" />);
34
+ const content = screen.getByRole("dialog");
35
+ expect(content.className).toContain("max-w-lg");
36
+ });
37
+
38
+ it("size='sm' renders max-w-sm", () => {
39
+ render(<Wrapper size="sm" />);
40
+ const content = screen.getByRole("dialog");
41
+ expect(content.className).toContain("max-w-sm");
42
+ });
43
+
44
+ it("size='xl' renders max-w-3xl", () => {
45
+ render(<Wrapper size="xl" />);
46
+ const content = screen.getByRole("dialog");
47
+ expect(content.className).toContain("max-w-3xl");
48
+ });
49
+
50
+ it("size='full' renders max-w-[95vw] and h-[90vh]", () => {
51
+ render(<Wrapper size="full" />);
52
+ const content = screen.getByRole("dialog");
53
+ expect(content.className).toContain("max-w-[95vw]");
54
+ expect(content.className).toContain("h-[90vh]");
55
+ });
56
+ });
57
+
58
+ describe("DialogContent grid overflow guard (regression)", () => {
59
+ // The grid that lays out DialogContent's children must use a
60
+ // `minmax(0,1fr)` column, not the implicit `auto` column. Without the floor,
61
+ // a grid item's default `min-width:auto` lets a `truncate`/`nowrap` child or a
62
+ // long unbreakable token set the column's min-content size and push the dialog
63
+ // past its `max-w-*`. jsdom can't measure layout, so this locks the class that
64
+ // prevents it; the visual proof is the `OverflowGuard` Storybook story.
65
+ it("renders a minmax(0,1fr) grid column so truncate children can shrink", () => {
66
+ render(<Wrapper />);
67
+ const content = screen.getByRole("dialog");
68
+ expect(content.className).toContain("grid-cols-[minmax(0,1fr)]");
69
+ expect(content.className).toContain("grid");
70
+ });
71
+
72
+ it("keeps the minmax column on every size variant", () => {
73
+ for (const size of ["sm", "lg", "xl", "full"] as const) {
74
+ const { unmount } = render(<Wrapper size={size} />);
75
+ expect(screen.getByRole("dialog").className).toContain("grid-cols-[minmax(0,1fr)]");
76
+ unmount();
77
+ }
78
+ });
79
+ });
80
+
81
+ describe("DialogContent height/scroll regime (#341)", () => {
82
+ // jsdom does NO layout, so these are CLASS assertions and nothing more — they
83
+ // lock the contract that produces the behaviour. The behaviour itself (header
84
+ // and footer fixed, body scrolling, at a short viewport) is proven in the
85
+ // `WideWithSections` / `ShortViewport` stories, which run in a real browser.
86
+ it("caps at the viewport and scrolls itself when no DialogBody is present", () => {
87
+ render(<Wrapper />);
88
+ const content = screen.getByRole("dialog");
89
+ expect(content.className).toContain("max-h-[calc(100dvh-2rem)]");
90
+ expect(content.className).toContain("overflow-y-auto");
91
+ });
92
+
93
+ it("hands scroll to the body via a has-[] variant (inert without one)", () => {
94
+ render(<Wrapper />);
95
+ const content = screen.getByRole("dialog");
96
+ expect(content.className).toContain("has-[[data-slot=dialog-body]]:overflow-hidden");
97
+ expect(content.className).toContain(
98
+ "has-[[data-slot=dialog-body]]:grid-rows-[auto_minmax(0,1fr)_auto]",
99
+ );
100
+ });
101
+
102
+ it("size='full' keeps its own overflow/height after the merge", () => {
103
+ render(<Wrapper size="full" />);
104
+ const content = screen.getByRole("dialog");
105
+ // tailwind-merge resolves the base `overflow-y-auto` away in favour of the
106
+ // later variant utility, so `full` still lets its body own the scroll.
107
+ expect(content.className).toContain("overflow-hidden");
108
+ expect(content.className).not.toContain("overflow-y-auto");
109
+ expect(content.className).toContain("max-h-[90vh]");
110
+ expect(content.className).not.toContain("max-h-[calc(100dvh-2rem)]");
111
+ });
112
+ });
113
+
114
+ describe("DialogBody", () => {
115
+ it("declares the dialog-body slot and owns the scroll", () => {
116
+ render(
117
+ <Dialog open>
118
+ <DialogContent>
119
+ <DialogTitle>Test dialog</DialogTitle>
120
+ <DialogDescription>Test description</DialogDescription>
121
+ <DialogBody>Body</DialogBody>
122
+ </DialogContent>
123
+ </Dialog>,
124
+ );
125
+ const body = screen.getByText("Body");
126
+ expect(body.getAttribute("data-slot")).toBe("dialog-body");
127
+ expect(body.className).toContain("overflow-y-auto");
128
+ expect(body.className).toContain("min-h-0");
129
+ // Scroll must not chain to the page behind the overlay.
130
+ expect(body.className).toContain("overscroll-contain");
131
+ // Focus-ring gutter (#425): `p-1`/`-m-1`/`scroll-p-1` reserve 4px of
132
+ // scrollport padding so an outward `box-shadow` ring on a flush child is
133
+ // not clipped, then pull the border box back so body content stays flush
134
+ // with the header/footer. jsdom performs no layout and no paint, so this
135
+ // can only assert the classes are DECLARED — it can never observe a
136
+ // `box-shadow` actually being clipped (or not). Its job is to stop a
137
+ // future "tidy the class string" edit from silently deleting the trio;
138
+ // the real geometric proof is the `FocusRingClearance` story, run in a
139
+ // real browser via `@storybook/addon-vitest`.
140
+ expect(body.className).toContain("p-1");
141
+ expect(body.className).toContain("-m-1");
142
+ expect(body.className).toContain("scroll-p-1");
143
+ });
144
+
145
+ it("is keyboard-operable by default and lets a caller opt out", () => {
146
+ const { rerender } = render(
147
+ <Dialog open>
148
+ <DialogContent>
149
+ <DialogTitle>Test dialog</DialogTitle>
150
+ <DialogDescription>Test description</DialogDescription>
151
+ <DialogBody>Body</DialogBody>
152
+ </DialogContent>
153
+ </Dialog>,
154
+ );
155
+ expect(screen.getByText("Body")).toHaveAttribute("tabindex", "0");
156
+
157
+ rerender(
158
+ <Dialog open>
159
+ <DialogContent>
160
+ <DialogTitle>Test dialog</DialogTitle>
161
+ <DialogDescription>Test description</DialogDescription>
162
+ <DialogBody tabIndex={-1}>Body</DialogBody>
163
+ </DialogContent>
164
+ </Dialog>,
165
+ );
166
+ expect(screen.getByText("Body")).toHaveAttribute("tabindex", "-1");
167
+ });
168
+ });
169
+
170
+ describe("DialogBody opening focus", () => {
171
+ function Wrapped({ withBody }: { withBody: boolean }) {
172
+ const fields = (
173
+ <>
174
+ <label htmlFor="first">First</label>
175
+ <input id="first" />
176
+ </>
177
+ );
178
+ return (
179
+ <Dialog open>
180
+ <DialogContent>
181
+ <DialogTitle>Test dialog</DialogTitle>
182
+ <DialogDescription>Test description</DialogDescription>
183
+ {withBody ? <DialogBody>{fields}</DialogBody> : fields}
184
+ </DialogContent>
185
+ </Dialog>
186
+ );
187
+ }
188
+
189
+ // The body takes a tab stop so it can be scrolled from the keyboard; it must
190
+ // NOT therefore become the dialog's opening focus target — that would ring the
191
+ // whole scroll region on open and skip the first field.
192
+ it("skips the scroll wrapper and opens on the first real control", async () => {
193
+ render(<Wrapped withBody />);
194
+ const input = screen.getByLabelText("First");
195
+ await waitFor(() => expect(document.activeElement).toBe(input));
196
+ expect(document.activeElement).not.toBe(screen.getByText("First").closest("div"));
197
+ });
198
+
199
+ it("leaves a body-less dialog's focus behaviour untouched", async () => {
200
+ render(<Wrapped withBody={false} />);
201
+ await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("First")));
202
+ });
203
+
204
+ // A body with nothing focusable in it must fall back to Radix, which focuses
205
+ // the body — the right target for a text-only scroll region. Reaching past it
206
+ // to the footer would open the dialog with the keyboard on the PRIMARY action.
207
+ it("leaves a control-less body to Radix, which focuses the scroll region", async () => {
208
+ render(
209
+ <Dialog open>
210
+ <DialogContent>
211
+ <DialogTitle>Test dialog</DialogTitle>
212
+ <DialogDescription>Test description</DialogDescription>
213
+ <DialogBody>Nothing focusable in here.</DialogBody>
214
+ <button type="button">Save</button>
215
+ </DialogContent>
216
+ </Dialog>,
217
+ );
218
+ const body = screen.getByText("Nothing focusable in here.");
219
+ await waitFor(() => expect(document.activeElement).toBe(body));
220
+ expect(document.activeElement).not.toBe(screen.getByRole("button", { name: "Save" }));
221
+ });
222
+
223
+ // A candidate that matches the selector but cannot take focus (a `hidden` file
224
+ // input is the everyday case) must not consume the redirect: preventing
225
+ // Radix's default for it strands focus on the TRIGGER, outside the modal.
226
+ // NOT assertable here — jsdom does no layout and happily focuses a `hidden`
227
+ // input — so the lock is the `HiddenFirstControl` story, which runs in a real
228
+ // browser under `test-storybook`.
229
+ });
230
+
231
+ describe("DialogSection", () => {
232
+ function SectionWrapper() {
233
+ return (
234
+ <Dialog open>
235
+ <DialogContent>
236
+ <DialogTitle>Test dialog</DialogTitle>
237
+ <DialogDescription>Test description</DialogDescription>
238
+ <DialogSection title="Retention" description="How long runs are kept.">
239
+ <Label htmlFor="days">Days to keep</Label>
240
+ </DialogSection>
241
+ </DialogContent>
242
+ </Dialog>
243
+ );
244
+ }
245
+
246
+ it("renders a real heading one level below DialogTitle", () => {
247
+ render(<SectionWrapper />);
248
+ const heading = screen.getByRole("heading", { name: "Retention" });
249
+ expect(heading.tagName).toBe("H3");
250
+ expect(heading.getAttribute("data-slot")).toBe("dialog-section-title");
251
+ });
252
+
253
+ it("renders the section title one type ROLE above a field label", () => {
254
+ render(<SectionWrapper />);
255
+ const heading = screen.getByRole("heading", { name: "Retention" });
256
+ const label = screen.getByText("Days to keep");
257
+ // The rungs must differ: section = `subtitle` (1rem), label = body (0.875rem).
258
+ // jsdom applies no stylesheet, so this asserts the ROLE; the rendered pixel
259
+ // sizes are compared with getComputedStyle in the `WideWithSections` story.
260
+ expect(heading.className).toContain("text-subtitle");
261
+ expect(label.className).not.toContain("text-subtitle");
262
+ });
263
+
264
+ it("accepts a deeper level and slots its description and actions", () => {
265
+ render(
266
+ <Dialog open>
267
+ <DialogContent>
268
+ <DialogTitle>Test dialog</DialogTitle>
269
+ <DialogDescription>Test description</DialogDescription>
270
+ <DialogSection
271
+ level={4}
272
+ title="Retention"
273
+ description="How long runs are kept."
274
+ actions={<button type="button">Reset</button>}
275
+ />
276
+ </DialogContent>
277
+ </Dialog>,
278
+ );
279
+ expect(screen.getByRole("heading", { name: "Retention" }).tagName).toBe("H4");
280
+ expect(screen.getByText("How long runs are kept.").getAttribute("data-slot")).toBe(
281
+ "dialog-section-description",
282
+ );
283
+ expect(screen.getByRole("button", { name: "Reset" })).toBeInTheDocument();
284
+ });
285
+ });
@@ -0,0 +1,340 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useRef,
5
+ type ComponentPropsWithoutRef,
6
+ type ElementRef,
7
+ type HTMLAttributes,
8
+ type MutableRefObject,
9
+ type ReactNode,
10
+ } from "react";
11
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
12
+ import { cva, type VariantProps } from "class-variance-authority";
13
+ import { cn } from "../../lib/cn";
14
+ import { Heading, Text } from "../typography";
15
+
16
+ /**
17
+ * The first keyboard-reachable control inside a dialog, EXCLUDING the
18
+ * `DialogBody` wrapper itself. Deliberately a pragmatic selector rather than a
19
+ * full tabbability implementation — Radix owns focus trapping; this only
20
+ * reorders the opening target (see `DialogContent`'s `onOpenAutoFocus`).
21
+ */
22
+ const TABBABLE = [
23
+ "a[href]",
24
+ "button:not([disabled])",
25
+ "input:not([disabled])",
26
+ "select:not([disabled])",
27
+ "textarea:not([disabled])",
28
+ '[tabindex]:not([tabindex="-1"]):not([data-slot="dialog-body"])',
29
+ ].join(",");
30
+
31
+ export const dialogContentVariants = cva(
32
+ // `grid-cols-[minmax(0,1fr)]` (NOT the implicit `auto` column): grid items
33
+ // default to `min-width:auto`, so a `nowrap`/`truncate` child or a long
34
+ // unbreakable token (a file path, a URL) sets the column's min-content size and
35
+ // pushes the dialog past its `max-w-*`. A `minmax(0,1fr)` column floors that at
36
+ // 0, so inner `truncate`/`w-full` shrink instead of overflowing (#regression).
37
+ //
38
+ // Height/scroll (#341). Two mutually-exclusive regimes, picked by ANATOMY —
39
+ // never by a `scroll` prop (`.claude/rules/component-api.md`: `cva` is for
40
+ // visual axes, behaviour is composition):
41
+ // 1. NO `DialogBody` — the content box itself caps at the viewport
42
+ // (`max-h-…` + `overflow-y-auto`) and scrolls as one column. Before this,
43
+ // a taller-than-viewport dialog overflowed past BOTH viewport edges and
44
+ // its top was unreachable (the `-translate-y-1/2` centring).
45
+ // 2. WITH a `DialogBody` — the `has-[…]` pair turns the content box into a
46
+ // 3-row grid (`auto | minmax(0,1fr) | auto`) and stops it scrolling, so a
47
+ // fixed header and footer sandwich a body that owns the scroll. The pair
48
+ // is INERT for every dialog that has no `DialogBody`.
49
+ // `size="full"`'s own `h-/max-h-/overflow-hidden` still wins — cva emits the
50
+ // base first, so tailwind-merge resolves the later variant utilities over it.
51
+ "fixed left-1/2 top-1/2 z-50 grid grid-cols-[minmax(0,1fr)] w-full -translate-x-1/2 -translate-y-1/2 gap-4 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain has-[[data-slot=dialog-body]]:overflow-hidden has-[[data-slot=dialog-body]]:grid-rows-[auto_minmax(0,1fr)_auto] rounded-xl bg-card p-6 text-card-foreground shadow-ring-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
52
+ {
53
+ variants: {
54
+ size: {
55
+ sm: "max-w-sm",
56
+ lg: "max-w-lg",
57
+ xl: "max-w-3xl",
58
+ full: "max-w-[95vw] h-[90vh] max-h-[90vh] overflow-hidden",
59
+ },
60
+ },
61
+ defaultVariants: { size: "lg" },
62
+ },
63
+ );
64
+
65
+ export const Dialog = DialogPrimitive.Root;
66
+ export const DialogTrigger = DialogPrimitive.Trigger;
67
+ export const DialogPortal = DialogPrimitive.Portal;
68
+ export const DialogClose = DialogPrimitive.Close;
69
+
70
+ export const DialogOverlay = forwardRef<
71
+ ElementRef<typeof DialogPrimitive.Overlay>,
72
+ ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
73
+ >(function DialogOverlay({ className, ...props }, ref) {
74
+ return (
75
+ <DialogPrimitive.Overlay
76
+ ref={ref}
77
+ data-slot="dialog-overlay"
78
+ className={cn(
79
+ "fixed inset-0 z-50 bg-foreground/50 backdrop-blur-sm",
80
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
81
+ className,
82
+ )}
83
+ {...props}
84
+ />
85
+ );
86
+ });
87
+
88
+ export const DialogContent = forwardRef<
89
+ ElementRef<typeof DialogPrimitive.Content>,
90
+ ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
91
+ VariantProps<typeof dialogContentVariants>
92
+ >(function DialogContent({ className, children, size, onOpenAutoFocus, ...props }, ref) {
93
+ const contentRef = useRef<HTMLDivElement | null>(null);
94
+ const setRefs = useCallback(
95
+ (node: HTMLDivElement | null) => {
96
+ contentRef.current = node;
97
+ if (typeof ref === "function") ref(node);
98
+ else if (ref) (ref as MutableRefObject<HTMLDivElement | null>).current = node;
99
+ },
100
+ [ref],
101
+ );
102
+
103
+ /**
104
+ * A `DialogBody` carries `tabIndex={0}` so a keyboard user can scroll it — but
105
+ * being the first tab stop would also make the scroll WRAPPER the dialog's
106
+ * opening focus target, which paints a focus ring around the whole body on
107
+ * open and skips the first field. Skip past it to the first real control
108
+ * INSIDE the body. Inert for dialogs with no `DialogBody`, so nothing that
109
+ * shipped before changes.
110
+ *
111
+ * Two things this deliberately does NOT do, both because getting them wrong
112
+ * strands the keyboard:
113
+ * * It only prevents Radix's default once a candidate has *actually taken*
114
+ * focus. A selector match is not proof of focusability — a `display:none`
115
+ * candidate (the ubiquitous `<input type="file" hidden>`) swallows
116
+ * `.focus()`, so preventing the default for it would leave focus on the
117
+ * TRIGGER, outside the modal.
118
+ * * It gives up when the body holds no focusable control at all. Radix's own
119
+ * fallback then focuses the body — which is the right target for a
120
+ * text-only scroll region, and better than reaching past it to the
121
+ * footer's primary action.
122
+ */
123
+ const handleOpenAutoFocus = useCallback(
124
+ (event: Event) => {
125
+ onOpenAutoFocus?.(event);
126
+ if (event.defaultPrevented) return;
127
+ const body = contentRef.current?.querySelector<HTMLElement>('[data-slot="dialog-body"]');
128
+ if (!body) return;
129
+ for (const candidate of body.querySelectorAll<HTMLElement>(TABBABLE)) {
130
+ candidate.focus({ preventScroll: true });
131
+ if (candidate.ownerDocument.activeElement !== candidate) continue;
132
+ event.preventDefault();
133
+ return;
134
+ }
135
+ },
136
+ [onOpenAutoFocus],
137
+ );
138
+
139
+ return (
140
+ <DialogPortal>
141
+ <DialogOverlay />
142
+ <DialogPrimitive.Content
143
+ ref={setRefs}
144
+ data-slot="dialog-content"
145
+ className={cn(dialogContentVariants({ size }), className)}
146
+ onOpenAutoFocus={handleOpenAutoFocus}
147
+ {...props}
148
+ >
149
+ {children}
150
+ <DialogPrimitive.Close
151
+ data-slot="dialog-close"
152
+ className="absolute end-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
153
+ aria-label="Close"
154
+ >
155
+ <svg
156
+ width="16"
157
+ height="16"
158
+ viewBox="0 0 24 24"
159
+ fill="none"
160
+ stroke="currentColor"
161
+ strokeWidth="2"
162
+ strokeLinecap="round"
163
+ strokeLinejoin="round"
164
+ aria-hidden="true"
165
+ >
166
+ <path d="M18 6 6 18M6 6l12 12" />
167
+ </svg>
168
+ </DialogPrimitive.Close>
169
+ </DialogPrimitive.Content>
170
+ </DialogPortal>
171
+ );
172
+ });
173
+
174
+ export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
175
+ return (
176
+ <div
177
+ data-slot="dialog-header"
178
+ className={cn("flex flex-col gap-1.5 text-start", className)}
179
+ {...props}
180
+ />
181
+ );
182
+ }
183
+
184
+ export function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
185
+ return (
186
+ <div
187
+ data-slot="dialog-footer"
188
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
189
+ {...props}
190
+ />
191
+ );
192
+ }
193
+
194
+ export const DialogTitle = forwardRef<
195
+ ElementRef<typeof DialogPrimitive.Title>,
196
+ ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
197
+ >(function DialogTitle({ className, ...props }, ref) {
198
+ return (
199
+ <DialogPrimitive.Title
200
+ ref={ref}
201
+ data-slot="dialog-title"
202
+ className={cn("text-title leading-none", className)}
203
+ {...props}
204
+ />
205
+ );
206
+ });
207
+
208
+ export const DialogDescription = forwardRef<
209
+ ElementRef<typeof DialogPrimitive.Description>,
210
+ ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
211
+ >(function DialogDescription({ className, ...props }, ref) {
212
+ return (
213
+ <DialogPrimitive.Description
214
+ ref={ref}
215
+ data-slot="dialog-description"
216
+ className={cn("text-sm text-muted-foreground", className)}
217
+ {...props}
218
+ />
219
+ );
220
+ });
221
+
222
+ export type DialogBodyProps = HTMLAttributes<HTMLDivElement>;
223
+
224
+ /**
225
+ * The dialog's scroll owner (#341).
226
+ *
227
+ * Placing a `DialogBody` between `DialogHeader` and `DialogFooter` switches
228
+ * `DialogContent` into its fixed-header/footer regime (see
229
+ * `dialogContentVariants`): the header and footer stay put and ONLY this region
230
+ * scrolls, so the primary action never scrolls out of view. Without it a dialog
231
+ * behaves exactly as before — the anatomy is the switch, there is no `scroll`
232
+ * prop.
233
+ *
234
+ * `overscroll-contain` stops the scroll chaining to the page underneath
235
+ * (`.claude/rules/interaction-guidelines.md`, Touch & overscroll).
236
+ *
237
+ * **The `p-1`/`-m-1`/`scroll-p-1` trio reserves a focus-ring gutter (#425).**
238
+ * Per CSS Overflow 3, authoring only `overflow-y-auto` still makes this a
239
+ * two-axis scroll container (the `visible` axis computes to `auto` too), and a
240
+ * scroll container clips at its **padding box**. With no padding of its own,
241
+ * that padding box IS the content box, so a full-width child's border box
242
+ * lands exactly on the clip edge — leaving 0px for a focus ring, which is
243
+ * `box-shadow` (ink overflow: never part of the scrollable region, so it is
244
+ * simply cut, not merely scrolled out of reach). `p-1` moves the scrollport
245
+ * 4px outward on every side — enough for the library's widest outward paint
246
+ * (`ring-2 ring-offset-2` = 4px) — and `-m-1` pulls the border box back by the
247
+ * same amount so body content stays flush with `DialogHeader`/`DialogFooter`
248
+ * instead of indenting 4px against them. `scroll-p-1` keeps a keyboard-driven
249
+ * scroll-into-view (Tab landing on a control currently outside the scrollport)
250
+ * from placing that control flush against the scrollport edge again.
251
+ *
252
+ * Two consumer-facing consequences: a larger `className` padding (e.g. `p-6`)
253
+ * still wins through `tailwind-merge`, with the border box remaining 4px
254
+ * outward; but **`className="p-0"` re-opens the bug** — the merge kills `p-1`
255
+ * while `-m-1` survives — so a consumer who genuinely wants no gutter must
256
+ * clear both sides with `className="m-0 p-0"`.
257
+ *
258
+ * Known, accepted residual: at `data-density="compact"`, `--spacing` is
259
+ * `0.222rem` (`density.css:97`), so `p-1` is 3.55px against a 4px ring — a
260
+ * 0.45px shortfall on the outermost edge of a 2px stroke. Do not "fix" this
261
+ * with a fixed-px override; that would break the density dial's contract.
262
+ */
263
+ export const DialogBody = forwardRef<HTMLDivElement, DialogBodyProps>(function DialogBody(
264
+ { className, tabIndex, ...props },
265
+ ref,
266
+ ) {
267
+ return (
268
+ <div
269
+ ref={ref}
270
+ data-slot="dialog-body"
271
+ // A scroll container must be keyboard-operable (WCAG 2.1.1; axe's
272
+ // `scrollable-region-focusable`). A body holding only text has no focusable
273
+ // child to scroll from, so the region itself takes the tab stop. Pass
274
+ // `tabIndex={-1}` to opt out when the body always contains its own
275
+ // focusable content.
276
+ tabIndex={tabIndex ?? 0}
277
+ className={cn(
278
+ "-m-1 min-h-0 overflow-y-auto overscroll-contain p-1 scroll-p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
279
+ className,
280
+ )}
281
+ {...props}
282
+ />
283
+ );
284
+ });
285
+
286
+ export interface DialogSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
287
+ /** The section heading. States what the group of fields is for. */
288
+ title: ReactNode;
289
+ /** One line of supporting context under the heading. */
290
+ description?: ReactNode;
291
+ /**
292
+ * Semantic heading level. `DialogTitle` renders an `<h2>`, so a section is an
293
+ * `<h3>` by default and an `<h4>` when nested a rung deeper.
294
+ */
295
+ level?: 3 | 4;
296
+ /** Section-scoped actions, aligned to the inline end of the heading row. */
297
+ actions?: ReactNode;
298
+ }
299
+
300
+ /**
301
+ * A real section heading inside a dialog (#341).
302
+ *
303
+ * The recurring downstream defect was section labels rendering at the same size
304
+ * as the field labels beneath them, which flattens a long form into one
305
+ * undifferentiated list. The rungs are distinct BY TOKEN, not by eye:
306
+ * `DialogTitle` `text-title` (1.25rem) > `DialogSection` `text-subtitle` (1rem) >
307
+ * `Label` `text-body` (0.875rem).
308
+ *
309
+ * Deliberately NOT `SectionHeader` (`../section-header`): that is the page-level
310
+ * `<h2>` row with an eyebrow, one rung too loud and one level too high inside a
311
+ * dialog whose title already owns the `<h2>`.
312
+ */
313
+ export const DialogSection = forwardRef<HTMLElement, DialogSectionProps>(function DialogSection(
314
+ { className, title, description, level = 3, actions, children, ...props },
315
+ ref,
316
+ ) {
317
+ return (
318
+ <section
319
+ ref={ref}
320
+ data-slot="dialog-section"
321
+ className={cn("flex flex-col gap-3", className)}
322
+ {...props}
323
+ >
324
+ <div className="flex items-start justify-between gap-3">
325
+ <div className="flex min-w-0 flex-col gap-1">
326
+ <Heading level={level} size="subtitle" data-slot="dialog-section-title">
327
+ {title}
328
+ </Heading>
329
+ {description ? (
330
+ <Text variant="caption" tone="muted" data-slot="dialog-section-description">
331
+ {description}
332
+ </Text>
333
+ ) : null}
334
+ </div>
335
+ {actions ? <div className="shrink-0">{actions}</div> : null}
336
+ </div>
337
+ {children}
338
+ </section>
339
+ );
340
+ });
@@ -0,0 +1,18 @@
1
+ export {
2
+ dialogContentVariants,
3
+ Dialog,
4
+ DialogTrigger,
5
+ DialogPortal,
6
+ DialogClose,
7
+ DialogOverlay,
8
+ DialogContent,
9
+ DialogHeader,
10
+ DialogFooter,
11
+ DialogTitle,
12
+ DialogDescription,
13
+ DialogBody,
14
+ DialogSection,
15
+ } from "./dialog";
16
+ export type { DialogBodyProps, DialogSectionProps } from "./dialog";
17
+ export { useDialogDismissGuard } from "./use-dialog-dismiss-guard";
18
+ export type { UseDialogDismissGuardOptions, DialogDismissGuard } from "./use-dialog-dismiss-guard";