@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,1102 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ use,
7
+ useCallback,
8
+ useEffect,
9
+ useId,
10
+ useLayoutEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ type CSSProperties,
15
+ type HTMLAttributes,
16
+ type KeyboardEvent as ReactKeyboardEvent,
17
+ type ReactNode,
18
+ type TextareaHTMLAttributes,
19
+ } from "react";
20
+ import { Slot } from "@radix-ui/react-slot";
21
+ import { cn } from "../../lib/cn";
22
+ import { mergeRefs } from "../../lib/merge-refs";
23
+ import { useLocale } from "../locale-provider";
24
+ import { Popover, PopoverAnchor, PopoverContent } from "../popover";
25
+ import { Textarea } from "../textarea";
26
+ import {
27
+ defaultMentionFilter,
28
+ insertMention,
29
+ mentionAt,
30
+ mentionEnd,
31
+ remapMentions,
32
+ type Mention,
33
+ type MentionOption,
34
+ type MentionValue,
35
+ } from "./mention-value";
36
+
37
+ export type { Mention, MentionOption, MentionValue };
38
+
39
+ const EMPTY_VALUE: MentionValue = { text: "", mentions: [] };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Context — state / actions / meta (component-api.md provider-injection shape)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface MentionInputContextValue {
46
+ state: {
47
+ value: MentionValue;
48
+ /** The in-progress query WITHOUT the trigger char; `null` when closed. */
49
+ query: string | null;
50
+ open: boolean;
51
+ /** Index into `filtered`; `-1` when nothing is highlighted. */
52
+ activeIndex: number;
53
+ /** DOM id of the highlighted option; `undefined` when nothing is. */
54
+ activeId?: string;
55
+ filtered: MentionOption[];
56
+ };
57
+ actions: {
58
+ insert: (option: MentionOption) => void;
59
+ close: () => void;
60
+ setActiveIndex: (index: number) => void;
61
+ };
62
+ meta: {
63
+ trigger: string;
64
+ listId: string;
65
+ /**
66
+ * The id `MentionInputTextarea` puts on its element. Advisory only — with
67
+ * `asChild`, Radix `Slot` lets a consumer-supplied `id` on the child win
68
+ * (child props are merged OVER slot props), so the component itself never
69
+ * resolves the field through this id; it holds a real ref.
70
+ */
71
+ textareaId: string;
72
+ optionId: (index: number) => string;
73
+ };
74
+ }
75
+
76
+ const MentionInputContext = createContext<MentionInputContextValue | null>(null);
77
+
78
+ /** Read the surrounding `MentionInput`'s state, actions and ids. */
79
+ export function useMentionInput(): MentionInputContextValue {
80
+ const ctx = use(MentionInputContext);
81
+ if (!ctx) {
82
+ throw new Error("useMentionInput must be used inside a <MentionInput>.");
83
+ }
84
+ return ctx;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Query detection
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** A trigger only starts a query at the start of a word — never inside one. */
92
+ function isWordBoundary(text: string, index: number): boolean {
93
+ if (index === 0) return true;
94
+ const previous = text[index - 1] ?? "";
95
+ return /\s/.test(previous) || previous === "(" || previous === "[";
96
+ }
97
+
98
+ /**
99
+ * Is the caret currently sitting in a `trigger + query` run?
100
+ *
101
+ * Derived from the committed text + caret rather than intercepted at keydown,
102
+ * on purpose: a keydown-driven trigger would have to `preventDefault()` every
103
+ * printable character and re-insert it by hand, which is exactly what destroys
104
+ * IME composition, native undo, spellcheck and paste — the things a real
105
+ * `<textarea>` gives us for free. Deriving instead means the popup opens
106
+ * identically whether the trigger arrived by typing, pasting, an IME commit or
107
+ * a mouse click that moved the caret back into a half-typed query.
108
+ */
109
+ function findQuery(
110
+ value: MentionValue,
111
+ caret: number,
112
+ trigger: string,
113
+ ): { start: number; query: string } | null {
114
+ const { text } = value;
115
+ if (caret < trigger.length) return null;
116
+ const start = text.lastIndexOf(trigger, caret - trigger.length);
117
+ if (start < 0) return null;
118
+
119
+ const query = text.slice(start + trigger.length, caret);
120
+ if (/\s/.test(query)) return null;
121
+ if (!isWordBoundary(text, start)) return null;
122
+ // The trigger char of an ALREADY-inserted mention must not re-open the popup.
123
+ if (mentionAt(value, start, trigger)) return null;
124
+
125
+ return { start, query };
126
+ }
127
+
128
+ /** Next enabled index in `list`, wrapping; `-1` when none is selectable. */
129
+ function step(list: MentionOption[], from: number, direction: 1 | -1): number {
130
+ const count = list.length;
131
+ if (count === 0) return -1;
132
+ for (let i = 1; i <= count; i++) {
133
+ const index = (((from + direction * i) % count) + count) % count;
134
+ if (!list[index]?.disabled) return index;
135
+ }
136
+ return -1;
137
+ }
138
+
139
+ function firstEnabled(list: MentionOption[]): number {
140
+ const index = list.findIndex((o) => !o.disabled);
141
+ return index;
142
+ }
143
+
144
+ function lastEnabled(list: MentionOption[]): number {
145
+ for (let i = list.length - 1; i >= 0; i--) {
146
+ if (!list[i]?.disabled) return i;
147
+ }
148
+ return -1;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Caret / overlay mirror
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Computed properties the mirror has to copy verbatim for its glyph metrics to
157
+ * land on the same pixels as the textarea's. Anything that changes where a
158
+ * character starts belongs here.
159
+ */
160
+ const MIRRORED_PROPERTIES = [
161
+ "boxSizing",
162
+ "paddingTop",
163
+ "paddingRight",
164
+ "paddingBottom",
165
+ "paddingLeft",
166
+ "borderTopWidth",
167
+ "borderRightWidth",
168
+ "borderBottomWidth",
169
+ "borderLeftWidth",
170
+ "fontFamily",
171
+ "fontSize",
172
+ "fontWeight",
173
+ "fontStyle",
174
+ "fontVariant",
175
+ "fontStretch",
176
+ "fontFeatureSettings",
177
+ "fontVariationSettings",
178
+ "letterSpacing",
179
+ "lineHeight",
180
+ "textTransform",
181
+ "textIndent",
182
+ "wordSpacing",
183
+ "textAlign",
184
+ "direction",
185
+ "tabSize",
186
+ // Wrapping, copied rather than assumed: forcing `word-break: break-word` when
187
+ // the field says `normal` breaks long words one line early in the mirror.
188
+ "whiteSpace",
189
+ "overflowWrap",
190
+ "wordBreak",
191
+ ] as const satisfies readonly (keyof CSSStyleDeclaration & string)[];
192
+
193
+ function readMirrorStyle(textarea: HTMLTextAreaElement): CSSProperties {
194
+ const computed = getComputedStyle(textarea);
195
+ const style: Record<string, string | number> = {};
196
+ for (const property of MIRRORED_PROPERTIES) {
197
+ style[property] = computed[property] as string;
198
+ }
199
+
200
+ const borderX =
201
+ Number.parseFloat(computed.borderLeftWidth || "0") +
202
+ Number.parseFloat(computed.borderRightWidth || "0");
203
+ // Whatever the box is wider than its content area, minus the borders, is the
204
+ // vertical scrollbar. Fold it into the padding or every wrapped line in the
205
+ // mirror breaks a few characters later than it does in the field.
206
+ const scrollbar = Math.max(0, textarea.offsetWidth - textarea.clientWidth - borderX);
207
+
208
+ return {
209
+ ...(style as CSSProperties),
210
+ position: "absolute",
211
+ left: textarea.offsetLeft,
212
+ top: textarea.offsetTop,
213
+ width: textarea.offsetWidth,
214
+ height: textarea.offsetHeight,
215
+ paddingRight: Number.parseFloat(computed.paddingRight || "0") + scrollbar,
216
+ borderStyle: "solid",
217
+ borderColor: "transparent",
218
+ // A textarea already computes `pre-wrap`; the fallback only covers a host
219
+ // that reset it, and an environment (jsdom) that resolves nothing.
220
+ whiteSpace: computed.whiteSpace || "pre-wrap",
221
+ overflow: "hidden",
222
+ margin: 0,
223
+ };
224
+ }
225
+
226
+ /** Split the text into plain runs and mention runs, in document order. */
227
+ function buildRuns(
228
+ value: MentionValue,
229
+ trigger: string,
230
+ ): Array<{ key: string; text: string; mention?: Mention }> {
231
+ const runs: Array<{ key: string; text: string; mention?: Mention }> = [];
232
+ const ordered = [...value.mentions].sort((a, b) => a.start - b.start);
233
+ let cursor = 0;
234
+ for (const mention of ordered) {
235
+ if (mention.start > cursor) {
236
+ runs.push({ key: `t${cursor}`, text: value.text.slice(cursor, mention.start) });
237
+ }
238
+ const end = mentionEnd(mention, trigger);
239
+ runs.push({ key: `m${mention.start}`, text: value.text.slice(mention.start, end), mention });
240
+ cursor = end;
241
+ }
242
+ if (cursor < value.text.length) {
243
+ runs.push({ key: `t${cursor}`, text: value.text.slice(cursor) });
244
+ }
245
+ return runs;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // MentionInput (root + provider + Popover)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export interface MentionInputProps extends Omit<
253
+ HTMLAttributes<HTMLDivElement>,
254
+ "onChange" | "defaultValue"
255
+ > {
256
+ /** The roster the popup offers. */
257
+ options: MentionOption[];
258
+ /** Controlled value. */
259
+ value?: MentionValue;
260
+ /** Uncontrolled initial value. */
261
+ defaultValue?: MentionValue;
262
+ /** Called with the whole value whenever text or mentions change. */
263
+ onValueChange?: (value: MentionValue) => void;
264
+ /** Trigger character. Default `"@"`. */
265
+ trigger?: string;
266
+ /**
267
+ * Which options survive the current query. Defaults to
268
+ * `defaultMentionFilter`; pass `() => true` when the roster is fetched
269
+ * app-side from `onQueryChange`.
270
+ */
271
+ filter?: (option: MentionOption, query: string) => boolean;
272
+ /** The query changed. `null` means the popup closed. */
273
+ onQueryChange?: (query: string | null) => void;
274
+ /** Controlled popup state. */
275
+ open?: boolean;
276
+ /** Called when the popup wants to open or close. */
277
+ onOpenChange?: (open: boolean) => void;
278
+ children: ReactNode;
279
+ }
280
+
281
+ /**
282
+ * MentionInput — an `@`-mention text field built on a real `<textarea>`.
283
+ *
284
+ * The field stays a native textarea (so IME, paste, undo, spellcheck, mobile
285
+ * keyboards and `FormData` all keep working) and mentions are tracked as
286
+ * offsets beside it. The suggestion popup is this component's own listbox
287
+ * driven by `aria-activedescendant`, so **focus never leaves the textarea** —
288
+ * a popup that took focus would take the caret with it.
289
+ */
290
+ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput(
291
+ {
292
+ options,
293
+ value: valueProp,
294
+ defaultValue,
295
+ onValueChange,
296
+ trigger = "@",
297
+ filter = defaultMentionFilter,
298
+ onQueryChange,
299
+ open: openProp,
300
+ onOpenChange,
301
+ className,
302
+ children,
303
+ ...props
304
+ },
305
+ ref,
306
+ ) {
307
+ const uid = useId();
308
+ const listId = `${uid}-listbox`;
309
+ const textareaId = `${uid}-textarea`;
310
+ const optionId = useCallback((index: number) => `${uid}-option-${index}`, [uid]);
311
+
312
+ const rootRef = useRef<HTMLDivElement>(null);
313
+ const mergedRootRef = useMemo(() => mergeRefs<HTMLDivElement>(ref, rootRef), [ref]);
314
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
315
+
316
+ // ------------------------------------------------------------------
317
+ // Controlled / uncontrolled value
318
+ // ------------------------------------------------------------------
319
+ const isControlled = valueProp !== undefined;
320
+ const [internalValue, setInternalValue] = useState<MentionValue>(defaultValue ?? EMPTY_VALUE);
321
+ const value = isControlled ? valueProp : internalValue;
322
+
323
+ const commit = useCallback(
324
+ (next: MentionValue) => {
325
+ if (!isControlled) setInternalValue(next);
326
+ onValueChange?.(next);
327
+ },
328
+ [isControlled, onValueChange],
329
+ );
330
+
331
+ // ------------------------------------------------------------------
332
+ // Caret
333
+ // ------------------------------------------------------------------
334
+ const [caret, setCaret] = useState(() => (defaultValue?.text ?? "").length);
335
+ const pendingCaretRef = useRef<number | null>(null);
336
+
337
+ const applyCaret = useCallback((position: number) => {
338
+ pendingCaretRef.current = position;
339
+ setCaret(position);
340
+ // Pure caret moves change no value, so React may skip the re-render that
341
+ // would flush `pendingCaretRef` — set it here as well. Value changes still
342
+ // need the post-commit pass below, because React writes the new `value`
343
+ // into the DOM after this point and that resets the selection.
344
+ textareaRef.current?.setSelectionRange(position, position);
345
+ }, []);
346
+
347
+ useLayoutEffect(() => {
348
+ const position = pendingCaretRef.current;
349
+ if (position === null) return;
350
+ pendingCaretRef.current = null;
351
+ textareaRef.current?.setSelectionRange(position, position);
352
+ });
353
+
354
+ // ------------------------------------------------------------------
355
+ // Query / open state
356
+ // ------------------------------------------------------------------
357
+ const queryInfo = useMemo(() => findQuery(value, caret, trigger), [value, caret, trigger]);
358
+
359
+ // Escape (and an outside dismissal) suppress the popup for the run it was
360
+ // dismissed on, without touching the text — the query is still there, the
361
+ // user just said they are done with the roster for now.
362
+ const [dismissedStart, setDismissedStart] = useState<number | null>(null);
363
+ useEffect(() => {
364
+ if (queryInfo === null && dismissedStart !== null) setDismissedStart(null);
365
+ }, [queryInfo, dismissedStart]);
366
+
367
+ const derivedOpen = queryInfo !== null && dismissedStart !== queryInfo.start;
368
+ const open = openProp ?? derivedOpen;
369
+ const query = open && queryInfo ? queryInfo.query : null;
370
+
371
+ const setOpen = useCallback(
372
+ (next: boolean) => {
373
+ onOpenChange?.(next);
374
+ if (!next) setDismissedStart(queryInfo?.start ?? null);
375
+ },
376
+ [onOpenChange, queryInfo],
377
+ );
378
+
379
+ const onQueryChangeRef = useRef(onQueryChange);
380
+ useEffect(() => {
381
+ onQueryChangeRef.current = onQueryChange;
382
+ }, [onQueryChange]);
383
+ useEffect(() => {
384
+ onQueryChangeRef.current?.(query);
385
+ }, [query]);
386
+
387
+ // ------------------------------------------------------------------
388
+ // Filtering + highlight
389
+ // ------------------------------------------------------------------
390
+ const filtered = useMemo(
391
+ () => (open && query !== null ? options.filter((option) => filter(option, query)) : []),
392
+ [open, query, options, filter],
393
+ );
394
+
395
+ const [activeIndexState, setActiveIndexState] = useState(0);
396
+
397
+ /**
398
+ * The highlight is RESOLVED during render, never repaired in an effect.
399
+ *
400
+ * `aria-activedescendant` and the option's `aria-selected` must be written in
401
+ * the same commit, or the attribute names an element that does not exist yet
402
+ * — the generalised form of cmdk's `selectedItemId` hole (see the docblock in
403
+ * `../command/command.tsx`). Clamping here means a stale index from before a
404
+ * filter keystroke can never reach the DOM, not even for one frame.
405
+ */
406
+ const activeIndex = useMemo(() => {
407
+ const candidate = filtered[activeIndexState];
408
+ if (candidate && !candidate.disabled) return activeIndexState;
409
+ return firstEnabled(filtered);
410
+ }, [filtered, activeIndexState]);
411
+
412
+ // Reset the *stored* index when the query changes so the next arrow press
413
+ // starts from the top. Safe as an effect precisely because the resolved value
414
+ // above is already correct in the intervening commit.
415
+ useEffect(() => {
416
+ setActiveIndexState(0);
417
+ }, [query]);
418
+
419
+ const activeId = activeIndex >= 0 ? optionId(activeIndex) : undefined;
420
+
421
+ // ------------------------------------------------------------------
422
+ // Actions
423
+ // ------------------------------------------------------------------
424
+ const insert = useCallback(
425
+ (option: MentionOption) => {
426
+ if (option.disabled || !queryInfo) return;
427
+ const result = insertMention(value, option, trigger, queryInfo.start, caret);
428
+ commit(result.value);
429
+ applyCaret(result.caret);
430
+ },
431
+ [queryInfo, value, trigger, caret, commit, applyCaret],
432
+ );
433
+
434
+ const close = useCallback(() => {
435
+ setOpen(false);
436
+ }, [setOpen]);
437
+
438
+ const removeMention = useCallback(
439
+ (mention: Mention) => {
440
+ const end = mentionEnd(mention, trigger);
441
+ const nextText = value.text.slice(0, mention.start) + value.text.slice(end);
442
+ commit({ text: nextText, mentions: remapMentions(value, nextText, trigger) });
443
+ applyCaret(mention.start);
444
+ },
445
+ [value, trigger, commit, applyCaret],
446
+ );
447
+
448
+ // ------------------------------------------------------------------
449
+ // Textarea event handlers (owned here, handed to MentionInputTextarea)
450
+ // ------------------------------------------------------------------
451
+ const syncCaret = useCallback((element: HTMLTextAreaElement) => {
452
+ setCaret(element.selectionStart ?? 0);
453
+ }, []);
454
+
455
+ const handleChange = useCallback(
456
+ (element: HTMLTextAreaElement) => {
457
+ const nextText = element.value;
458
+ commit({ text: nextText, mentions: remapMentions(value, nextText, trigger) });
459
+ setCaret(element.selectionStart ?? nextText.length);
460
+ },
461
+ [commit, value, trigger],
462
+ );
463
+
464
+ const handleKeyDownCapture = useCallback(
465
+ (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
466
+ const element = event.currentTarget;
467
+ const position = element.selectionStart ?? 0;
468
+ const hasRange = element.selectionStart !== element.selectionEnd;
469
+
470
+ if (open) {
471
+ switch (event.key) {
472
+ case "ArrowDown":
473
+ event.preventDefault();
474
+ setActiveIndexState(step(filtered, activeIndex, 1));
475
+ return;
476
+ case "ArrowUp":
477
+ event.preventDefault();
478
+ setActiveIndexState(step(filtered, activeIndex < 0 ? 0 : activeIndex, -1));
479
+ return;
480
+ case "Home":
481
+ event.preventDefault();
482
+ setActiveIndexState(firstEnabled(filtered));
483
+ return;
484
+ case "End":
485
+ event.preventDefault();
486
+ setActiveIndexState(lastEnabled(filtered));
487
+ return;
488
+ case "Enter":
489
+ case "Tab": {
490
+ const option = activeIndex >= 0 ? filtered[activeIndex] : undefined;
491
+ if (option && !option.disabled) {
492
+ // Capture phase, so `defaultPrevented` is already true when a
493
+ // host handler (PromptInput's Enter→submit) runs in the bubble
494
+ // phase on this same element and this same SyntheticEvent.
495
+ event.preventDefault();
496
+ insert(option);
497
+ }
498
+ return;
499
+ }
500
+ case "Escape":
501
+ event.preventDefault();
502
+ close();
503
+ return;
504
+ default:
505
+ break;
506
+ }
507
+ }
508
+
509
+ // Chip atomicity. A range selection is ordinary text editing — the user
510
+ // asked for exactly those characters, mentions included.
511
+ if (hasRange || event.metaKey || event.ctrlKey || event.altKey) return;
512
+
513
+ if (event.key === "Backspace") {
514
+ const mention = mentionAt(value, position - 1, trigger);
515
+ if (mention) {
516
+ event.preventDefault();
517
+ removeMention(mention);
518
+ }
519
+ return;
520
+ }
521
+ if (event.key === "Delete") {
522
+ const mention = mentionAt(value, position, trigger);
523
+ if (mention) {
524
+ event.preventDefault();
525
+ removeMention(mention);
526
+ }
527
+ return;
528
+ }
529
+ if (event.key === "ArrowLeft" && !event.shiftKey) {
530
+ const mention = mentionAt(value, position - 1, trigger);
531
+ if (mention && position - 1 > mention.start) {
532
+ event.preventDefault();
533
+ applyCaret(mention.start);
534
+ }
535
+ return;
536
+ }
537
+ if (event.key === "ArrowRight" && !event.shiftKey) {
538
+ const mention = mentionAt(value, position, trigger);
539
+ if (mention) {
540
+ event.preventDefault();
541
+ applyCaret(mentionEnd(mention, trigger));
542
+ }
543
+ }
544
+ },
545
+ [open, filtered, activeIndex, insert, close, value, trigger, removeMention, applyCaret],
546
+ );
547
+
548
+ // ------------------------------------------------------------------
549
+ // Mirror geometry (caret anchor + chip overlay)
550
+ // ------------------------------------------------------------------
551
+ const [mirrorStyle, setMirrorStyle] = useState<CSSProperties | null>(null);
552
+ const [scrollTop, setScrollTop] = useState(0);
553
+ const caretMarkerRef = useRef<HTMLSpanElement>(null);
554
+ const [caretRect, setCaretRect] = useState({ left: 0, top: 0, height: 0 });
555
+
556
+ /**
557
+ * Re-snapshot the field's metrics whenever anything that MOVES A GLYPH can
558
+ * have changed.
559
+ *
560
+ * A one-shot snapshot is not enough and the failure is visible, not
561
+ * theoretical: the very first layout effect runs before the theme attribute
562
+ * has been applied, so it captures the `:root` fallback font — the chip wash
563
+ * then renders in a different typeface from the text it is supposed to sit
564
+ * behind and runs ~14% wide (measured: 143.3px vs 125.5px for the same
565
+ * string). Exactly the same drift happens at runtime whenever a `ThemeSwitcher`
566
+ * changes `data-theme`, a density dial changes the type scale, or a web font
567
+ * finishes loading. So the snapshot is re-taken on all of them.
568
+ */
569
+ const remeasureMirror = useCallback(() => {
570
+ const element = textareaRef.current;
571
+ if (!element) return;
572
+ const next = readMirrorStyle(element);
573
+ setMirrorStyle((previous) =>
574
+ previous && JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
575
+ );
576
+ }, []);
577
+
578
+ useLayoutEffect(() => {
579
+ remeasureMirror();
580
+ }, [remeasureMirror, value.text]);
581
+
582
+ useLayoutEffect(() => {
583
+ const element = textareaRef.current;
584
+ if (!element) return;
585
+ const teardown: Array<() => void> = [];
586
+
587
+ if (typeof ResizeObserver !== "undefined") {
588
+ const observer = new ResizeObserver(remeasureMirror);
589
+ observer.observe(element);
590
+ teardown.push(() => observer.disconnect());
591
+ }
592
+
593
+ if (typeof MutationObserver !== "undefined") {
594
+ // The field's own classes/inline styles.
595
+ const fieldObserver = new MutationObserver(remeasureMirror);
596
+ fieldObserver.observe(element, {
597
+ attributes: true,
598
+ attributeFilter: ["class", "style"],
599
+ });
600
+ teardown.push(() => fieldObserver.disconnect());
601
+
602
+ // Theme / density / decoration switches re-resolve the INHERITED font and
603
+ // type scale, and a stale snapshot misaligns every chip on the surface.
604
+ const rootObserver = new MutationObserver(remeasureMirror);
605
+ for (const node of [document.documentElement, document.body]) {
606
+ rootObserver.observe(node, {
607
+ attributes: true,
608
+ attributeFilter: ["data-theme", "data-density", "data-decoration", "class", "style"],
609
+ });
610
+ }
611
+ teardown.push(() => rootObserver.disconnect());
612
+ }
613
+
614
+ // A web font landing after first paint changes every advance width. This
615
+ // arm is intentionally kept as unpinned defence-in-depth: five isolation
616
+ // attempts (issue #405) could not construct a scenario where removing it
617
+ // alone turns any test red — it is covered only compositely by the
618
+ // ResizeObserver/MutationObserver arms above. Do not delete it on the
619
+ // strength of "no test notices"; see #405 for the full mutation audit and
620
+ // the maintainer's keep decision (ADR 0023 §6).
621
+ let cancelled = false;
622
+ void document.fonts?.ready
623
+ .then(() => {
624
+ if (!cancelled) remeasureMirror();
625
+ })
626
+ .catch(() => undefined);
627
+ teardown.push(() => {
628
+ cancelled = true;
629
+ });
630
+
631
+ return () => {
632
+ for (const fn of teardown) fn();
633
+ };
634
+ }, [remeasureMirror]);
635
+
636
+ useLayoutEffect(() => {
637
+ if (!open) return;
638
+ const marker = caretMarkerRef.current;
639
+ const root = rootRef.current;
640
+ if (!marker || !root) return;
641
+ const rect = marker.getClientRects()[0] ?? marker.getBoundingClientRect();
642
+ const rootRect = root.getBoundingClientRect();
643
+ const next = {
644
+ left: rect.left - rootRect.left,
645
+ top: rect.top - rootRect.top,
646
+ height: rect.height,
647
+ };
648
+ setCaretRect((previous) =>
649
+ previous.left === next.left && previous.top === next.top && previous.height === next.height
650
+ ? previous
651
+ : next,
652
+ );
653
+ }, [open, caret, value.text, mirrorStyle, scrollTop]);
654
+
655
+ const runs = useMemo(() => buildRuns(value, trigger), [value, trigger]);
656
+
657
+ // ------------------------------------------------------------------
658
+ // Context
659
+ // ------------------------------------------------------------------
660
+ const contextValue = useMemo<MentionInputContextValue>(
661
+ () => ({
662
+ state: { value, query, open, activeIndex, activeId, filtered },
663
+ actions: { insert, close, setActiveIndex: setActiveIndexState },
664
+ meta: { trigger, listId, textareaId, optionId },
665
+ }),
666
+ [
667
+ value,
668
+ query,
669
+ open,
670
+ activeIndex,
671
+ activeId,
672
+ filtered,
673
+ insert,
674
+ close,
675
+ trigger,
676
+ listId,
677
+ textareaId,
678
+ optionId,
679
+ ],
680
+ );
681
+
682
+ const textareaContextValue = useMemo<TextareaWiring>(
683
+ () => ({
684
+ textareaRef,
685
+ textareaId,
686
+ listId,
687
+ open,
688
+ activeId,
689
+ value: value.text,
690
+ onValueInput: handleChange,
691
+ onCaretSync: syncCaret,
692
+ onKeyDownCapture: handleKeyDownCapture,
693
+ onDismiss: close,
694
+ onScrollSync: setScrollTop,
695
+ }),
696
+ [
697
+ textareaId,
698
+ listId,
699
+ open,
700
+ activeId,
701
+ value.text,
702
+ handleChange,
703
+ syncCaret,
704
+ handleKeyDownCapture,
705
+ close,
706
+ ],
707
+ );
708
+
709
+ return (
710
+ <Popover open={open} onOpenChange={setOpen}>
711
+ <MentionInputContext value={contextValue}>
712
+ <TextareaWiringContext value={textareaContextValue}>
713
+ <div
714
+ ref={mergedRootRef}
715
+ data-slot="mention-input"
716
+ className={cn("relative", className)}
717
+ {...props}
718
+ >
719
+ {children}
720
+
721
+ {/*
722
+ Chip overlay — decorative, painted ON TOP of the field.
723
+ Every run is `text-transparent`; only a mention run paints a
724
+ background, and it is a 10% wash, so the real glyphs in the
725
+ textarea below stay legible and stay the ONLY copy of the text
726
+ (native selection, IME and spellcheck are untouched). It sits on
727
+ top rather than behind because a textarea's own background is
728
+ opaque (`bg-background`), which would hide anything underneath.
729
+ */}
730
+ {mirrorStyle ? (
731
+ <div
732
+ data-slot="mention-input-overlay"
733
+ aria-hidden="true"
734
+ className="pointer-events-none select-none text-transparent"
735
+ style={{ ...mirrorStyle, zIndex: 1 }}
736
+ >
737
+ <div style={{ transform: `translateY(${-scrollTop}px)` }}>
738
+ {runs.map((run) =>
739
+ run.mention ? (
740
+ <span
741
+ key={run.key}
742
+ data-slot="mention-input-chip"
743
+ className="box-decoration-clone rounded-sm bg-primary/10"
744
+ >
745
+ {run.text}
746
+ </span>
747
+ ) : (
748
+ <span key={run.key}>{run.text}</span>
749
+ ),
750
+ )}
751
+ </div>
752
+ </div>
753
+ ) : null}
754
+
755
+ {/* Caret measurement mirror — laid out, never painted. */}
756
+ {open && mirrorStyle ? (
757
+ <div
758
+ data-slot="mention-input-caret-mirror"
759
+ aria-hidden="true"
760
+ className="pointer-events-none select-none"
761
+ style={{ ...mirrorStyle, visibility: "hidden", zIndex: -1 }}
762
+ >
763
+ <div style={{ transform: `translateY(${-scrollTop}px)` }}>
764
+ {value.text.slice(0, caret)}
765
+ <span ref={caretMarkerRef}>{value.text.slice(caret) || "."}</span>
766
+ </div>
767
+ </div>
768
+ ) : null}
769
+
770
+ <PopoverAnchor asChild>
771
+ <span
772
+ data-slot="mention-input-anchor"
773
+ aria-hidden="true"
774
+ className="pointer-events-none absolute"
775
+ style={{
776
+ left: caretRect.left,
777
+ top: caretRect.top,
778
+ width: 0,
779
+ height: caretRect.height,
780
+ }}
781
+ />
782
+ </PopoverAnchor>
783
+ </div>
784
+ </TextareaWiringContext>
785
+ </MentionInputContext>
786
+ </Popover>
787
+ );
788
+ });
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // MentionInputTextarea
792
+ // ---------------------------------------------------------------------------
793
+
794
+ interface TextareaWiring {
795
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
796
+ textareaId: string;
797
+ listId: string;
798
+ open: boolean;
799
+ activeId?: string;
800
+ value: string;
801
+ onValueInput: (element: HTMLTextAreaElement) => void;
802
+ onCaretSync: (element: HTMLTextAreaElement) => void;
803
+ onKeyDownCapture: (event: ReactKeyboardEvent<HTMLTextAreaElement>) => void;
804
+ onDismiss: () => void;
805
+ onScrollSync: (scrollTop: number) => void;
806
+ }
807
+
808
+ const TextareaWiringContext = createContext<TextareaWiring | null>(null);
809
+
810
+ export interface MentionInputTextareaProps extends Omit<
811
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
812
+ "value" | "defaultValue"
813
+ > {
814
+ /**
815
+ * Render the consumer's own textarea-rendering component instead of a
816
+ * `Textarea` — e.g. `PromptInputTextarea`. The child must ultimately render a
817
+ * real `<textarea>`; everything this component needs is spread onto it.
818
+ */
819
+ asChild?: boolean;
820
+ }
821
+
822
+ /**
823
+ * The field itself — a real `<textarea>` carrying the combobox semantics.
824
+ *
825
+ * The mention interception is bound as **`onKeyDownCapture`**, not `onKeyDown`.
826
+ * Under `asChild`, Radix `Slot` merges CHILD handlers over slot handlers and
827
+ * runs the child's **first**. So when the child binds `onKeyDown` directly on
828
+ * the host element, a bubble-phase handler here would fire *after* it — the
829
+ * child's Enter→submit would win and the popup would never see the key. React
830
+ * dispatches an element's capture pass before its bubble pass and shares one
831
+ * `SyntheticEvent` between them, so `preventDefault()` here is already visible
832
+ * as `event.defaultPrevented` when the child's handler runs.
833
+ *
834
+ * **Scope of the hazard, stated precisely** (an earlier version of this comment
835
+ * overclaimed): it applies to a child that binds `onKeyDown` on a *host
836
+ * element*. It does NOT apply to `PromptInputTextarea`, which is a *component*
837
+ * that destructures `onKeyDown` out of its own props, calls it first and bails
838
+ * on `defaultPrevented` (`packages/ai/src/prompt-input.tsx`) — that composition
839
+ * works with either binding. The capture binding is what makes the raw-host
840
+ * case correct too, and that is the case the locking test exercises: see
841
+ * "T8 Slot handler-order contract" in `mention-input.test.tsx`, which fails if
842
+ * this is ever moved to `onKeyDown`.
843
+ */
844
+ export const MentionInputTextarea = forwardRef<HTMLTextAreaElement, MentionInputTextareaProps>(
845
+ function MentionInputTextarea(
846
+ {
847
+ asChild,
848
+ className,
849
+ // The seven handlers this component owns are destructured OUT of `props`
850
+ // so `{...props}` can be spread BEFORE them without clobbering them.
851
+ // Spreading props last (the obvious reading of "spread ...props onto the
852
+ // root") silently replaced the component's own wiring: a consumer passing
853
+ // `onChange` used to knock out `wiring.onValueInput`, and since the value
854
+ // is provider-controlled that left the field completely inert. Consumer
855
+ // handlers still run — each one is invoked from inside its counterpart —
856
+ // and every NON-handler prop (`id`, `name`, `aria-*`, `placeholder`, …)
857
+ // still wins, because the spread sits after those defaults.
858
+ onChange,
859
+ onKeyDownCapture,
860
+ onSelect,
861
+ onKeyUp,
862
+ onClick,
863
+ onScroll,
864
+ onBlur,
865
+ ...props
866
+ },
867
+ ref,
868
+ ) {
869
+ const wiring = use(TextareaWiringContext);
870
+ if (!wiring) {
871
+ throw new Error("MentionInputTextarea must be used inside a <MentionInput>.");
872
+ }
873
+ const mergedRef = useMemo(
874
+ () => mergeRefs<HTMLTextAreaElement>(ref, wiring.textareaRef),
875
+ [ref, wiring.textareaRef],
876
+ );
877
+
878
+ const Comp = asChild ? Slot : Textarea;
879
+
880
+ return (
881
+ <Comp
882
+ ref={mergedRef}
883
+ id={wiring.textareaId}
884
+ data-slot="mention-input-textarea"
885
+ // Machine-readable open state. It is a `data-` attribute rather than
886
+ // `aria-expanded` because `aria-expanded` is NOT in `textbox`'s
887
+ // supported-property set — see the ARIA note below.
888
+ data-state={wiring.open ? "open" : "closed"}
889
+ // The field stays an implicit `textbox`. `role="combobox"` on a
890
+ // `<textarea>` is invalid per ARIA in HTML (`<textarea>` allows no role
891
+ // override) and axe's `aria-allowed-role` — enabled by default — flags
892
+ // it on every story; `aria-expanded` on a textbox is worse still
893
+ // (`aria-allowed-attr`, CRITICAL). All four attributes below ARE in
894
+ // `textbox`'s supported set, so this arrangement is both spec-valid and
895
+ // axe-clean while keeping the announcement that matters: with
896
+ // `aria-activedescendant` on a textbox, AT reads the highlighted option
897
+ // as the user arrows through the roster.
898
+ aria-autocomplete="list"
899
+ aria-haspopup="listbox"
900
+ // Only while the popup is mounted: the listbox lives in a Radix portal
901
+ // that unmounts on close, and an `aria-controls` pointing at a removed
902
+ // node is a dangling reference.
903
+ aria-controls={wiring.open ? wiring.listId : undefined}
904
+ aria-activedescendant={wiring.activeId}
905
+ autoComplete="off"
906
+ value={wiring.value}
907
+ className={cn("relative z-0", className)}
908
+ {...props}
909
+ onChange={(event) => {
910
+ wiring.onValueInput(event.currentTarget);
911
+ onChange?.(event);
912
+ }}
913
+ onKeyDownCapture={(event) => {
914
+ wiring.onKeyDownCapture(event);
915
+ onKeyDownCapture?.(event);
916
+ }}
917
+ onSelect={(event) => {
918
+ wiring.onCaretSync(event.currentTarget);
919
+ onSelect?.(event);
920
+ }}
921
+ onKeyUp={(event) => {
922
+ wiring.onCaretSync(event.currentTarget);
923
+ onKeyUp?.(event);
924
+ }}
925
+ onClick={(event) => {
926
+ wiring.onCaretSync(event.currentTarget);
927
+ onClick?.(event);
928
+ }}
929
+ onScroll={(event) => {
930
+ wiring.onScrollSync(event.currentTarget.scrollTop);
931
+ onScroll?.(event);
932
+ }}
933
+ onBlur={(event) => {
934
+ wiring.onDismiss();
935
+ onBlur?.(event);
936
+ }}
937
+ />
938
+ );
939
+ },
940
+ );
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // MentionInputContent / List / Item / Empty
944
+ // ---------------------------------------------------------------------------
945
+
946
+ export type MentionInputContentProps = React.ComponentPropsWithoutRef<typeof PopoverContent>;
947
+
948
+ /** The floating panel. Never takes focus — the caret has to stay in the field. */
949
+ export const MentionInputContent = forwardRef<
950
+ React.ElementRef<typeof PopoverContent>,
951
+ MentionInputContentProps
952
+ >(function MentionInputContent({ className, align = "start", ...props }, ref) {
953
+ const { meta } = useMentionInput();
954
+ const wiring = use(TextareaWiringContext);
955
+
956
+ return (
957
+ <PopoverContent
958
+ ref={ref}
959
+ data-slot="mention-input-content"
960
+ align={align}
961
+ // Radix `PopoverContent` defaults to `role="dialog"`, which is wrong here
962
+ // twice over: a combobox popup is not a dialog, and an unnamed dialog is
963
+ // an axe `aria-dialog-name` violation (serious). The panel is a plain
964
+ // container — the `role="listbox"` inside carries all the semantics.
965
+ role="presentation"
966
+ // No border, no ring: PopoverContent already carries `shadow-ring-md`.
967
+ className={cn("w-72 p-0", className)}
968
+ onOpenAutoFocus={(event) => event.preventDefault()}
969
+ onCloseAutoFocus={(event) => event.preventDefault()}
970
+ onPointerDownOutside={(event) => {
971
+ // Clicking the field itself only moves the caret; it must not count as
972
+ // a dismissal, or the popup would fight the caret it depends on.
973
+ const target = event.detail.originalEvent.target as Node | null;
974
+ if (target && wiring?.textareaRef.current?.contains(target)) event.preventDefault();
975
+ props.onPointerDownOutside?.(event);
976
+ }}
977
+ id={`${meta.listId}-content`}
978
+ {...props}
979
+ />
980
+ );
981
+ });
982
+
983
+ export interface MentionInputListProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
984
+ /**
985
+ * Either static nodes, or a render function called once per filtered option.
986
+ * The function form is the one to reach for — the parent has to hand each
987
+ * option back, which is exactly what `children`-as-a-function is for.
988
+ */
989
+ children?: ReactNode | ((option: MentionOption, index: number) => ReactNode);
990
+ }
991
+
992
+ /** The listbox. Owns the accessible name and the option mapping. */
993
+ export const MentionInputList = forwardRef<HTMLDivElement, MentionInputListProps>(
994
+ function MentionInputList({ className, children, ...props }, ref) {
995
+ const { t } = useLocale();
996
+ const { state, meta } = useMentionInput();
997
+
998
+ return (
999
+ <div
1000
+ ref={ref}
1001
+ id={meta.listId}
1002
+ data-slot="mention-input-list"
1003
+ role="listbox"
1004
+ aria-label={t("ui.mentionInput.listLabel")}
1005
+ className={cn("max-h-60 overflow-y-auto p-1", className)}
1006
+ {...props}
1007
+ >
1008
+ {typeof children === "function"
1009
+ ? state.filtered.map((option, index) => children(option, index))
1010
+ : children}
1011
+ </div>
1012
+ );
1013
+ },
1014
+ );
1015
+
1016
+ export interface MentionInputItemProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
1017
+ option: MentionOption;
1018
+ children?: ReactNode;
1019
+ }
1020
+
1021
+ /**
1022
+ * One option row.
1023
+ *
1024
+ * The row derives its own index from the provider's `filtered` list rather than
1025
+ * accepting one, so its `id` and `aria-selected` can never disagree with the
1026
+ * `aria-activedescendant` the field is publishing.
1027
+ */
1028
+ export const MentionInputItem = forwardRef<HTMLDivElement, MentionInputItemProps>(
1029
+ function MentionInputItem({ option, className, children, ...props }, ref) {
1030
+ const { state, actions, meta } = useMentionInput();
1031
+ const index = state.filtered.findIndex((candidate) => candidate.id === option.id);
1032
+ const isActive = index >= 0 && index === state.activeIndex;
1033
+
1034
+ return (
1035
+ <div
1036
+ ref={ref}
1037
+ id={index >= 0 ? meta.optionId(index) : undefined}
1038
+ data-slot="mention-input-item"
1039
+ role="option"
1040
+ aria-selected={isActive}
1041
+ aria-disabled={option.disabled || undefined}
1042
+ data-active={isActive || undefined}
1043
+ className={cn(
1044
+ "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5",
1045
+ "text-body outline-none transition-colors duration-fast motion-reduce:transition-none",
1046
+ "data-[active=true]:bg-accent data-[active=true]:text-accent-foreground",
1047
+ "aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4",
1048
+ className,
1049
+ )}
1050
+ // Keep the caret where it is: a mousedown inside the portal would
1051
+ // otherwise blur the textarea before the click ever lands.
1052
+ onMouseDown={(event) => {
1053
+ event.preventDefault();
1054
+ props.onMouseDown?.(event);
1055
+ }}
1056
+ onClick={(event) => {
1057
+ actions.insert(option);
1058
+ props.onClick?.(event);
1059
+ }}
1060
+ onMouseEnter={(event) => {
1061
+ if (!option.disabled && index >= 0) actions.setActiveIndex(index);
1062
+ props.onMouseEnter?.(event);
1063
+ }}
1064
+ {...props}
1065
+ >
1066
+ {children ?? (
1067
+ <span className="flex min-w-0 flex-col">
1068
+ <span className="truncate">{option.label}</span>
1069
+ {option.description ? (
1070
+ <span className="truncate text-meta text-muted-foreground">{option.description}</span>
1071
+ ) : null}
1072
+ </span>
1073
+ )}
1074
+ </div>
1075
+ );
1076
+ },
1077
+ );
1078
+
1079
+ export type MentionInputEmptyProps = HTMLAttributes<HTMLDivElement>;
1080
+
1081
+ /**
1082
+ * The "nothing matches" line. A **sibling** of the listbox, never a child —
1083
+ * a `role="listbox"` may only own `option` and `group` children.
1084
+ */
1085
+ export const MentionInputEmpty = forwardRef<HTMLDivElement, MentionInputEmptyProps>(
1086
+ function MentionInputEmpty({ className, children, ...props }, ref) {
1087
+ const { t } = useLocale();
1088
+ const { state } = useMentionInput();
1089
+ if (state.filtered.length > 0) return null;
1090
+
1091
+ return (
1092
+ <div
1093
+ ref={ref}
1094
+ data-slot="mention-input-empty"
1095
+ className={cn("py-6 text-center text-body text-muted-foreground", className)}
1096
+ {...props}
1097
+ >
1098
+ {children ?? t("noResults")}
1099
+ </div>
1100
+ );
1101
+ },
1102
+ );