@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
@@ -19,6 +19,7 @@ import { Badge } from "../badge";
19
19
  import { Button } from "../button";
20
20
  import { Input } from "../input";
21
21
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
22
+ import { useLocale } from "../locale-provider";
22
23
  import { type VirtualSelectOption, useVirtualListbox } from "./use-virtual-listbox";
23
24
 
24
25
  export type { VirtualSelectOption };
@@ -85,9 +86,9 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
85
86
  value,
86
87
  defaultValue,
87
88
  onValueChange,
88
- placeholder = "Select…",
89
- searchPlaceholder = "Search…",
90
- emptyText = "No results.",
89
+ placeholder,
90
+ searchPlaceholder,
91
+ emptyText,
91
92
  estimateOptionHeight = 32,
92
93
  maxListHeight = "18rem",
93
94
  className,
@@ -95,6 +96,11 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
95
96
  },
96
97
  ref,
97
98
  ) {
99
+ const { t } = useLocale();
100
+ const resolvedPlaceholder = placeholder ?? t("ui.virtualSelect.placeholder");
101
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t("ui.virtualSelect.searchPlaceholder");
102
+ const resolvedEmptyText = emptyText ?? t("noResults");
103
+
98
104
  // ------------------------------------------------------------------
99
105
  // Controlled / uncontrolled
100
106
  // ------------------------------------------------------------------
@@ -135,6 +141,15 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
135
141
  [options, query],
136
142
  );
137
143
 
144
+ // O(1) label lookup for the trigger's selected chips — `selected.map(find)`
145
+ // is O(selected.length * options.length) and options lists here can be
146
+ // large (that's the whole point of virtualizing).
147
+ const optionByValue = useMemo(() => {
148
+ const map = new Map<string, VirtualSelectOption>();
149
+ for (const o of options) map.set(o.value, o);
150
+ return map;
151
+ }, [options]);
152
+
138
153
  // ------------------------------------------------------------------
139
154
  // Virtualizer
140
155
  // ------------------------------------------------------------------
@@ -184,26 +199,45 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
184
199
  const listboxId = `${uid}-listbox`;
185
200
  const optionId = (index: number) => `${uid}-option-${index}`;
186
201
 
187
- // Focus the search input when the popover opens
202
+ // Focus the search input when the popover opens. The timeout id is
203
+ // tracked so it can be cancelled on unmount (or a rapid re-close) —
204
+ // otherwise it fires after the component (and `searchRef`'s DOM node)
205
+ // is gone, or focuses a NEW popover's input on a fast toggle.
206
+ const focusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
207
+
208
+ useEffect(
209
+ () => () => {
210
+ if (focusTimeoutRef.current != null) clearTimeout(focusTimeoutRef.current);
211
+ },
212
+ [],
213
+ );
214
+
188
215
  const handleOpenChange = useCallback((next: boolean) => {
189
216
  setOpen(next);
217
+ if (focusTimeoutRef.current != null) {
218
+ clearTimeout(focusTimeoutRef.current);
219
+ focusTimeoutRef.current = null;
220
+ }
190
221
  if (next) {
191
222
  // Defer one frame so Radix has mounted the portal
192
- setTimeout(() => searchRef.current?.focus(), 0);
223
+ focusTimeoutRef.current = setTimeout(() => {
224
+ focusTimeoutRef.current = null;
225
+ searchRef.current?.focus();
226
+ }, 0);
193
227
  }
194
228
  }, []);
195
229
 
196
230
  // ------------------------------------------------------------------
197
231
  // Trigger label
198
232
  // ------------------------------------------------------------------
199
- const selectedLabels = selected.map((v) => {
200
- const opt = options.find((o) => o.value === v);
201
- return { value: v, label: opt?.label ?? v };
202
- });
233
+ const selectedLabels = selected.map((v) => ({
234
+ value: v,
235
+ label: optionByValue.get(v)?.label ?? v,
236
+ }));
203
237
 
204
238
  const triggerContent =
