@elabs-ai/components-ui 4.0.0 → 4.2.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -4,7 +4,7 @@ import { Button } from "../button";
4
4
  import { MatchHighlight, queryToRanges } from "./match-highlight";
5
5
 
6
6
  const meta = {
7
- title: "Typography/MatchHighlight",
7
+ title: "Display/MatchHighlight",
8
8
  component: MatchHighlight,
9
9
  tags: ["autodocs"],
10
10
  parameters: {
@@ -25,6 +25,14 @@ import type { MentionOption, MentionValue } from "./mention-value";
25
25
  * lock that only holds when it runs alone is not a lock at all in CI — so it
26
26
  * gets its own story file, which the runner loads as its own test file.
27
27
  *
28
+ * It shares the `Forms/MentionInput` TITLE with the main story file so it reads
29
+ * as one sidebar entry rather than a sub-group of its own (RM-005). That is a
30
+ * sidebar concern only: `@storybook/addon-vitest` turns each story FILE into its
31
+ * own test file, so the isolation above survives the shared title. Do NOT
32
+ * "finish the job" by moving these stories into `mention-input.stories.tsx` —
33
+ * that is the exact co-residency the audit measured as green-with-the-bug (ADR
34
+ * 0023 §6).
35
+ *
28
36
  * ### What it pins
29
37
  *
30
38
  * Every box-affecting property of the field is nailed to absolute pixels, so
@@ -39,7 +47,7 @@ import type { MentionOption, MentionValue } from "./mention-value";
39
47
  * the test rather than argued for in a comment.
40
48
  */
41
49
  const meta = {
42
- title: "Forms/MentionInput/Mirror re-measure",
50
+ title: "Forms/MentionInput",
43
51
  component: MentionInput,
44
52
  parameters: {
45
53
  docs: {
@@ -77,7 +77,7 @@ function Field({
77
77
  }) {
78
78
  const id = useId();
79
79
  return (
80
- <div className="w-[28rem] space-y-2">
80
+ <div className="w-full max-w-[28rem] space-y-2">
81
81
  <Label htmlFor={id}>{label}</Label>
82
82
  <MentionInput options={options} {...props}>
83
83
  <MentionInputTextarea id={id} placeholder="Type @ to mention someone…" />
@@ -193,7 +193,7 @@ export const SerializedOutput: Story = {
193
193
  const payload = useMemo(() => serializeMentions(value), [value]);
194
194
 
195
195
  return (
196
- <div className="w-[28rem] space-y-4">
196
+ <div className="w-full max-w-[28rem] space-y-4">
197
197
  <div className="space-y-2">
198
198
  <Label htmlFor={id}>Comment</Label>
199
199
  <MentionInput options={ROSTER} value={value} onValueChange={setValue}>
@@ -235,7 +235,7 @@ export const AsyncRoster: Story = {
235
235
  const [options, setOptions] = useState<MentionOption[]>([]);
236
236
 
237
237
  return (
238
- <div className="w-[28rem] space-y-2">
238
+ <div className="w-full max-w-[28rem] space-y-2">
239
239
  <Label htmlFor={id}>Assign reviewers</Label>
240
240
  <MentionInput
241
241
  options={options}
@@ -1,6 +1,6 @@
1
1
  import { createRef, useState } from "react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
- import { render, screen } from "@testing-library/react";
3
+ import { act, render, screen } from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
  import {
6
6
  MentionInput,
@@ -615,3 +615,53 @@ describe("onQueryChange", () => {
615
615
  expect(onQueryChange).toHaveBeenLastCalledWith(null);
616
616
  });
617
617
  });
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Mirror re-measure — scoped MutationObserver (perf review §3.3)
621
+ // ---------------------------------------------------------------------------
622
+
623
+ describe("mirror re-measure observer scope", () => {
624
+ /**
625
+ * Regression lock for the fix: the observer that used to watch `<html>`
626
+ * AND `<body>` for `class`/`style`/`data-theme`/`data-density`/
627
+ * `data-decoration` now watches only the field's closest `[data-theme]`
628
+ * ancestor (`<html>` here, since jsdom's default document carries none),
629
+ * filtered to `data-theme`/`data-density`/`data-decoration` — so a
630
+ * `style` mutation on `<body>` (what Radix's scroll-lock does on every
631
+ * `Sheet`/`Dialog`/`Popover` open, anywhere in the app) no longer causes a
632
+ * re-measure, while a real theme/density/decoration attribute flip still
633
+ * does.
634
+ */
635
+ it("ignores an unrelated body style mutation but reacts to a theme attribute flip", async () => {
636
+ render(<Harness />);
637
+ // Let the mount-time layout effects (which call getComputedStyle
638
+ // themselves) settle before counting.
639
+ await Promise.resolve();
640
+ const spy = vi.spyOn(window, "getComputedStyle");
641
+ spy.mockClear();
642
+
643
+ // Simulate Radix's scroll-lock: an inline style write on <body>, nothing
644
+ // to do with this field's own theme/density/decoration.
645
+ document.body.style.paddingRight = "17px";
646
+ await Promise.resolve();
647
+ await Promise.resolve();
648
+ expect(spy).not.toHaveBeenCalled();
649
+ document.body.style.removeProperty("padding-right");
650
+
651
+ // A real theme attribute flip on the scope the observer actually
652
+ // watches (<html>, the field's closest `[data-theme]` ancestor — there
653
+ // is none here, so it falls back to the document root) DOES trigger a
654
+ // re-measure.
655
+ try {
656
+ await act(async () => {
657
+ document.documentElement.setAttribute("data-theme", "dark");
658
+ await vi.waitFor(() => {
659
+ expect(spy).toHaveBeenCalled();
660
+ });
661
+ });
662
+ } finally {
663
+ document.documentElement.removeAttribute("data-theme");
664
+ spy.mockRestore();
665
+ }
666
+ });
667
+ });
@@ -20,6 +20,7 @@ import {
20
20
  import { Slot } from "@radix-ui/react-slot";
21
21
  import { cn } from "../../lib/cn";
22
22
  import { mergeRefs } from "../../lib/merge-refs";
23
+ import { findTriggerQuery } from "../../lib/trigger-query";
23
24
  import { useLocale } from "../locale-provider";
24
25
  import { Popover, PopoverAnchor, PopoverContent } from "../popover";
25
26
  import { Textarea } from "../textarea";
@@ -88,43 +89,6 @@ export function useMentionInput(): MentionInputContextValue {
88
89
  // Query detection
89
90
  // ---------------------------------------------------------------------------
90
91
 
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
92
  /** Next enabled index in `list`, wrapping; `-1` when none is selectable. */
129
93
  function step(list: MentionOption[], from: number, direction: 1 | -1): number {
130
94
  const count = list.length;
@@ -190,6 +154,45 @@ const MIRRORED_PROPERTIES = [
190
154
  "wordBreak",
191
155
  ] as const satisfies readonly (keyof CSSStyleDeclaration & string)[];
192
156
 
157
+ /**
158
+ * Extra keys `readMirrorStyle` writes onto the mirror style object BESIDES
159
+ * `MIRRORED_PROPERTIES` (some of which — `paddingRight`, `whiteSpace` — it
160
+ * overrides rather than adds). Used by `stylesEqual` below so the "did
161
+ * anything actually change" check compares every key the object can carry,
162
+ * without re-deriving that list from an object literal on every call.
163
+ */
164
+ const EXTRA_MIRROR_KEYS = [
165
+ "position",
166
+ "left",
167
+ "top",
168
+ "width",
169
+ "height",
170
+ "borderStyle",
171
+ "borderColor",
172
+ "overflow",
173
+ "margin",
174
+ ] as const;
175
+
176
+ /**
177
+ * Field-by-field comparison of two mirror style snapshots (perf review §3.3:
178
+ * "two JSON.stringify per callback"). Both objects always carry the exact
179
+ * same key set (`MIRRORED_PROPERTIES` + `EXTRA_MIRROR_KEYS`), so a plain
180
+ * per-key `!==` walk is both cheaper and exact — `JSON.stringify` also
181
+ * silently drops `undefined`-valued keys and is sensitive to key order in a
182
+ * way this object's construction never guarantees.
183
+ */
184
+ function stylesEqual(a: CSSProperties, b: CSSProperties): boolean {
185
+ const record = a as Record<string, unknown>;
186
+ const other = b as Record<string, unknown>;
187
+ for (const key of MIRRORED_PROPERTIES) {
188
+ if (record[key] !== other[key]) return false;
189
+ }
190
+ for (const key of EXTRA_MIRROR_KEYS) {
191
+ if (record[key] !== other[key]) return false;
192
+ }
193
+ return true;
194
+ }
195
+
193
196
  function readMirrorStyle(textarea: HTMLTextAreaElement): CSSProperties {
194
197
  const computed = getComputedStyle(textarea);
195
198
  const style: Record<string, string | number> = {};
@@ -354,7 +357,13 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
354
357
  // ------------------------------------------------------------------
355
358
  // Query / open state
356
359
  // ------------------------------------------------------------------
357
- const queryInfo = useMemo(() => findQuery(value, caret, trigger), [value, caret, trigger]);
360
+ const queryInfo = useMemo(
361
+ () =>
362
+ findTriggerQuery(value.text, caret, trigger, {
363
+ isTriggerConsumed: (start) => mentionAt(value, start, trigger) !== undefined,
364
+ }),
365
+ [value, caret, trigger],
366
+ );
358
367
 
359
368
  // Escape (and an outside dismissal) suppress the popup for the run it was
360
369
  // dismissed on, without touching the text — the query is still there, the
@@ -570,9 +579,7 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
570
579
  const element = textareaRef.current;
571
580
  if (!element) return;
572
581
  const next = readMirrorStyle(element);
573
- setMirrorStyle((previous) =>
574
- previous && JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
575
- );
582
+ setMirrorStyle((previous) => (previous && stylesEqual(previous, next) ? previous : next));
576
583
  }, []);
577
584
 
578
585
  useLayoutEffect(() => {
@@ -601,14 +608,27 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
601
608
 
602
609
  // Theme / density / decoration switches re-resolve the INHERITED font and
603
610
  // 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, {
611
+ //
612
+ // Scoped to the field's OWN nearest `[data-theme]` ancestor (falling
613
+ // back to `<html>` when the field isn't inside a scoped region), never
614
+ // the whole document — and filtered to only the three attributes a
615
+ // theme/density/decoration switch actually writes, never generic
616
+ // `class`/`style` on `<html>`/`<body>`. The broader, document-wide
617
+ // observer this replaced fired on every Radix `Sheet`/`Dialog`/`Popover`
618
+ // scroll-lock ANYWHERE in the app (it toggles inline `style` —
619
+ // `overflow`, `padding-right` — on `<body>` on every open/close), which
620
+ // has nothing to do with this field's own metrics (perf review §3.3).
621
+ const themeScope =
622
+ element.closest("[data-theme]") ??
623
+ (typeof document !== "undefined" ? document.documentElement : null);
624
+ if (themeScope) {
625
+ const themeObserver = new MutationObserver(remeasureMirror);
626
+ themeObserver.observe(themeScope, {
607
627
  attributes: true,
608
- attributeFilter: ["data-theme", "data-density", "data-decoration", "class", "style"],
628
+ attributeFilter: ["data-theme", "data-density", "data-decoration"],
609
629
  });
630
+ teardown.push(() => themeObserver.disconnect());
610
631
  }
611
- teardown.push(() => rootObserver.disconnect());
612
632
  }
613
633
 
614
634
  // A web font landing after first paint changes every advance width. This
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { replaceTriggerRun } from "../../lib/trigger-query";
2
3
 
3
4
  /**
4
5
  * The pure offset algebra behind `MentionInput`.
@@ -138,7 +139,12 @@ export function insertMention(
138
139
  caret: number,
139
140
  ): InsertMentionResult {
140
141
  const inserted = `${trigger}${option.label} `;
141
- const nextText = value.text.slice(0, queryStart) + inserted + value.text.slice(caret);
142
+ const { text: nextText, caret: nextCaret } = replaceTriggerRun(
143
+ value.text,
144
+ queryStart,
145
+ caret,
146
+ inserted,
147
+ );
142
148
  const delta = inserted.length - (caret - queryStart);
143
149
 
144
150
  const mentions: Mention[] = [];
@@ -160,7 +166,7 @@ export function insertMention(
160
166
  (m) => nextText.slice(m.start, mentionEnd(m, trigger)) === trigger + m.label,
161
167
  ),
162
168
  },
163
- caret: queryStart + inserted.length,
169
+ caret: nextCaret,
164
170
  };
165
171
  }
166
172
 
@@ -25,6 +25,7 @@ export const Menubar = forwardRef<
25
25
  return (
26
26
  <MenubarPrimitive.Root
27
27
  ref={ref}
28
+ data-slot="menubar"
28
29
  className={cn("flex h-9 items-center gap-1 rounded-md bg-card p-1 shadow-ring-sm", className)}
29
30
  {...props}
30
31
  />
@@ -38,8 +39,9 @@ export const MenubarTrigger = forwardRef<
38
39
  return (
39
40
  <MenubarPrimitive.Trigger
40
41
  ref={ref}
42
+ data-slot="menubar-trigger"
41
43
  className={cn(
42
- "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
44
+ "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-body font-medium outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
43
45
  className,
44
46
  )}
45
47
  {...props}
@@ -58,6 +60,7 @@ export const MenubarContent = forwardRef<
58
60
  <MenubarPrimitive.Portal>
59
61
  <MenubarPrimitive.Content
60
62
  ref={ref}
63
+ data-slot="menubar-content"
61
64
  align={align}
62
65
  alignOffset={alignOffset}
63
66
  sideOffset={sideOffset}
@@ -80,8 +83,9 @@ export const MenubarItem = forwardRef<
80
83
  return (
81
84
  <MenubarPrimitive.Item
82
85
  ref={ref}
86
+ data-slot="menubar-item"
83
87
  className={cn(
84
- "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
88
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
85
89
  inset && "ps-8",
86
90
  className,
87
91
  )}
@@ -97,8 +101,9 @@ export const MenubarSubTrigger = forwardRef<
97
101
  return (
98
102
  <MenubarPrimitive.SubTrigger
99
103
  ref={ref}
104
+ data-slot="menubar-sub-trigger"
100
105
  className={cn(
101
- "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
106
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
102
107
  inset && "ps-8",
103
108
  className,
104
109
  )}
@@ -117,6 +122,7 @@ export const MenubarSubContent = forwardRef<
117
122
  return (
118
123
  <MenubarPrimitive.SubContent
119
124
  ref={ref}
125
+ data-slot="menubar-sub-content"
120
126
  className={cn(
121
127
  "z-50 min-w-[8rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
122
128
  "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
@@ -135,9 +141,10 @@ export const MenubarCheckboxItem = forwardRef<
135
141
  return (
136
142
  <MenubarPrimitive.CheckboxItem
137
143
  ref={ref}
144
+ data-slot="menubar-checkbox-item"
138
145
  checked={checked}
139
146
  className={cn(
140
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
147
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
141
148
  className,
142
149
  )}
143
150
  {...props}
@@ -159,8 +166,9 @@ export const MenubarRadioItem = forwardRef<
159
166
  return (
160
167
  <MenubarPrimitive.RadioItem
161
168
  ref={ref}
169
+ data-slot="menubar-radio-item"
162
170
  className={cn(
163
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
171
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
164
172
  className,
165
173
  )}
166
174
  {...props}
@@ -182,6 +190,7 @@ export const MenubarSeparator = forwardRef<
182
190
  return (
183
191
  <MenubarPrimitive.Separator
184
192
  ref={ref}
193
+ data-slot="menubar-separator"
185
194
  className={cn("-mx-1 my-1 h-px bg-border", className)}
186
195
  {...props}
187
196
  />
@@ -191,7 +200,8 @@ export const MenubarSeparator = forwardRef<
191
200
  export function MenubarShortcut({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
192
201
  return (
193
202
  <span
194
- className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
203
+ data-slot="menubar-shortcut"
204
+ className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
195
205
  {...props}
196
206
  />
197
207
  );
@@ -11,9 +11,11 @@ const meta = {
11
11
  docs: {
12
12
  description: {
13
13
  component:
14
- "The canonical KPI tile, owned by @elabs-ai/components-ui (ADR 0012). @elabs-ai/components-charts re-exports this exact " +
15
- "component as Charts/MetricCard they are the same component, so import from @elabs-ai/components-ui for app " +
16
- "UI, or from @elabs-ai/components-charts when already in a charts context. Don't fork a second KPI tile.",
14
+ "The canonical KPI tile, owned by @elabs-ai/components-ui (ADR 0012). @elabs-ai/components-charts re-exports this " +
15
+ "EXACT component, so import it from @elabs-ai/components-ui for app UI, or from @elabs-ai/components-charts when " +
16
+ "you are already in a charts context both resolve to the component documented here, which is why the " +
17
+ "sidebar carries one MetricCard entry and not two. Don't fork a second KPI tile. The `visual` slot is " +
18
+ "shown by the WithSparkline story, which fills it with a real @elabs-ai/components-charts mini-chart.",
17
19
  },
18
20
  },
19
21
  },
@@ -166,9 +166,28 @@ export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(function M
166
166
  {loading ? (
167
167
  <Skeleton className="h-4 w-24" />
168
168
  ) : (
169
- <span className="text-body font-medium text-muted-foreground">{label}</span>
169
+ /* `min-w-0` + `truncate`, not just the text utility: a flex child's
170
+ default `min-width: auto` refuses to shrink below its content, so
171
+ without it the label OVERFLOWS the row and `Card`'s own
172
+ `overflow-hidden` hard-clips it — characters lost mid-word, no
173
+ ellipsis, no title. Measured on a 230px pane: "Awaiting approval"
174
+ rendered as "Awaiting approva". See interaction-guidelines.md
175
+ § Content handling ("flex children need `min-w-0`"). `title` only
176
+ when the label is a string — there is nothing to put in the
177
+ attribute otherwise, and the ellipsis needs a way back to the
178
+ full words. */
179
+ <span
180
+ className="min-w-0 truncate text-body font-medium text-muted-foreground"
181
+ title={typeof label === "string" ? label : undefined}
182
+ >
183
+ {label}
184
+ </span>
170
185
  )}
171
- {icon ? <span className="text-muted-foreground [&_svg]:size-4">{icon}</span> : null}
186
+ {/* `shrink-0`: the icon is a fixed 16px mark, so the LABEL is what
187
+ gives way when the row runs out of width. */}
188
+ {icon ? (
189
+ <span className="shrink-0 text-muted-foreground [&_svg]:size-4">{icon}</span>
190
+ ) : null}
172
191
  </div>
173
192
  <div className="flex items-baseline gap-2">
174
193
  {loading ? (
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { AppWindow, Bot, Sparkles } from "lucide-react";
3
3
  import { useState } from "react";
4
+ import { within } from "storybook/test";
4
5
  import { ModelPicker } from "./model-picker";
5
6
  import type { ModelPickerGroup } from "./model-picker-state";
6
7
 
@@ -61,9 +62,15 @@ const meta = {
61
62
  docs: {
62
63
  description: {
63
64
  component:
65
+ "The INLINE composer pill; a full palette in a dialog is a `CommandDialog` " +
66
+ "you compose yourself — see " +
67
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
64
68
  "A compact pill that opens a grouped, searchable target list anchored under itself — " +
65
- "sized for a composer footer. The inline sibling of `@elabs-ai/components-ai`'s " +
66
- "`ModelSelector`, which is the same `Command` internals in a modal `Dialog`.\n\n" +
69
+ "sized for a composer footer.\n\n" +
70
+ "**Which one to reach for.** An inline pill in a composer footer → this component. " +
71
+ "A full ⌘K palette → compose `CommandDialog` + `Command*` from " +
72
+ "`@elabs-ai/components-ui` directly; there is no separate selector component " +
73
+ "wrapping them.\n\n" +
67
74
  "**`Command` lives in a `Popover`, never a `DropdownMenu`.** `DropdownMenuContent` owns " +
68
75
  "roving tabindex and its own typeahead; both fight a real `<input>` in its subtree, so " +
69
76
  "arrow keys move the menu's focus instead of cmdk's highlight and typing never reaches " +
@@ -80,9 +87,19 @@ type Story = StoryObj<typeof meta>;
80
87
 
81
88
  export const Default: Story = {};
82
89
 
83
- /** Skeleton rows while the first load is in flight. */
90
+ /**
91
+ * Skeleton rows while the first load is in flight.
92
+ *
93
+ * The `play` opens the popover — issue #121: axe never rendered `CommandList`
94
+ * (cmdk's `role="listbox"`) until a story actually opened it, which is how a
95
+ * structural `aria-required-children` violation shipped unnoticed.
96
+ */
84
97
  export const Loading: Story = {
85
98
  args: { groups: [], status: "loading", triggerLabel: "Loading…", value: undefined },
99
+ play: async ({ canvas, canvasElement, userEvent }) => {
100
+ await userEvent.click(canvas.getByRole("combobox"));
101
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
102
+ },
86
103
  };
87
104
 
88
105
  /**
@@ -103,11 +120,54 @@ export const ErrorState: Story = {
103
120
  value: undefined,
104
121
  onRetry: () => {},
105
122
  },
123
+ play: async ({ canvas, canvasElement, userEvent }) => {
124
+ await userEvent.click(canvas.getByRole("combobox"));
125
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
126
+ },
106
127
  };
107
128
 
108
129
  /** Nothing to show, but nothing went wrong — a different message, deliberately. */
109
130
  export const Empty: Story = {
110
131
  args: { groups: [], status: "empty", triggerLabel: "No targets", value: undefined },
132
+ play: async ({ canvas, canvasElement, userEvent }) => {
133
+ await userEvent.click(canvas.getByRole("combobox"));
134
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
135
+ },
136
+ };
137
+
138
+ /**
139
+ * Nothing to show, nothing went wrong, AND a retry is offered — the empty
140
+ * panel's `Retry` button, the specific case where the button previously
141
+ * landed inside `role="listbox"` (issue #121).
142
+ */
143
+ export const EmptyWithRetry: Story = {
144
+ args: {
145
+ groups: [],
146
+ status: "empty",
147
+ triggerLabel: "No targets",
148
+ value: undefined,
149
+ onRetry: () => {},
150
+ },
151
+ play: async ({ canvas, canvasElement, userEvent }) => {
152
+ await userEvent.click(canvas.getByRole("combobox"));
153
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
154
+ },
155
+ };
156
+
157
+ /**
158
+ * The everyday path issue #121 was really about: a fully-populated, working
159
+ * list where the search query happens to match nothing. `CommandEmpty`
160
+ * renders while the list itself stays mounted with zero options — the case
161
+ * that shipped broken because no story ever typed a non-matching query.
162
+ */
163
+ export const ListNoSearchResults: Story = {
164
+ play: async ({ canvas, canvasElement, userEvent }) => {
165
+ await userEvent.click(canvas.getByRole("combobox"));
166
+ const body = within(canvasElement.ownerDocument.body);
167
+ await body.findByRole("dialog");
168
+ await userEvent.type(body.getByPlaceholderText("Search…"), "zzzzzznomatch");
169
+ await body.findByText("No results.");
170
+ },
111
171
  };
112
172
 
113
173
  export const Disabled: Story = { args: { disabled: true } };
@@ -228,6 +228,69 @@ describe("ModelPicker", () => {
228
228
  });
229
229
  });
230
230
 
231
+ // Issue #121 — `CommandList` IS the ARIA listbox (cmdk hardcodes
232
+ // role="listbox" on it); ARIA only allows a listbox to own `option`/`group`.
233
+ // This asserts the CAUSE (no disallowed descendant), not axe's message, so
234
+ // it survives an axe-core upgrade — the story-level `play` functions run
235
+ // the actual blocking axe rule.
236
+ describe("CommandList owns only option/group content (ARIA listbox contract)", () => {
237
+ const disallowedSelector =
238
+ '[role]:not([role="option"]):not([role="group"]):not([role="presentation"])';
239
+
240
+ const assertListClean = () => {
241
+ const list = document.querySelector('[data-slot="command-list"]')!;
242
+ expect(list).not.toBeNull();
243
+ expect(list.querySelector(disallowedSelector)).toBeNull();
244
+ return list as HTMLElement;
245
+ };
246
+
247
+ it("loading body: no role=status descendant inside the listbox", async () => {
248
+ render(<ModelPicker groups={[]} status="loading" triggerLabel="Pick" />);
249
+ await open();
250
+ // The list stays mounted (so CommandInput's aria-controls still
251
+ // resolves) but owns no option/group content while loading — the
252
+ // loading panel is a sibling, not a child.
253
+ assertListClean();
254
+ expect(document.querySelector('[data-slot="model-picker-loading"]')).not.toBeNull();
255
+ });
256
+
257
+ it("error body: the retry button is not a descendant of the listbox", async () => {
258
+ const onRetry = vi.fn();
259
+ render(<ModelPicker groups={[]} status="error" triggerLabel="Pick" onRetry={onRetry} />);
260
+ await open();
261
+ const list = assertListClean();
262
+ const retryButton = screen.getByRole("button", { name: "Retry" });
263
+ expect(list).not.toContainElement(retryButton);
264
+ });
265
+
266
+ it("empty body (with retry): the retry button is not a descendant of the listbox", async () => {
267
+ const onRetry = vi.fn();
268
+ render(<ModelPicker groups={[]} status="empty" triggerLabel="Pick" onRetry={onRetry} />);
269
+ await open();
270
+ const list = assertListClean();
271
+ const retryButton = screen.getByRole("button", { name: "Retry" });
272
+ expect(list).not.toContainElement(retryButton);
273
+ });
274
+
275
+ it("populated list with a non-matching search: CommandEmpty is not a descendant of the listbox", async () => {
276
+ render(<ModelPicker groups={groups} triggerLabel="Pick" />);
277
+ await open();
278
+ await userEvent.type(screen.getByPlaceholderText("Search…"), "zzzzzznomatch");
279
+ const noResults = await screen.findByText("No results.");
280
+ const list = assertListClean();
281
+ expect(list).not.toContainElement(noResults);
282
+ });
283
+
284
+ it("CommandInput's aria-controls still resolves to the mounted (empty) listbox", async () => {
285
+ render(<ModelPicker groups={[]} status="empty" triggerLabel="Pick" />);
286
+ await open();
287
+ const input = screen.getByPlaceholderText("Search…");
288
+ const controlsId = input.getAttribute("aria-controls");
289
+ expect(controlsId).toBeTruthy();
290
+ expect(document.getElementById(controlsId!)).not.toBeNull();
291
+ });
292
+ });
293
+
231
294
  it("uncontrolled mode: trigger click still opens and closes on its own (regression)", async () => {
232
295
  const onSelect = vi.fn();
233
296
  render(<ModelPicker groups={groups} triggerLabel="Pick" onSelect={onSelect} />);