@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,617 @@
1
+ import { createRef, useState } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import {
6
+ MentionInput,
7
+ MentionInputContent,
8
+ MentionInputEmpty,
9
+ MentionInputItem,
10
+ MentionInputList,
11
+ MentionInputTextarea,
12
+ type MentionOption,
13
+ type MentionValue,
14
+ } from "./mention-input";
15
+
16
+ const ROSTER: MentionOption[] = [
17
+ { id: "u1", label: "Ada Lovelace", keywords: ["math"] },
18
+ { id: "u2", label: "Grace Hopper" },
19
+ { id: "u3", label: "Alan Turing", disabled: true },
20
+ { id: "u4", label: "Katherine Johnson" },
21
+ ];
22
+
23
+ /** The full compound arrangement, exactly as a consumer composes it. */
24
+ function Harness({
25
+ options = ROSTER,
26
+ ...props
27
+ }: Partial<React.ComponentProps<typeof MentionInput>> & { options?: MentionOption[] }) {
28
+ return (
29
+ <MentionInput options={options} {...props}>
30
+ <MentionInputTextarea aria-label="Comment" />
31
+ <MentionInputContent>
32
+ <MentionInputList>
33
+ {(option) => (
34
+ <MentionInputItem key={option.id} option={option}>
35
+ {option.label}
36
+ </MentionInputItem>
37
+ )}
38
+ </MentionInputList>
39
+ <MentionInputEmpty />
40
+ </MentionInputContent>
41
+ </MentionInput>
42
+ );
43
+ }
44
+
45
+ const field = () => screen.getByRole("textbox", { name: "Comment" }) as HTMLTextAreaElement;
46
+
47
+ /** Put the caret at `index` and let React observe the selection change. */
48
+ async function setCaret(el: HTMLTextAreaElement, index: number) {
49
+ const user = userEvent.setup();
50
+ await user.click(el);
51
+ el.setSelectionRange(index, index);
52
+ // `setSelectionRange` fires no event; `select` is what React listens for.
53
+ el.dispatchEvent(new Event("select", { bubbles: true }));
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // T3 — trigger boundary
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe("T3 trigger boundary", () => {
61
+ it("opens the popup for a trigger at index 0", async () => {
62
+ const user = userEvent.setup();
63
+ render(<Harness />);
64
+ await user.type(field(), "@");
65
+ expect(field()).toHaveAttribute("data-state", "open");
66
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
67
+ });
68
+
69
+ it("opens the popup for a trigger after a space", async () => {
70
+ const user = userEvent.setup();
71
+ render(<Harness />);
72
+ await user.type(field(), "hello @");
73
+ expect(field()).toHaveAttribute("data-state", "open");
74
+ });
75
+
76
+ it("does NOT open for a trigger mid-word (an email address)", async () => {
77
+ const user = userEvent.setup();
78
+ render(<Harness />);
79
+ await user.type(field(), "ada@");
80
+ expect(field()).toHaveAttribute("data-state", "closed");
81
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
82
+ });
83
+
84
+ it("closes again once the query runs into whitespace", async () => {
85
+ const user = userEvent.setup();
86
+ render(<Harness />);
87
+ await user.type(field(), "@ada");
88
+ expect(field()).toHaveAttribute("data-state", "open");
89
+ await user.type(field(), " ");
90
+ expect(field()).toHaveAttribute("data-state", "closed");
91
+ });
92
+
93
+ it("filters the roster as the query grows", async () => {
94
+ const user = userEvent.setup();
95
+ render(<Harness />);
96
+ await user.type(field(), "@grace");
97
+ const options = screen.getAllByRole("option");
98
+ expect(options).toHaveLength(1);
99
+ expect(options[0]).toHaveTextContent("Grace Hopper");
100
+ });
101
+
102
+ it("shows the empty state when nothing matches", async () => {
103
+ const user = userEvent.setup();
104
+ render(<Harness />);
105
+ await user.type(field(), "@zzzz");
106
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
107
+ expect(screen.getByText("No results.")).toBeInTheDocument();
108
+ });
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // T4 — chip atomicity (the headline acceptance criterion)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("T4 chip atomicity", () => {
116
+ it("Backspace with the caret right after a mention removes the WHOLE token", async () => {
117
+ const user = userEvent.setup();
118
+ const onValueChange = vi.fn();
119
+ render(<Harness onValueChange={onValueChange} />);
120
+
121
+ await user.type(field(), "@ada");
122
+ await user.keyboard("{Enter}");
123
+ expect(field().value).toBe("@Ada Lovelace ");
124
+
125
+ // Caret currently sits after the trailing space; step back onto the token end.
126
+ await user.keyboard("{Backspace}"); // eats the trailing space
127
+ expect(field().value).toBe("@Ada Lovelace");
128
+
129
+ await user.keyboard("{Backspace}"); // must eat the entire token, not one char
130
+ expect(field().value).toBe("");
131
+ expect(onValueChange).toHaveBeenLastCalledWith({ text: "", mentions: [] });
132
+ });
133
+
134
+ it("Delete with the caret right before a mention removes the WHOLE token", async () => {
135
+ const user = userEvent.setup();
136
+ render(<Harness />);
137
+ await user.type(field(), "@ada");
138
+ await user.keyboard("{Enter}");
139
+ expect(field().value).toBe("@Ada Lovelace ");
140
+
141
+ await setCaret(field(), 0);
142
+ await user.keyboard("{Delete}");
143
+ expect(field().value).toBe(" ");
144
+ });
145
+
146
+ it("ArrowLeft from the end of a mention lands on its START boundary, never inside", async () => {
147
+ const user = userEvent.setup();
148
+ render(<Harness />);
149
+ await user.type(field(), "@ada");
150
+ await user.keyboard("{Enter}");
151
+ // "@Ada Lovelace " — token is [0, 13), caret is at 14.
152
+ await setCaret(field(), 13);
153
+ await user.keyboard("{ArrowLeft}");
154
+ expect(field().selectionStart).toBe(0);
155
+ });
156
+
157
+ it("ArrowRight from the start of a mention lands on its END boundary", async () => {
158
+ const user = userEvent.setup();
159
+ render(<Harness />);
160
+ await user.type(field(), "@ada");
161
+ await user.keyboard("{Enter}");
162
+ await setCaret(field(), 0);
163
+ await user.keyboard("{ArrowRight}");
164
+ expect(field().selectionStart).toBe(13);
165
+ });
166
+
167
+ it("editing INSIDE a mention drops it from the value", async () => {
168
+ const user = userEvent.setup();
169
+ const onValueChange = vi.fn();
170
+ render(<Harness onValueChange={onValueChange} />);
171
+ await user.type(field(), "@ada");
172
+ await user.keyboard("{Enter}");
173
+ expect(onValueChange).toHaveBeenLastCalledWith({
174
+ text: "@Ada Lovelace ",
175
+ mentions: [{ id: "u1", label: "Ada Lovelace", start: 0 }],
176
+ });
177
+
178
+ // Type a character into the middle of the label.
179
+ await setCaret(field(), 6);
180
+ await user.keyboard("x");
181
+ expect(onValueChange).toHaveBeenLastCalledWith({ text: "@Ada Lxovelace ", mentions: [] });
182
+ });
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // T5 — keyboard model
187
+ // ---------------------------------------------------------------------------
188
+
189
+ describe("T5 keyboard", () => {
190
+ it("ArrowDown / ArrowUp move aria-activedescendant, wrapping and skipping disabled", async () => {
191
+ const user = userEvent.setup();
192
+ render(<Harness />);
193
+ await user.type(field(), "@");
194
+
195
+ const ids = screen.getAllByRole("option").map((o) => o.id);
196
+ expect(ids).toHaveLength(4);
197
+ expect(field()).toHaveAttribute("aria-activedescendant", ids[0]);
198
+
199
+ await user.keyboard("{ArrowDown}");
200
+ expect(field()).toHaveAttribute("aria-activedescendant", ids[1]);
201
+
202
+ // ids[2] is `disabled` — ArrowDown must skip straight past it.
203
+ await user.keyboard("{ArrowDown}");
204
+ expect(field()).toHaveAttribute("aria-activedescendant", ids[3]);
205
+
206
+ // …and wrap back to the first.
207
+ await user.keyboard("{ArrowDown}");
208
+ expect(field()).toHaveAttribute("aria-activedescendant", ids[0]);
209
+
210
+ await user.keyboard("{ArrowUp}");
211
+ expect(field()).toHaveAttribute("aria-activedescendant", ids[3]);
212
+ });
213
+
214
+ it("Home / End jump to the first / last enabled option", async () => {
215
+ const user = userEvent.setup();
216
+ render(<Harness />);
217
+ await user.type(field(), "@");
218
+ const ids = screen.getAllByRole("option").map((o) => o.id);
219
+
220
+ await user.keyboard("{End}");
221
+ expect(field()).toHaveAttribute("aria-activedescendant", ids[3]);
222
+ await user.keyboard("{Home}");
223
+ expect(field()).toHaveAttribute("aria-activedescendant", ids[0]);
224
+ });
225
+
226
+ it("Enter inserts the highlighted option", async () => {
227
+ const user = userEvent.setup();
228
+ render(<Harness />);
229
+ await user.type(field(), "@");
230
+ await user.keyboard("{ArrowDown}{Enter}");
231
+ expect(field().value).toBe("@Grace Hopper ");
232
+ });
233
+
234
+ it("Tab inserts the highlighted option", async () => {
235
+ const user = userEvent.setup();
236
+ render(<Harness />);
237
+ await user.type(field(), "@ada");
238
+ await user.keyboard("{Tab}");
239
+ expect(field().value).toBe("@Ada Lovelace ");
240
+ });
241
+
242
+ it("a disabled option cannot be inserted, and Enter falls through to the host", async () => {
243
+ const user = userEvent.setup();
244
+ render(<Harness />);
245
+ await user.type(field(), "@alan");
246
+ expect(screen.getAllByRole("option")).toHaveLength(1);
247
+ // The only match is disabled, so nothing is highlighted…
248
+ expect(field()).not.toHaveAttribute("aria-activedescendant");
249
+
250
+ await user.keyboard("{Enter}");
251
+ // …the option is not inserted, AND the key is NOT intercepted — it does
252
+ // what it would have done anyway (a newline here; a submit in a composer).
253
+ // That fall-through is what lets `PromptInput` keep Enter when the roster
254
+ // has nothing to offer.
255
+ expect(field().value).not.toContain("Alan Turing");
256
+ expect(field().value).toBe("@alan\n");
257
+ });
258
+
259
+ it("Escape closes the popup WITHOUT clearing the textarea", async () => {
260
+ const user = userEvent.setup();
261
+ render(<Harness />);
262
+ await user.type(field(), "@ada");
263
+ expect(field()).toHaveAttribute("data-state", "open");
264
+
265
+ await user.keyboard("{Escape}");
266
+ expect(field()).toHaveAttribute("data-state", "closed");
267
+ expect(field().value).toBe("@ada");
268
+ expect(field()).toHaveFocus();
269
+ });
270
+
271
+ it("leaves Enter alone when the popup is closed", async () => {
272
+ const user = userEvent.setup();
273
+ render(<Harness />);
274
+ await user.type(field(), "hello{Enter}world");
275
+ expect(field().value).toBe("hello\nworld");
276
+ });
277
+ });
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // T6 — the ARIA invariant (the screen-reader acceptance criterion)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ describe("T6 ARIA invariant", () => {
284
+ /**
285
+ * `aria-activedescendant` must always name the element that is actually
286
+ * carrying `aria-selected="true"` in the SAME commit — the generalised
287
+ * lesson from cmdk's `selectedItemId` hole (see command.tsx).
288
+ */
289
+ function expectActiveDescendantInvariant() {
290
+ const el = field();
291
+ const active = el.getAttribute("aria-activedescendant");
292
+ const selected = screen
293
+ .queryAllByRole("option")
294
+ .filter((o) => o.getAttribute("aria-selected") === "true");
295
+ expect(selected).toHaveLength(active === null ? 0 : 1);
296
+ if (active !== null) {
297
+ expect(selected[0]!.id).toBe(active);
298
+ expect(document.getElementById(active)).not.toBeNull();
299
+ }
300
+ }
301
+
302
+ it("keeps the field a spec-valid textbox and wires the autocomplete properties", async () => {
303
+ const user = userEvent.setup();
304
+ render(<Harness />);
305
+ const el = field();
306
+ expect(el.tagName).toBe("TEXTAREA");
307
+
308
+ // `<textarea>` permits NO role override (ARIA in HTML), and `aria-expanded`
309
+ // is not in `textbox`'s supported set — axe flags the first as
310
+ // `aria-allowed-role` and the second as `aria-allowed-attr` (critical).
311
+ // Everything the field DOES carry is in `textbox`'s supported set.
312
+ expect(el).not.toHaveAttribute("role");
313
+ expect(el).not.toHaveAttribute("aria-expanded");
314
+ expect(el).toHaveAttribute("aria-autocomplete", "list");
315
+ expect(el).toHaveAttribute("aria-haspopup", "listbox");
316
+ expect(el).toHaveAttribute("data-state", "closed");
317
+ // Nothing to control while the portalled listbox is unmounted.
318
+ expect(el).not.toHaveAttribute("aria-controls");
319
+
320
+ await user.type(el, "@");
321
+ expect(field()).toHaveAttribute("data-state", "open");
322
+ expect(field().getAttribute("aria-controls")).toBe(screen.getByRole("listbox").id);
323
+ });
324
+
325
+ it("holds the invariant on open, after a filter keystroke, and after an arrow move", async () => {
326
+ const user = userEvent.setup();
327
+ render(<Harness />);
328
+
329
+ await user.type(field(), "@");
330
+ expectActiveDescendantInvariant(); // on open
331
+
332
+ await user.type(field(), "a");
333
+ expectActiveDescendantInvariant(); // after a filter keystroke
334
+
335
+ await user.type(field(), "d");
336
+ expectActiveDescendantInvariant();
337
+
338
+ await user.keyboard("{ArrowDown}");
339
+ expectActiveDescendantInvariant(); // after an arrow move
340
+ });
341
+
342
+ it("drops aria-activedescendant entirely when nothing is highlighted", async () => {
343
+ const user = userEvent.setup();
344
+ render(<Harness />);
345
+ await user.type(field(), "@zzzz");
346
+ expect(field()).not.toHaveAttribute("aria-activedescendant");
347
+ expectActiveDescendantInvariant();
348
+ });
349
+
350
+ it("never leaves a dangling aria-activedescendant when the list shrinks", async () => {
351
+ const user = userEvent.setup();
352
+ render(<Harness />);
353
+ await user.type(field(), "@");
354
+ await user.keyboard("{End}"); // highlight index 3
355
+ await user.type(field(), "grace"); // list collapses to one option
356
+ expectActiveDescendantInvariant();
357
+ });
358
+
359
+ it("names the listbox and keeps focus on the textarea", async () => {
360
+ const user = userEvent.setup();
361
+ render(<Harness />);
362
+ await user.type(field(), "@");
363
+ expect(screen.getByRole("listbox", { name: "Mention suggestions" })).toBeInTheDocument();
364
+ expect(field()).toHaveFocus();
365
+ });
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // T7 — controlled/uncontrolled parity + ref
370
+ // ---------------------------------------------------------------------------
371
+
372
+ describe("T7 controlled / uncontrolled parity", () => {
373
+ function Controlled({ onValueChange }: { onValueChange: (v: MentionValue) => void }) {
374
+ const [value, setValue] = useState<MentionValue>({ text: "", mentions: [] });
375
+ return (
376
+ <Harness
377
+ value={value}
378
+ onValueChange={(v) => {
379
+ setValue(v);
380
+ onValueChange(v);
381
+ }}
382
+ />
383
+ );
384
+ }
385
+
386
+ it("produces an identical MentionValue in both modes", async () => {
387
+ const uncontrolled = vi.fn();
388
+ const { unmount } = render(<Harness onValueChange={uncontrolled} />);
389
+ const user = userEvent.setup();
390
+ await user.type(field(), "hi @ada");
391
+ await user.keyboard("{Enter}");
392
+ const uncontrolledValue = uncontrolled.mock.calls.at(-1)?.[0];
393
+ unmount();
394
+
395
+ const controlled = vi.fn();
396
+ render(<Controlled onValueChange={controlled} />);
397
+ await user.type(field(), "hi @ada");
398
+ await user.keyboard("{Enter}");
399
+ const controlledValue = controlled.mock.calls.at(-1)?.[0];
400
+
401
+ expect(uncontrolledValue).toEqual({
402
+ text: "hi @Ada Lovelace ",
403
+ mentions: [{ id: "u1", label: "Ada Lovelace", start: 3 }],
404
+ });
405
+ expect(controlledValue).toEqual(uncontrolledValue);
406
+ });
407
+
408
+ it("a controlled value the parent refuses to update never mutates the field", async () => {
409
+ const user = userEvent.setup();
410
+ const onValueChange = vi.fn();
411
+ render(<Harness value={{ text: "frozen", mentions: [] }} onValueChange={onValueChange} />);
412
+ await user.type(field(), "abc");
413
+ expect(onValueChange).toHaveBeenCalled();
414
+ expect(field().value).toBe("frozen");
415
+ });
416
+
417
+ it("seeds an uncontrolled field from defaultValue", () => {
418
+ render(<Harness defaultValue={{ text: "hello", mentions: [] }} />);
419
+ expect(field().value).toBe("hello");
420
+ });
421
+
422
+ it("forwards a ref to the real HTMLTextAreaElement", () => {
423
+ const ref = createRef<HTMLTextAreaElement>();
424
+ render(
425
+ <MentionInput options={ROSTER}>
426
+ <MentionInputTextarea ref={ref} aria-label="Comment" />
427
+ </MentionInput>,
428
+ );
429
+ expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
430
+ });
431
+
432
+ it("forwards a ref to the real HTMLTextAreaElement THROUGH asChild", () => {
433
+ const ref = createRef<HTMLTextAreaElement>();
434
+ render(
435
+ <MentionInput options={ROSTER}>
436
+ <MentionInputTextarea ref={ref} asChild>
437
+ <textarea aria-label="Comment" name="message" />
438
+ </MentionInputTextarea>
439
+ </MentionInput>,
440
+ );
441
+ expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
442
+ // The child's own props survive the Slot merge…
443
+ expect(ref.current).toHaveAttribute("name", "message");
444
+ // …and so do the slot's, which is what makes the composer binding work.
445
+ expect(ref.current).toHaveAttribute("aria-autocomplete", "list");
446
+ expect(ref.current).toHaveAttribute("data-slot", "mention-input-textarea");
447
+ });
448
+ });
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // T8 — the Slot handler-order contract (the `onKeyDownCapture` requirement)
452
+ // ---------------------------------------------------------------------------
453
+
454
+ describe("T8 Slot handler-order contract", () => {
455
+ /**
456
+ * `asChild` around a child that binds `onKeyDown` on a HOST ELEMENT — the
457
+ * shape Radix `Slot`'s child-first merge actually breaks.
458
+ *
459
+ * The child mirrors `PromptInputTextarea`'s contract: run, then bail if an
460
+ * outer handler already claimed the key. With the interception bound as
461
+ * `onKeyDownCapture` it runs in the capture pass, so `defaultPrevented` is
462
+ * already true and the host bails. Bound as `onKeyDown`, `Slot` composes
463
+ * child-then-slot, the host runs FIRST with `defaultPrevented === false`, and
464
+ * it submits — which is the regression this test exists to catch.
465
+ */
466
+ function HostBindingHarness({ onHostEnter }: { onHostEnter: () => void }) {
467
+ return (
468
+ <MentionInput options={ROSTER}>
469
+ <MentionInputTextarea asChild>
470
+ <textarea
471
+ aria-label="Comment"
472
+ onKeyDown={(event) => {
473
+ if (event.defaultPrevented) return;
474
+ if (event.key === "Enter") onHostEnter();
475
+ }}
476
+ />
477
+ </MentionInputTextarea>
478
+ <MentionInputContent>
479
+ <MentionInputList>
480
+ {(option) => <MentionInputItem key={option.id} option={option} />}
481
+ </MentionInputList>
482
+ </MentionInputContent>
483
+ </MentionInput>
484
+ );
485
+ }
486
+
487
+ it("intercepts BEFORE the child's own onKeyDown when the roster is open", async () => {
488
+ const user = userEvent.setup();
489
+ const onHostEnter = vi.fn();
490
+ render(<HostBindingHarness onHostEnter={onHostEnter} />);
491
+
492
+ await user.type(field(), "@ada");
493
+ expect(field()).toHaveAttribute("data-state", "open");
494
+
495
+ await user.keyboard("{Enter}");
496
+
497
+ expect(field().value).toBe("@Ada Lovelace ");
498
+ // THE discriminating assertion: bound as `onKeyDown`, the child would have
499
+ // run first and fired.
500
+ expect(onHostEnter).not.toHaveBeenCalled();
501
+ });
502
+
503
+ it("leaves the child's onKeyDown alone when the roster is closed", async () => {
504
+ const user = userEvent.setup();
505
+ const onHostEnter = vi.fn();
506
+ render(<HostBindingHarness onHostEnter={onHostEnter} />);
507
+
508
+ await user.type(field(), "hello");
509
+ expect(field()).toHaveAttribute("data-state", "closed");
510
+
511
+ await user.keyboard("{Enter}");
512
+ expect(onHostEnter).toHaveBeenCalledTimes(1);
513
+ });
514
+ });
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Consumer prop composition (props spread must not clobber internal wiring)
518
+ // ---------------------------------------------------------------------------
519
+
520
+ describe("consumer prop composition", () => {
521
+ it("keeps tracking the value when the consumer ALSO passes onChange", async () => {
522
+ const user = userEvent.setup();
523
+ const onChange = vi.fn();
524
+ const onValueChange = vi.fn();
525
+ render(
526
+ <MentionInput options={ROSTER} onValueChange={onValueChange}>
527
+ <MentionInputTextarea aria-label="Comment" onChange={onChange} />
528
+ </MentionInput>,
529
+ );
530
+
531
+ await user.type(field(), "@ada");
532
+
533
+ // Spreading `...props` after the handlers used to replace `onValueInput`
534
+ // outright, leaving the provider-controlled field permanently empty.
535
+ expect(field().value).toBe("@ada");
536
+ expect(onValueChange).toHaveBeenLastCalledWith({ text: "@ada", mentions: [] });
537
+ // …and the consumer's handler still runs.
538
+ expect(onChange).toHaveBeenCalled();
539
+ });
540
+
541
+ it("composes the other handlers rather than replacing them", async () => {
542
+ const user = userEvent.setup();
543
+ const onKeyDownCapture = vi.fn();
544
+ const onClick = vi.fn();
545
+ const onBlur = vi.fn();
546
+ render(
547
+ <MentionInput options={ROSTER}>
548
+ <MentionInputTextarea
549
+ aria-label="Comment"
550
+ onKeyDownCapture={onKeyDownCapture}
551
+ onClick={onClick}
552
+ onBlur={onBlur}
553
+ />
554
+ <MentionInputContent>
555
+ <MentionInputList>
556
+ {(option) => <MentionInputItem key={option.id} option={option} />}
557
+ </MentionInputList>
558
+ </MentionInputContent>
559
+ </MentionInput>,
560
+ );
561
+
562
+ await user.click(field());
563
+ expect(onClick).toHaveBeenCalled();
564
+
565
+ // The internal keyboard model still runs (the roster opens and Enter
566
+ // inserts) AND the consumer's capture handler is called.
567
+ await user.keyboard("@ada");
568
+ expect(field()).toHaveAttribute("data-state", "open");
569
+ await user.keyboard("{Enter}");
570
+ expect(field().value).toBe("@Ada Lovelace ");
571
+ expect(onKeyDownCapture).toHaveBeenCalled();
572
+
573
+ await user.tab();
574
+ expect(onBlur).toHaveBeenCalled();
575
+ });
576
+
577
+ it("lets consumer NON-handler props win, so <Label htmlFor> keeps working", () => {
578
+ render(
579
+ <MentionInput options={ROSTER}>
580
+ <MentionInputTextarea
581
+ id="my-field"
582
+ name="comment"
583
+ placeholder="Say something…"
584
+ aria-label="Comment"
585
+ aria-describedby="my-hint"
586
+ data-testid="field"
587
+ />
588
+ </MentionInput>,
589
+ );
590
+ const el = screen.getByTestId("field");
591
+ expect(el).toHaveAttribute("id", "my-field");
592
+ expect(el).toHaveAttribute("name", "comment");
593
+ expect(el).toHaveAttribute("placeholder", "Say something…");
594
+ expect(el).toHaveAttribute("aria-describedby", "my-hint");
595
+ // The component's own wiring is still present alongside them.
596
+ expect(el).toHaveAttribute("aria-autocomplete", "list");
597
+ expect(el).toHaveAttribute("data-slot", "mention-input-textarea");
598
+ });
599
+ });
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Async rosters / query reporting
603
+ // ---------------------------------------------------------------------------
604
+
605
+ describe("onQueryChange", () => {
606
+ it("reports the query while open and null when the popup closes", async () => {
607
+ const user = userEvent.setup();
608
+ const onQueryChange = vi.fn();
609
+ render(<Harness onQueryChange={onQueryChange} filter={() => true} />);
610
+
611
+ await user.type(field(), "@ad");
612
+ expect(onQueryChange).toHaveBeenLastCalledWith("ad");
613
+
614
+ await user.keyboard("{Escape}");
615
+ expect(onQueryChange).toHaveBeenLastCalledWith(null);
616
+ });
617
+ });