205
239
  selectedLabels.length === 0 ? (
206
- <span className="text-muted-foreground">{placeholder}</span>
240
+ <span className="text-muted-foreground">{resolvedPlaceholder}</span>
207
241
  ) : multiple ? (
208
242
  <>
209
243
  {selectedLabels.slice(0, 2).map((l) => (
@@ -247,7 +281,7 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
247
281
  aria-expanded={open}
248
282
  aria-haspopup="listbox"
249
283
  aria-controls={listboxId}
250
- className={cn("h-auto min-h-9 w-64 justify-between font-normal", className)}
284
+ className={cn("h-auto min-h-9 w-full justify-between font-normal", className)}
251
285
  {...props}
252
286
  >
253
287
  <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
@@ -258,7 +292,7 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
258
292
  </PopoverTrigger>
259
293
 
260
294
  <PopoverContent
261
- className="w-64 p-0"
295
+ className="w-[var(--radix-popover-trigger-width)] min-w-64 p-0"
262
296
  align="start"
263
297
  // Prevent Radix from stealing focus so it can go to the input
264
298
  onOpenAutoFocus={(e) => e.preventDefault()}
@@ -270,17 +304,21 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
270
304
  type="text"
271
305
  autoComplete="off"
272
306
  spellCheck={false}
273
- placeholder={searchPlaceholder}
307
+ placeholder={resolvedSearchPlaceholder}
274
308
  value={query}
275
309
  onChange={(e) => setQuery(e.target.value)}
276
310
  onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => onInputKeyDown(e)}
277
- aria-label={searchPlaceholder}
311
+ aria-label={resolvedSearchPlaceholder}
278
312
  aria-autocomplete="list"
279
313
  aria-controls={listboxId}
280
314
  aria-activedescendant={
281
315
  activeIndex >= 0 && activeMounted ? optionId(activeIndex) : undefined
282
316
  }
283
- className="h-8 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
317
+ // Border/shadow removed (sits inside the popover's own bordered
318
+ // header strip) but the base `Input` focus-ring is kept — no
319
+ // compound ring lives elsewhere on this control, so silencing it
320
+ // would leave a re-focused search box with no visible indicator.
321
+ className="h-8 border-0 shadow-none"
284
322
  />
285
323
  </div>
286
324
 
@@ -318,7 +356,7 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
318
356
  transform: `translateY(${virtualRow.start}px)`,
319
357
  }}
320
358
  className={cn(
321
- "flex cursor-pointer select-none items-center rounded-sm px-2 text-sm outline-none",
359
+ "flex cursor-pointer select-none items-center rounded-sm px-2 text-body outline-none",
322
360
  "transition-colors duration-fast motion-reduce:transition-none",
323
361
  isActive && "bg-accent text-accent-foreground",
324
362
  !isActive && "hover:bg-accent hover:text-accent-foreground",
@@ -349,9 +387,9 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
349
387
  <div
350
388
  role="status"
351
389
  aria-live="polite"
352
- className="px-2 py-6 text-center text-sm text-muted-foreground"
390
+ className="px-2 py-6 text-center text-body text-muted-foreground"
353
391
  >
354
- {emptyText}
392
+ {resolvedEmptyText}
355
393
  </div>
356
394
  ) : null}
357
395
  </PopoverContent>
@@ -64,4 +64,13 @@ describe("Wizard", () => {
64
64
  await user.click(screen.getByRole("button", { name: "Next" }));
65
65
  await waitFor(() => expect(screen.getByRole("button", { name: "Finish" })).toBeInTheDocument());
66
66
  });
67
+
68
+ it("names the completed-step indicator button — it renders only an icon", async () => {
69
+ const user = userEvent.setup();
70
+ renderWizard();
71
+ await user.click(screen.getByRole("button", { name: "Next" }));
72
+ // The first step's indicator now shows a checkmark glyph with no text
73
+ // content — without an accessible name it would be an unlabelled button.
74
+ expect(screen.getByRole("button", { name: "Account — completed" })).toBeInTheDocument();
75
+ });
67
76
  });
