@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
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  // a2ui.exposed: yes
2
4
  import {
3
5
  forwardRef,
@@ -10,6 +12,7 @@ import {
10
12
  } from "react";
11
13
  import { cn } from "../../lib/cn";
12
14
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
15
+ import { useLocale } from "../locale-provider";
13
16
 
14
17
  // ---------------------------------------------------------------------------
15
18
  // Token vocabulary
@@ -120,7 +123,7 @@ const Swatch = forwardRef<HTMLButtonElement, SwatchProps>(function Swatch(
120
123
  selected
121
124
  ? "border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
122
125
  : "border-transparent hover:border-foreground/40",
123
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
126
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
124
127
  className,
125
128
  )}
126
129
  {...props}
@@ -194,12 +197,14 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
194
197
  onValueChange,
195
198
  swatches = COLOR_TOKENS,
196
199
  allowCustom = false,
197
- "aria-label": ariaLabel = "Pick color",
200
+ "aria-label": ariaLabel,
198
201
  className,
199
202
  ...props
200
203
  },
201
204
  ref,
202
205
  ) {
206
+ const { t } = useLocale();
207
+ const resolvedAriaLabel = ariaLabel ?? t("ui.colorPicker.pickColor");
203
208
  const isControlled = valueProp !== undefined;
204
209
  const [internalValue, setInternalValue] = useState<ColorToken | string>(
205
210
  defaultValue ?? "chart-1",
@@ -259,19 +264,19 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
259
264
  <PopoverTrigger asChild>
260
265
  <button
261
266
  type="button"
262
- aria-label={ariaLabel}
267
+ aria-label={resolvedAriaLabel}
263
268
  aria-haspopup="dialog"
264
269
  aria-expanded={open}
265
270
  className={cn(
266
- "inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm",
271
+ "inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-body shadow-sm",
267
272
  "transition-[color,box-shadow] duration-fast",
268
273
  "hover:bg-accent hover:text-accent-foreground",
269
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
274
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
270
275
  "disabled:pointer-events-none disabled:opacity-50",
271
276
  )}
272
277
  >
273
278
  <TriggerSwatch value={current} />
274
- <span className="text-muted-foreground">{current || "None"}</span>
279
+ <span className="text-muted-foreground">{current || t("ui.colorPicker.none")}</span>
275
280
  </button>
276
281
  </PopoverTrigger>
277
282
 
@@ -279,7 +284,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
279
284
  <div
280
285
  ref={groupRef}
281
286
  role="radiogroup"
282
- aria-label="Color swatches"
287
+ aria-label={t("ui.colorPicker.swatches")}
283
288
  className="flex flex-wrap gap-1.5"
284
289
  onKeyDown={handleGroupKeyDown}
285
290
  >
@@ -296,17 +301,19 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
296
301
  {allowCustom && (
297
302
  <div className="mt-3 border-t border-border pt-3">
298
303
  {/* Custom hex is NOT theme-aware — it will not adapt to theme changes. */}
299
- <p className="mb-1.5 text-xs text-muted-foreground">
300
- Custom hex <span className="text-warning">(not theme-aware)</span>
304
+ <p className="mb-1.5 text-meta text-muted-foreground">
305
+ {/* #124: inline running text — ink rung, not the 3:1 mark rung. */}
306
+ {t("ui.colorPicker.customHexLabel")}{" "}
307
+ <span className="text-warning-text">{t("ui.colorPicker.notThemeAware")}</span>
301
308
  </p>
302
309
  <div className="flex gap-2">
303
310
  <input
304
311
  type="text"
305
312
  value={customHex}
306
- placeholder="#3b82f6"
313
+ placeholder="#3b82f6" // i18n-exempt: example hex value, not prose
307
314
  maxLength={7}
308
315
  spellCheck={false}
309
- aria-label="Custom hex color"
316
+ aria-label={t("ui.colorPicker.customHex")}
310
317
  onChange={(e) => setCustomHex(e.target.value)}
311
318
  onKeyDown={(e) => {
312
319
  if (e.key === "Enter") {
@@ -315,8 +322,8 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
315
322
  }
316
323
  }}
317
324
  className={cn(
318
- "h-8 w-28 rounded-md border border-input bg-background px-2 text-sm",
319
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
325
+ "h-8 w-28 rounded-md border border-input bg-background px-2 text-body",
326
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
320
327
  "placeholder:text-muted-foreground",
321
328
  )}
322
329
  />
@@ -324,9 +331,9 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
324
331
  type="button"
325
332
  onClick={handleCustomSubmit}
326
333
  className={cn(
327
- "h-8 rounded-md border border-input bg-background px-2 text-sm",
334
+ "h-8 rounded-md border border-input bg-background px-2 text-body",
328
335
  "hover:bg-accent hover:text-accent-foreground",
329
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
336
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
330
337
  )}
331
338
  >
332
339
  Apply
@@ -5,6 +5,16 @@ const meta = {
5
5
  title: "Forms/Combobox",
6
6
  component: Combobox,
7
7
  tags: ["autodocs"],
8
+ // The trigger fills its container by default (no hard-fixed width) — cap
9
+ // the docs canvas to a realistic form-field width instead of letting it
10
+ // stretch the full autodocs page.
11
+ decorators: [
12
+ (Story) => (
13
+ <div className="w-full max-w-md">
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
8
18
  argTypes: {
9
19
  options: {
10
20
  description: "Array of `{ label, value }` options.",
@@ -66,6 +66,60 @@ describe("Combobox", () => {
66
66
  );
67
67
  expect(screen.getByRole("combobox")).toHaveTextContent("Custom Value");
68
68
  });
69
+
70
+ it("does not hard-fix the trigger width — fills its container by default", () => {
71
+ render(<Combobox options={[{ label: "A", value: "a" }]} />);
72
+ expect(screen.getByRole("combobox").className).not.toMatch(/\bw-56\b/);
73
+ });
74
+ });
75
+
76
+ // jsdom doesn't implement Element.scrollIntoView, which cmdk calls internally
77
+ // to keep the auto-highlighted item visible — see the comment on the
78
+ // `allowCustomValue` describe below for the full explanation.
79
+ describe("Combobox (options interaction)", () => {
80
+ let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
81
+
82
+ beforeEach(() => {
83
+ originalScrollIntoView = Element.prototype.scrollIntoView;
84
+ Element.prototype.scrollIntoView = vi.fn();
85
+ });
86
+
87
+ afterEach(() => {
88
+ Element.prototype.scrollIntoView = originalScrollIntoView;
89
+ });
90
+
91
+ // #reviewed 1.7 — a hand-rolled `onValueChange ? onValueChange(v) : setInternal(v)`
92
+ // never updates internal state once a listener is attached, so an
93
+ // uncontrolled Combobox with an onValueChange handler never shows its own
94
+ // selection on the trigger.
95
+ it("updates the trigger's own displayed value when uncontrolled even with an onValueChange listener", async () => {
96
+ const onValueChange = vi.fn();
97
+ render(<Combobox options={[{ label: "Alpha", value: "a" }]} onValueChange={onValueChange} />);
98
+ await userEvent.click(screen.getByRole("combobox"));
99
+ await userEvent.click(await screen.findByRole("option", { name: "Alpha" }));
100
+ expect(onValueChange).toHaveBeenCalledWith("a");
101
+ expect(screen.getByRole("combobox")).toHaveTextContent("Alpha");
102
+ });
103
+
104
+ // Duplicate labels used to collide because CommandItem's `value` (cmdk's
105
+ // identity/scoring key) was the LABEL, not the unique option value.
106
+ it("distinguishes options that share the same label", async () => {
107
+ const onValueChange = vi.fn();
108
+ render(
109
+ <Combobox
110
+ options={[
111
+ { label: "Production", value: "prod-us" },
112
+ { label: "Production", value: "prod-eu" },
113
+ ]}
114
+ onValueChange={onValueChange}
115
+ />,
116
+ );
117
+ await userEvent.click(screen.getByRole("combobox"));
118
+ const options = await screen.findAllByRole("option", { name: "Production" });
119
+ expect(options).toHaveLength(2);
120
+ await userEvent.click(options[1]!);
121
+ expect(onValueChange).toHaveBeenCalledWith("prod-eu");
122
+ });
69
123
  });
70
124
 
71
125
  // #359 — allowCustomValue. A nested describe because these tests TYPE into the
@@ -1,7 +1,10 @@
1
+ "use client";
2
+
1
3
  import { forwardRef, useState, type ComponentPropsWithoutRef } from "react";
2
4
  import { defaultFilter } from "cmdk";
3
5
  import { Check, ChevronsUpDown, Plus } from "lucide-react";
4
6
  import { cn } from "../../lib/cn";
7
+ import { useControllableState } from "../../lib/use-controllable-state";
5
8
  import { Button } from "../button";
6
9
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
7
10
  import { useLocale } from "../locale-provider";
@@ -20,6 +23,12 @@ export interface ComboboxOption {
20
23
  }
21
24
 
22
25
  export interface ComboboxProps {
26
+ /**
27
+ * The option list — every option is mounted in the DOM (cmdk has no built-in
28
+ * windowing), so this is fine for typical form/menu-sized lists but not for
29
+ * open-ended data. For thousands of options, use `VirtualSelect` instead,
30
+ * which windows rendering via `@tanstack/react-virtual`.
31
+ */
23
32
  options: ComboboxOption[];
24
33
  value?: string;
25
34
  onValueChange?: (value: string) => void;
@@ -97,8 +106,8 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
97
106
  options,
98
107
  value,
99
108
  onValueChange,
100
- placeholder = "Select…",
101
- searchPlaceholder = "Search…",
109
+ placeholder,
110
+ searchPlaceholder,
102
111
  emptyText,
103
112
  className,
104
113
  disabled = false,
@@ -111,11 +120,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
111
120
  ref,
112
121
  ) {
113
122
  const { t } = useLocale();
123
+ const resolvedPlaceholder = placeholder ?? t("ui.combobox.placeholder");
124
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t("ui.combobox.searchPlaceholder");
114
125
  const [open, setOpen] = useState(false);
115
- const [internal, setInternal] = useState("");
126
+ const [selected, setValue] = useControllableState(value, "", onValueChange);
116
127
  const [search, setSearch] = useState("");
117
- const selected = value ?? internal;
118
- const setValue = (v: string) => (onValueChange ? onValueChange(v) : setInternal(v));
119
128
  const current = options.find((o) => o.value === selected);
120
129
 
121
130
  const trimmedSearch = search.trim();
@@ -150,30 +159,35 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
150
159
  aria-expanded={open}
151
160
  disabled={disabled}
152
161
  data-slot="combobox"
153
- className={cn("w-56 justify-between font-normal", triggerProps?.className, className)}
162
+ className={cn("w-full justify-between font-normal", triggerProps?.className, className)}
154
163
  >
155
- {current ? current.label : selected || placeholder}
164
+ {current ? current.label : selected || resolvedPlaceholder}
156
165
  <ChevronsUpDown className="ms-2 size-4 shrink-0 opacity-50" />
157
166
  </Button>
158
167
  </PopoverTrigger>
159
168
  <PopoverContent
160
- className="w-56 p-0"
169
+ className="w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
161
170
  data-slot="combobox-content"
162
171
  // Radix Popover.Content renders role="dialog"; without a name axe's
163
172
  // aria-dialog-name rule (rightly) flags it. Reuse the trigger's own
164
173
  // purpose label when the consumer set one, else fall back to the
165
174
  // (already-translatable) search placeholder — no new hardcoded copy.
166
- aria-label={ariaLabel ?? searchPlaceholder}
175
+ aria-label={ariaLabel ?? resolvedSearchPlaceholder}
167
176
  >
168
177
  <Command filter={allowCustomValue ? customValueFilter : undefined}>
169
- <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={setSearch} />
178
+ <CommandInput
179
+ placeholder={resolvedSearchPlaceholder}
180
+ value={search}
181
+ onValueChange={setSearch}
182
+ />
170
183
  <CommandList>
171
184
  <CommandEmpty>{resolvedEmptyText}</CommandEmpty>
172
185
  <CommandGroup>
173
186
  {options.map((o) => (
174
187
  <CommandItem
175
188
  key={o.value}
176
- value={o.label}
189
+ value={o.value}
190
+ keywords={[o.label]}
177
191
  onSelect={() => {
178
192
  setValue(o.value === selected ? "" : o.value);
179
193
  setOpen(false);
@@ -3,7 +3,6 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent }
3
3
  import { expect, waitFor, within } from "storybook/test";
4
4
  import { Calculator, Calendar, CreditCard, Settings, Smile, User } from "lucide-react";
5
5
  import { Button } from "../button";
6
- import { DialogTitle } from "../dialog";
7
6
  import {
8
7
  Command,
9
8
  CommandDialog,
@@ -19,6 +18,20 @@ const meta = {
19
18
  title: "Overlays/Command",
20
19
  component: Command,
21
20
  tags: ["autodocs"],
21
+ parameters: {
22
+ docs: {
23
+ description: {
24
+ component:
25
+ "A grouped, searchable, keyboard-driven list (cmdk). Render it inline, or wrap it " +
26
+ "in `CommandDialog` for a full ⌘K palette — pass `CommandDialog`'s optional `title` " +
27
+ "for the dialog's `sr-only` accessible name.\n\n" +
28
+ "**Choosing between the two model/target pickers.** A full ⌘K palette → compose " +
29
+ "`CommandDialog` + `Command*` from `@elabs-ai/components-ui` directly; there is no " +
30
+ "separate selector component. An inline pill in a composer footer that opens the " +
31
+ "same grouped list anchored under itself → `ModelPicker`.",
32
+ },
33
+ },
34
+ },
22
35
  argTypes: {
23
36
  label: {
24
37
  description: "Accessible label for the command palette (cmdk `label` prop).",
@@ -113,10 +126,7 @@ function CommandPaletteDemo() {
113
126
  Open command palette
114
127
  </Button>
115
128
 
116
- <CommandDialog open={open} onOpenChange={setOpen}>
117
- {/* CommandDialog doesn't ship a built-in title, so supply an sr-only one
118
- for the dialog's accessible name. */}
119
- <DialogTitle className="sr-only">Command palette</DialogTitle>
129
+ <CommandDialog open={open} onOpenChange={setOpen} title="Command palette">
120
130
  <CommandInput placeholder="Type a command or search…" />
121
131
  <CommandList>
122
132
  <CommandEmpty>No results found.</CommandEmpty>
@@ -291,7 +301,7 @@ function ExternalInputComboboxDemo() {
291
301
  role="combobox"
292
302
  onKeyDown={forwardKey}
293
303
  placeholder="Type @ to mention…"
294
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none focus-visible:ring-2 focus-visible:ring-ring"
304
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-body focus-ring"
295
305
  />
296
306
  <Command
297
307
  id="mention-list"
@@ -4,12 +4,16 @@ import { render, screen, waitFor } from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
  import {
6
6
  Command,
7
+ CommandDialog,
8
+ CommandEmpty,
7
9
  CommandGroup,
8
10
  CommandInput,
9
11
  CommandItem,
10
12
  CommandList,
13
+ CommandSeparator,
11
14
  useCommandActiveItemId,
12
15
  } from "./command";
16
+ import { DialogTitle } from "../dialog";
13
17
 
14
18
  // jsdom does not implement `Element.prototype.scrollIntoView`, and `cmdk`
15
19
  // calls it unconditionally (not feature-detected — it's a third-party
@@ -278,3 +282,87 @@ describe("Command — external combobox input wiring (integration)", () => {
278
282
  });
279
283
  });
280
284
  });
285
+
286
+ /**
287
+ * RM-010 — `CommandDialog` grew a `title` prop when the `ModelSelector` alias
288
+ * shell in `@elabs-ai/components-ai` was deleted; the `sr-only` `DialogTitle`
289
+ * was the one capability that shell had and `ui` lacked.
290
+ *
291
+ * It MUST stay optional. `@elabs-ai/components-ai`'s `VoiceSelectorDialog`
292
+ * spreads `ComponentProps<typeof CommandDialog>` straight through, and callers
293
+ * that already render their own `DialogTitle` in `children` must not be forced
294
+ * to move it. Both branches are asserted so a later "make it required" change
295
+ * fails here rather than in a downstream package.
296
+ */
297
+ describe("CommandDialog — optional title (RM-010)", () => {
298
+ it("names the dialog from `title`, rendered sr-only", () => {
299
+ render(
300
+ <CommandDialog open title="Command palette">
301
+ <CommandList>
302
+ <CommandItem>Calendar</CommandItem>
303
+ </CommandList>
304
+ </CommandDialog>,
305
+ );
306
+
307
+ const dialog = screen.getByRole("dialog");
308
+ expect(dialog).toHaveAccessibleName("Command palette");
309
+ expect(screen.getByText("Command palette")).toHaveClass("sr-only");
310
+ });
311
+
312
+ it("renders no title of its own when `title` is omitted — a caller's own DialogTitle still wins", () => {
313
+ render(
314
+ <CommandDialog open>
315
+ <DialogTitle>Caller-owned title</DialogTitle>
316
+ <CommandList>
317
+ <CommandItem>Calendar</CommandItem>
318
+ </CommandList>
319
+ </CommandDialog>,
320
+ );
321
+
322
+ expect(screen.getByRole("dialog")).toHaveAccessibleName("Caller-owned title");
323
+ // Exactly one heading — CommandDialog did not add a second, competing one.
324
+ expect(screen.getAllByText(/title/i)).toHaveLength(1);
325
+ });
326
+ });
327
+
328
+ /** #157 — `CommandSeparator` is `cmdk`'s divider between groups, rendered as a
329
+ * direct child of `CommandList`'s `role="listbox"`. A listbox may only own
330
+ * `option`/`group` children (WCAG 1.3.1); a bare `role="separator"` there is
331
+ * neither. */
332
+ describe("Command — CommandSeparator a11y (#157)", () => {
333
+ it("stays in the DOM for sighted users but is removed from the accessibility tree", () => {
334
+ render(
335
+ <Command>
336
+ <CommandList>
337
+ <CommandGroup heading="Fruit">
338
+ <CommandItem value="apple">Apple</CommandItem>
339
+ </CommandGroup>
340
+ <CommandSeparator />
341
+ <CommandGroup heading="Veg">
342
+ <CommandItem value="carrot">Carrot</CommandItem>
343
+ </CommandGroup>
344
+ </CommandList>
345
+ </Command>,
346
+ );
347
+
348
+ // Still rendered — cmdk owns the visual divider.
349
+ expect(document.querySelector("[cmdk-separator]")).not.toBeNull();
350
+ // Never exposed with its `role="separator"` — a listbox can't own one.
351
+ expect(screen.queryByRole("separator")).toBeNull();
352
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
353
+ });
354
+ });
355
+
356
+ describe("CommandEmpty — merges className instead of replacing it", () => {
357
+ it("keeps the base py-6/text-center classes alongside a caller className", () => {
358
+ render(
359
+ <Command>
360
+ <CommandList>
361
+ <CommandEmpty className="custom-empty">No results</CommandEmpty>
362
+ </CommandList>
363
+ </Command>,
364
+ );
365
+ const empty = screen.getByText("No results");
366
+ expect(empty).toHaveClass("py-6", "text-center", "custom-empty");
367
+ });
368
+ });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  createContext,
3
5
  forwardRef,
@@ -16,7 +18,7 @@ import { Command as CommandPrimitive, useCommandState } from "cmdk";
16
18
  import { Search } from "lucide-react";
17
19
  import { cn } from "../../lib/cn";
18
20
  import { mergeRefs } from "../../lib/merge-refs";
19
- import { Dialog, DialogContent } from "../dialog";
21
+ import { Dialog, DialogContent, DialogTitle } from "../dialog";
20
22
 
21
23
  /** cmdk's own marker attribute on an item row — the scope for the id lookup. */
22
24
  const CMDK_ITEM_SELECTOR = '[cmdk-item=""][aria-selected="true"]';
@@ -149,17 +151,34 @@ export function useCommandActiveItemId(): string | undefined {
149
151
  return id === NO_COMMAND_PROVIDER ? undefined : id;
150
152
  }
151
153
 
152
- export function CommandDialog({
153
- children,
154
- filter,
155
- ...props
156
- }: ComponentPropsWithoutRef<typeof Dialog> & {
154
+ export type CommandDialogProps = ComponentPropsWithoutRef<typeof Dialog> & {
157
155
  /** Custom cmdk ranking (e.g. strict substring instead of fuzzy). */
158
156
  filter?: ComponentPropsWithoutRef<typeof CommandPrimitive>["filter"];
159
- }) {
157
+ /**
158
+ * The dialog's accessible name, rendered `sr-only`.
159
+ *
160
+ * OPTIONAL, and deliberately so: callers that already render their own
161
+ * `DialogTitle` in `children` must not be forced to move it, and
162
+ * `@elabs-ai/components-ai`'s `VoiceSelectorDialog` passes this whole prop
163
+ * type straight through. Supply it when you have no visible title — a
164
+ * `Dialog` with no accessible name is a Radix warning and an axe violation.
165
+ */
166
+ title?: ReactNode;
167
+ };
168
+
169
+ /**
170
+ * A ⌘K command palette in a modal `Dialog`.
171
+ *
172
+ * This IS the palette presentation of `Command` — there is no separate
173
+ * "selector" component. For an inline pill that opens the same grouped,
174
+ * searchable list anchored under itself (a composer footer), reach for
175
+ * `ModelPicker` instead.
176
+ */
177
+ export function CommandDialog({ children, filter, title, ...props }: CommandDialogProps) {
160
178
  return (
161
179
  <Dialog {...props}>
162
180
  <DialogContent className="overflow-hidden p-0">
181
+ {title === undefined ? null : <DialogTitle className="sr-only">{title}</DialogTitle>}
163
182
  <Command
164
183
  filter={filter}
165
184
  className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3"
@@ -182,7 +201,7 @@ export const CommandInput = forwardRef<
182
201
  ref={ref}
183
202
  data-slot="command-input"
184
203
  className={cn(
185
- "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
204
+ "flex h-10 w-full rounded-md bg-transparent py-3 text-body outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
186
205
  className,
187
206
  )}
188
207
  {...props}
@@ -208,12 +227,12 @@ export const CommandList = forwardRef<
208
227
  export const CommandEmpty = forwardRef<
209
228
  ElementRef<typeof CommandPrimitive.Empty>,
210
229
  ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
211
- >(function CommandEmpty(props, ref) {
230
+ >(function CommandEmpty({ className, ...props }, ref) {
212
231
  return (
213
232
  <CommandPrimitive.Empty
214
233
  ref={ref}
215
234
  data-slot="command-empty"
216
- className="py-6 text-center text-sm"
235
+ className={cn("py-6 text-center text-body", className)}
217
236
  {...props}
218
237
  />
219
238
  );
@@ -228,7 +247,7 @@ export const CommandGroup = forwardRef<
228
247
  ref={ref}
229
248
  data-slot="command-group"
230
249
  className={cn(
231
- "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
250
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-meta [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
232
251
  className,
233
252
  )}
234
253
  {...props}
@@ -244,6 +263,12 @@ export const CommandSeparator = forwardRef<
244
263
  <CommandPrimitive.Separator
245
264
  ref={ref}
246
265
  data-slot="command-separator"
266
+ // `cmdk` renders this as a direct child of its `role="listbox"`
267
+ // (`CommandList`), which may only own `option`/`group` (WCAG 1.3.1,
268
+ // #157) — a purely visual divider between groups, so `aria-hidden`
269
+ // removes it from the accessibility tree instead of asking the listbox
270
+ // to own a role it can't. `{...props}` still lets a caller override it.
271
+ aria-hidden="true"
247
272
  className={cn("-mx-1 h-px bg-border", className)}
248
273
  {...props}
249
274
  />
@@ -282,7 +307,7 @@ export const CommandItem = forwardRef<
282
307
  ref={ref}
283
308
  data-slot="command-item"
284
309
  className={cn(
285
- "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 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4",
310
+ "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 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4",
286
311
  className,
287
312
  )}
288
313
  {...props}
@@ -294,7 +319,7 @@ export function CommandShortcut({ className, ...props }: HTMLAttributes<HTMLSpan
294
319
  return (
295
320
  <span
296
321
  data-slot="command-shortcut"
297
- className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
322
+ className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
298
323
  {...props}
299
324
  />
300
325
  );
@@ -10,4 +10,5 @@ export {
10
10
  CommandShortcut,
11
11
  useCommandActiveItemId,
12
12
  type CommandProps,
13
+ type CommandDialogProps,
13
14
  } from "./command";
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { CommandTrigger } from "./command-trigger";
4
+
5
+ const meta = {
6
+ title: "Navigation/CommandTrigger",
7
+ component: CommandTrigger,
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "The command-palette opener a top bar reaches for: shaped like a search field, " +
13
+ "collapsing to just the icon under `sm`. The visible label and the shortcut `Kbd` are " +
14
+ "both decorative — the accessible name is authored with `aria-label` so the shortcut " +
15
+ "glyph never concatenates into it (#117).",
16
+ },
17
+ },
18
+ },
19
+ tags: ["autodocs"],
20
+ } satisfies Meta<typeof CommandTrigger>;
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ /** Default label and platform-detected shortcut. */
25
+ export const Default: Story = {
26
+ play: async ({ canvasElement }) => {
27
+ const button = within(canvasElement).getByRole("button");
28
+ // #117: the Kbd's shortcut glyph must not pollute the computed name.
29
+ await expect(button).toHaveAccessibleName("Search");
30
+ },
31
+ };
32
+
33
+ /** A caller-supplied label and shortcut — useful when the platform guess is wrong. */
34
+ export const WithCustomShortcut: Story = {
35
+ args: {
36
+ label: "Find anything",
37
+ shortcut: "Ctrl K",
38
+ },
39
+ };
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { CommandTrigger } from "./command-trigger";
4
+
5
+ describe("CommandTrigger", () => {
6
+ it("announces only its label — the shortcut glyph must not pollute the accessible name", () => {
7
+ render(<CommandTrigger />);
8
+ // #117: a Kbd inside a control concatenates into the computed name. Assert the
9
+ // EXACT name; a regex match would happily pass on the polluted string.
10
+ expect(screen.getByRole("button")).toHaveAccessibleName("Search");
11
+ });
12
+
13
+ it("takes a custom label and shortcut", () => {
14
+ render(<CommandTrigger label="Find anything" shortcut="Ctrl K" />);
15
+ expect(screen.getByRole("button")).toHaveAccessibleName("Find anything");
16
+ expect(screen.getByText("Ctrl K")).toBeInTheDocument();
17
+ });
18
+ });