@@ -232,9 +232,13 @@ export const WizardSteps = forwardRef<HTMLOListElement, WizardStepsProps>(functi
232
232
  type="button"
233
233
  disabled={!isClickable}
234
234
  onClick={() => goTo(index)}
235
+ // The completed state renders only a checkmark glyph (hidden
236
+ // from AT below) — with no text content, the button would
237
+ // otherwise have no accessible name at all.
238
+ aria-label={isComplete ? `${meta.title} — completed` : undefined}
235
239
  className={cn(
236
- "flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium tabular-nums",
237
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
240
+ "flex size-7 shrink-0 items-center justify-center rounded-full border text-meta font-medium tabular-nums",
241
+ "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
238
242
  isActive && "border-primary bg-primary text-primary-foreground",
239
243
  // #399 — the step NUMBER is text on a `/10` wash, not plate ink
240
244
  // and not a mark: `-text` rung. The border keeps the fill rung.
@@ -248,14 +252,14 @@ export const WizardSteps = forwardRef<HTMLOListElement, WizardStepsProps>(functi
248
252
  <span className="flex min-w-0 flex-col">
249
253
  <span
250
254
  className={cn(
251
- "truncate text-sm font-medium",
255
+ "truncate text-body font-medium",
252
256
  isActive ? "text-foreground" : "text-muted-foreground",
253
257
  )}
254
258
  >
255
259
  {meta.title}
256
260
  </span>
257
261
  {meta.description && (
258
- <span className="truncate text-xs text-muted-foreground">{meta.description}</span>
262
+ <span className="truncate text-meta text-muted-foreground">{meta.description}</span>
259
263
  )}
260
264
  </span>
261
265
  </li>
@@ -309,7 +313,11 @@ export const WizardStep = forwardRef<HTMLDivElement, WizardStepProps>(function W
309
313
  role="group"
310
314
  aria-labelledby={`${baseId}-step-${step}`}
311
315
  tabIndex={-1}
312
- className={cn("min-w-0 flex-1 focus-visible:outline-none", className)}
316
+ // Programmatically focused on step change (`regionRef.current?.focus()`
317
+ // above) — `focus-ring` gives that a visible indicator instead of
318
+ // silently discarding the browser's default outline (mirrors
319
+ // `DialogBody`'s same tabIndex={-1}-container precedent).
320
+ className={cn("min-w-0 flex-1 focus-ring", className)}
313
321
  {...props}
314
322
  >
315
323
  {children}
@@ -0,0 +1,3 @@
1
+ export { WorkspacePicker } from "./workspace-picker";
2
+ export type { WorkspacePickerProps } from "./workspace-picker";
3
+ export { formatLastOpened, type Workspace } from "./workspace-picker-state";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `WorkspacePicker`'s pure, directly-testable logic — kept out of the
3
+ * component for the same reason `../model-picker/model-picker-state.ts` does
4
+ * (see that file's header comment): an expression buried in JSX cannot be
5
+ * unit-tested in isolation, and relative-time arithmetic in particular has
6
+ * edge cases worth locking down on their own.
7
+ */
8
+
9
+ /**
10
+ * One recent workspace. Deliberately a strict subset of `ModelPickerItem`'s
11
+ * shape (`id` / `name` → `label` / `path` → `description` / `lastOpenedAt` →
12
+ * a formatted `meta` entry) — see the reuse note in `workspace-picker.tsx`.
13
+ */
14
+ export interface Workspace {
15
+ id: string;
16
+ /** Rendered as the row's primary label and, when current, the trigger text. */
17
+ name: string;
18
+ /**
19
+ * The filesystem path. Long, user-supplied content — the row truncates it,
20
+ * never wraps or overflows.
21
+ */
22
+ path: string;
23
+ /**
24
+ * When this workspace was last opened. Rendered as a relative time via
25
+ * `Intl.RelativeTimeFormat` (`formatLastOpened`, below) — never a
26
+ * pre-formatted string the caller hands over.
27
+ */
28
+ lastOpenedAt?: Date;
29
+ }
30
+
31
+ /**
32
+ * "2 hours ago" / "in 3 minutes" / "yesterday" — `Intl.RelativeTimeFormat`
33
+ * picks the coarsest unit (minutes → hours → days) that still reads
34
+ * naturally, instead of e.g. "127 minutes ago".
35
+ *
36
+ * @param now - injection seam for tests; defaults to the real clock.
37
+ */
38
+ export function formatLastOpened(date: Date, locale: string, now: number = Date.now()): string {
39
+ const diffMs = date.getTime() - now;
40
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
41
+
42
+ const minutes = Math.round(diffMs / (60 * 1000));
43
+ if (Math.abs(minutes) < 60) return rtf.format(minutes, "minute");
44
+
45
+ const hours = Math.round(diffMs / (60 * 60 * 1000));
46
+ if (Math.abs(hours) < 24) return rtf.format(hours, "hour");
47
+
48
+ const days = Math.round(diffMs / (24 * 60 * 60 * 1000));
49
+ return rtf.format(days, "day");
50
+ }
@@ -0,0 +1,126 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, fn, within } from "storybook/test";
3
+ import { WorkspacePicker } from "./workspace-picker";
4
+ import type { Workspace } from "./workspace-picker-state";
5
+
6
+ const workspaces: Workspace[] = [
7
+ {
8
+ id: "ws-brand-ui",
9
+ name: "elabs-components",
10
+ path: "~/dev/elabs-components",
11
+ lastOpenedAt: new Date(Date.now() - 15 * 60 * 1000),
12
+ },
13
+ {
14
+ id: "ws-brainless",
15
+ name: "brainless",
16
+ path: "~/dev/oss/brainless",
17
+ lastOpenedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
18
+ },
19
+ {
20
+ id: "ws-monorepo",
21
+ name: "platform-monorepo",
22
+ // Long, user-supplied content — truncates instead of overflowing.
23
+ path: "~/workspaces/clients/northwind/services/platform-monorepo/packages/api-gateway",
24
+ lastOpenedAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000),
25
+ },
26
+ ];
27
+
28
+ const meta = {
29
+ title: "Core/WorkspacePicker",
30
+ component: WorkspacePicker,
31
+ tags: ["autodocs"],
32
+ parameters: {
33
+ docs: {
34
+ description: {
35
+ component:
36
+ "A recent-workspace switcher, built as a `ModelPicker` PRESET rather than a new " +
37
+ "picker (issue #111) — see `docs/decisions/2026-09-01-brainless-adoption-architecture.md` " +
38
+ "§ 7. Maps `Workspace[]` onto `ModelPicker`'s grouped rows, formats `lastOpenedAt` with " +
39
+ "`Intl.RelativeTimeFormat`, marks the current workspace in text, and renders a free-text " +
40
+ "path entry into `ModelPicker`'s `footer` slot.\n\n" +
41
+ "No `Sidebar` dependency, unlike `TeamSwitcher` — this works anywhere a picker is needed.",
42
+ },
43
+ },
44
+ },
45
+ args: { workspaces, currentId: "ws-brand-ui", onSelect: fn(), onSubmitPath: fn() },
46
+ } satisfies Meta<typeof WorkspacePicker>;
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof meta>;
50
+
51
+ export const Default: Story = {};
52
+
53
+ /**
54
+ * The current workspace is marked in the row's TEXT (a "Current" badge, not
55
+ * only `ModelPicker`'s check glyph) — open the popover to see it, and confirm
56
+ * it survives a greyscale render.
57
+ */
58
+ export const CurrentWorkspaceMarkedInText: Story = {
59
+ play: async ({ canvas, canvasElement, userEvent }) => {
60
+ await userEvent.click(canvas.getByRole("combobox"));
61
+ const body = within(canvasElement.ownerDocument.body);
62
+ const currentRow = await body.findByRole("option", { name: /elabs-components/i });
63
+ // The marker is part of the row's ACCESSIBLE NAME, not only a visual glyph.
64
+ await expect(currentRow).toHaveAccessibleName(/current/i);
65
+ const otherRow = body.getByRole("option", { name: /brainless/i });
66
+ await expect(otherRow).not.toHaveAccessibleName(/current/i);
67
+ },
68
+ };
69
+
70
+ /** No workspace chosen yet — the trigger falls back to a designed placeholder. */
71
+ export const NoCurrentWorkspace: Story = {
72
+ args: { currentId: undefined },
73
+ };
74
+
75
+ /**
76
+ * Nothing to show — `ModelPicker`'s own designed empty panel (reused, not
77
+ * reimplemented) plus the free-text path entry, which always renders in the
78
+ * footer regardless of body state and is this state's one action.
79
+ */
80
+ export const Empty: Story = {
81
+ args: { workspaces: [], currentId: undefined },
82
+ play: async ({ canvas, canvasElement, userEvent }) => {
83
+ await userEvent.click(canvas.getByRole("combobox"));
84
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
85
+ },
86
+ };
87
+
88
+ /** A very long, deeply nested path truncates instead of overflowing the row. */
89
+ export const LongPath: Story = {
90
+ args: {
91
+ workspaces: [
92
+ {
93
+ id: "ws-deep",
94
+ name: "api-gateway",
95
+ path: "~/workspaces/clients/northwind/services/platform-monorepo/packages/api-gateway/src/routes/internal",
96
+ },
97
+ ],
98
+ currentId: "ws-deep",
99
+ },
100
+ };
101
+
102
+ export const Disabled: Story = { args: { disabled: true } };
103
+
104
+ /**
105
+ * The free-text path entry validates non-empty and reports the raw string —
106
+ * this component performs no filesystem access of any kind (D5). Also proves
107
+ * Enter submits the path instead of being hijacked by cmdk's own list
108
+ * navigation (both bind a keydown handler on the same `Command` root).
109
+ */
110
+ export const SubmitFreeTextPath: Story = {
111
+ play: async ({ canvas, canvasElement, userEvent, args }) => {
112
+ await userEvent.click(canvas.getByRole("combobox"));
113
+ const body = within(canvasElement.ownerDocument.body);
114
+ const input = body.getByPlaceholderText(/path/i);
115
+ const submit = body.getByRole("button", { name: /open/i });
116
+
117
+ // Nothing typed yet — validated as empty, no call.
118
+ await userEvent.click(submit);
119
+ await expect(args.onSubmitPath).not.toHaveBeenCalled();
120
+
121
+ await userEvent.type(input, "~/dev/new-project{Enter}");
122
+ await expect(args.onSubmitPath).toHaveBeenCalledWith("~/dev/new-project");
123
+ // Enter submitted the path — it did not move cmdk's list highlight/select.
124
+ await expect(args.onSelect).not.toHaveBeenCalled();
125
+ },
126
+ };
@@ -0,0 +1,207 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { WorkspacePicker } from "./workspace-picker";
5
+ import { formatLastOpened } from "./workspace-picker-state";
6
+ import type { Workspace } from "./workspace-picker-state";
7
+
8
+ afterEach(cleanup);
9
+
10
+ // cmdk calls Element.scrollIntoView to keep the auto-highlighted item visible.
11
+ // jsdom doesn't implement it and this package's vitest setup deliberately ships
12
+ // no global stub, so third-party libraries that don't feature-detect get a
13
+ // local, scoped stub — the same pattern as model-picker.test.tsx and
14
+ // combobox.test.tsx.
15
+ let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
16
+ beforeEach(() => {
17
+ originalScrollIntoView = Element.prototype.scrollIntoView;
18
+ Element.prototype.scrollIntoView = vi.fn();
19
+ });
20
+ afterEach(() => {
21
+ Element.prototype.scrollIntoView = originalScrollIntoView;
22
+ });
23
+
24
+ // No `LocaleProvider` wrapper: `useLocale()` falls back to the shipped English
25
+ // defaults, so these assertions prove the real `ui.workspacePicker.*` catalogue in
26
+ // `packages/ui/src/components/locale-provider/messages.ts` rather than a local stub
27
+ // that would keep passing if the central entry were missing or misspelled.
28
+ function renderPicker(ui: React.ReactElement) {
29
+ return render(ui);
30
+ }
31
+
32
+ const workspaces: Workspace[] = [
33
+ {
34
+ id: "ws-1",
35
+ name: "elabs-components",
36
+ path: "~/dev/elabs-components",
37
+ lastOpenedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
38
+ },
39
+ {
40
+ id: "ws-2",
41
+ name: "brainless",
42
+ path: "~/dev/brainless",
43
+ },
44
+ ];
45
+
46
+ const open = async () => {
47
+ await userEvent.click(screen.getAllByRole("combobox")[0]!);
48
+ };
49
+
50
+ describe("formatLastOpened", () => {
51
+ const now = Date.parse("2026-09-01T12:00:00.000Z");
52
+
53
+ it("picks minutes for recent times", () => {
54
+ expect(formatLastOpened(new Date(now - 5 * 60 * 1000), "en-US", now)).toBe("5 minutes ago");
55
+ });
56
+
57
+ it("picks hours once minutes would read awkwardly large", () => {
58
+ expect(formatLastOpened(new Date(now - 3 * 60 * 60 * 1000), "en-US", now)).toBe("3 hours ago");
59
+ });
60
+
61
+ it("picks days once hours would read awkwardly large", () => {
62
+ expect(formatLastOpened(new Date(now - 2 * 24 * 60 * 60 * 1000), "en-US", now)).toBe(
63
+ "2 days ago",
64
+ );
65
+ });
66
+
67
+ it("formats a future time as relative-forward", () => {
68
+ expect(formatLastOpened(new Date(now + 10 * 60 * 1000), "en-US", now)).toBe("in 10 minutes");
69
+ });
70
+ });
71
+
72
+ describe("WorkspacePicker", () => {
73
+ it("renders each workspace's path as truncating secondary text", async () => {
74
+ renderPicker(<WorkspacePicker workspaces={workspaces} />);
75
+ await open();
76
+ const description = screen.getByText("~/dev/elabs-components");
77
+ expect(description).toBeInTheDocument();
78
+ expect(description).toHaveClass("truncate");
79
+ });
80
+
81
+ it("marks the current workspace in the row's accessible name, not only by icon", async () => {
82
+ renderPicker(<WorkspacePicker workspaces={workspaces} currentId="ws-1" />);
83
+ await open();
84
+ const current = screen.getByRole("option", { name: /elabs-components/ });
85
+ const other = screen.getByRole("option", { name: /brainless/ });
86
+ expect(current).toHaveAccessibleName(/current/i);
87
+ expect(other).not.toHaveAccessibleName(/current/i);
88
+ });
89
+
90
+ it("names the trigger from the current workspace, and falls back to a placeholder", async () => {
91
+ const { unmount } = renderPicker(<WorkspacePicker workspaces={workspaces} currentId="ws-1" />);
92
+ expect(screen.getByRole("combobox")).toHaveTextContent("elabs-components");
93
+ unmount();
94
+
95
+ renderPicker(<WorkspacePicker workspaces={workspaces} />);
96
+ expect(screen.getByRole("combobox")).toHaveTextContent("No workspace selected");
97
+ });
98
+
99
+ it("reports the whole Workspace object on selection, not just the id", async () => {
100
+ const onSelect = vi.fn();
101
+ renderPicker(<WorkspacePicker workspaces={workspaces} onSelect={onSelect} />);
102
+ await open();
103
+ await userEvent.click(screen.getByText("brainless"));
104
+ expect(onSelect).toHaveBeenCalledTimes(1);
105
+ expect(onSelect).toHaveBeenCalledWith(workspaces[1]);
106
+ });
107
+
108
+ it("shows a designed empty state whose one action is the free-text path entry", async () => {
109
+ renderPicker(<WorkspacePicker workspaces={[]} onSubmitPath={vi.fn()} />);
110
+ await open();
111
+ // ModelPicker's own empty panel — reused, not reimplemented.
112
+ expect(screen.getByText("Nothing to show yet")).toBeInTheDocument();
113
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
114
+ // The action: the free-text path entry always renders, even with zero rows.
115
+ expect(screen.getByPlaceholderText("/path/to/project…")).toBeInTheDocument();
116
+ });
117
+
118
+ it("validates non-empty free text before reporting the raw string", async () => {
119
+ const onSubmitPath = vi.fn();
120
+ renderPicker(<WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} />);
121
+ await open();
122
+ const submit = screen.getByRole("button", { name: "Open" });
123
+
124
+ await userEvent.click(submit);
125
+ expect(onSubmitPath).not.toHaveBeenCalled();
126
+
127
+ const input = screen.getByPlaceholderText("/path/to/project…");
128
+ await userEvent.type(input, " ");
129
+ await userEvent.click(submit);
130
+ expect(onSubmitPath).not.toHaveBeenCalled();
131
+
132
+ await userEvent.clear(input);
133
+ await userEvent.type(input, "/tmp/new-workspace");
134
+ await userEvent.click(submit);
135
+ expect(onSubmitPath).toHaveBeenCalledTimes(1);
136
+ expect(onSubmitPath).toHaveBeenCalledWith("/tmp/new-workspace");
137
+ });
138
+
139
+ it("performs no filesystem access — onSubmitPath is the only side effect of a submit", async () => {
140
+ const onSubmitPath = vi.fn();
141
+ const onSelect = vi.fn();
142
+ renderPicker(
143
+ <WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} onSelect={onSelect} />,
144
+ );
145
+ await open();
146
+ const input = screen.getByPlaceholderText("/path/to/project…");
147
+ await userEvent.type(input, "/tmp/new-workspace");
148
+ await userEvent.click(screen.getByRole("button", { name: "Open" }));
149
+ expect(onSubmitPath).toHaveBeenCalledWith("/tmp/new-workspace");
150
+ expect(onSelect).not.toHaveBeenCalled();
151
+ // The field clears after a successful submit.
152
+ expect(input).toHaveValue("");
153
+ });
154
+
155
+ it("submits the path on Enter instead of cmdk hijacking it as list navigation", async () => {
156
+ const onSubmitPath = vi.fn();
157
+ const onSelect = vi.fn();
158
+ renderPicker(
159
+ <WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} onSelect={onSelect} />,
160
+ );
161
+ await open();
162
+ const input = screen.getByPlaceholderText("/path/to/project…");
163
+ await userEvent.type(input, "/tmp/other-workspace{Enter}");
164
+ expect(onSubmitPath).toHaveBeenCalledWith("/tmp/other-workspace");
165
+ expect(onSelect).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("truncates a very long path without overflow", async () => {
169
+ const longPath = "~/dev/" + "very-long-segment/".repeat(20) + "elabs-components";
170
+ renderPicker(
171
+ <WorkspacePicker workspaces={[{ id: "ws-long", name: "long-one", path: longPath }]} />,
172
+ );
173
+ await open();
174
+ const description = screen.getByText(longPath);
175
+ expect(description).toHaveClass("truncate");
176
+ // The truncating span is a min-w-0 flex child — the min-w-0 + flex-1 rule
177
+ // that lets truncate actually clip instead of overflowing.
178
+ expect(description.parentElement).toHaveClass("min-w-0");
179
+ });
180
+
181
+ it("uses the caller's explicit aria-label over the default", async () => {
182
+ renderPicker(<WorkspacePicker workspaces={workspaces} aria-label="Session workspace" />);
183
+ expect(screen.getByRole("combobox")).toHaveAccessibleName("Session workspace");
184
+ });
185
+
186
+ it("renders the shipped English default with no LocaleProvider mounted", () => {
187
+ // No provider: `t()` falls back to `DEFAULT_MESSAGES[key] ?? key`. The keys
188
+ // landed in messages.ts with this component, so the fallback is now real
189
+ // microcopy — a regression that dropped them would surface here as the raw
190
+ // key string instead.
191
+ render(<WorkspacePicker workspaces={workspaces} />);
192
+ expect(screen.getByRole("combobox")).toHaveTextContent("No workspace selected");
193
+ });
194
+
195
+ it("scopes the workspace-picker data-slot to the root, distinct from model-picker", async () => {
196
+ const { container } = renderPicker(<WorkspacePicker workspaces={workspaces} />);
197
+ expect(container.querySelector('[data-slot="workspace-picker"]')).toBeInTheDocument();
198
+ expect(container.querySelector('[data-slot="model-picker"]')).not.toBeInTheDocument();
199
+ });
200
+
201
+ it("renders the free-text path form outside CommandList — the search query cannot hide it", async () => {
202
+ renderPicker(<WorkspacePicker workspaces={workspaces} />);
203
+ await open();
204
+ await userEvent.type(screen.getByPlaceholderText("Search workspaces…"), "no-match-xyz");
205
+ expect(screen.getByPlaceholderText("/path/to/project…")).toBeInTheDocument();
206
+ });
207
+ });