@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
@@ -51,6 +51,15 @@ const meta = {
51
51
  title: "Forms/TreeSelect",
52
52
  component: TreeSelect,
53
53
  tags: ["autodocs"],
54
+ // The trigger fills its container by default (no hard-fixed width) — cap
55
+ // the docs canvas to a realistic form-field width.
56
+ decorators: [
57
+ (Story) => (
58
+ <div className="w-72">
59
+ <Story />
60
+ </div>
61
+ ),
62
+ ],
54
63
  argTypes: {
55
64
  nodes: {
56
65
  description: "Hierarchical tree data (`TreeNode[]`).",
@@ -37,4 +37,9 @@ describe("TreeSelect", () => {
37
37
  render(<TreeSelect nodes={nodes} multiple value={["apple", "banana", "carrot"]} />);
38
38
  expect(screen.getByText("+1")).toBeInTheDocument();
39
39
  });
40
+
41
+ it("does not hard-fix the trigger width — fills its container by default", () => {
42
+ render(<TreeSelect nodes={nodes} />);
43
+ expect(screen.getByRole("combobox").className).not.toMatch(/\bw-64\b/);
44
+ });
40
45
  });
@@ -7,6 +7,7 @@ import { cn } from "../../lib/cn";
7
7
  import { Badge } from "../badge";
8
8
  import { Button } from "../button";
9
9
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
10
+ import { useLocale } from "../locale-provider";
10
11
  import { Tree, type TreeNode } from "../tree";
11
12
 
12
13
  export interface TreeSelectProps<T = unknown> extends Omit<
@@ -47,18 +48,11 @@ function findLabel<T>(nodes: TreeNode<T>[], id: string): ReactNode | undefined {
47
48
  * selection; composes `Tree` for all keyboard/SR behaviour.
48
49
  */
49
50
  export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(function TreeSelect(
50
- {
51
- nodes,
52
- value,
53
- defaultValue,
54
- onValueChange,
55
- multiple = false,
56
- placeholder = "Select…",
57
- className,
58
- ...props
59
- },
51
+ { nodes, value, defaultValue, onValueChange, multiple = false, placeholder, className, ...props },
60
52
  ref,
61
53
  ) {
54
+ const { t } = useLocale();
55
+ const resolvedPlaceholder = placeholder ?? t("ui.treeSelect.placeholder");
62
56
  const [open, setOpen] = useState(false);
63
57
  const treeId = useId();
64
58
  const isControlled = value !== undefined;
@@ -88,12 +82,12 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
88
82
  aria-haspopup="tree"
89
83
  aria-expanded={open}
90
84
  aria-controls={treeId}
91
- className={cn("h-auto min-h-9 w-64 justify-between font-normal", className)}
85
+ className={cn("h-auto min-h-9 w-full justify-between font-normal", className)}
92
86
  {...props}
93
87
  >
94
88
  <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
95
89
  {labels.length === 0 ? (
96
- <span className="text-muted-foreground">{placeholder}</span>
90
+ <span className="text-muted-foreground">{resolvedPlaceholder}</span>
97
91
  ) : multiple ? (
98
92
  <>
99
93
  {labels.slice(0, 2).map((l) => (
@@ -106,7 +100,10 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
106
100
  </Badge>
107
101
  ))}
108
102
  {labels.length > 2 ? (
109
- <Badge variant="secondary" aria-label={`and ${labels.length - 2} more selected`}>
103
+ <Badge
104
+ variant="secondary"
105
+ aria-label={t("ui.treeSelect.moreSelected", { count: labels.length - 2 })}
106
+ >
110
107
  +{labels.length - 2}
111
108
  </Badge>
112
109
  ) : null}
@@ -118,7 +115,7 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
118
115
  <ChevronsUpDown className="ms-2 size-4 shrink-0 opacity-50" aria-hidden="true" />
119
116
  </Button>
120
117
  </PopoverTrigger>
121
- <PopoverContent className="w-64 p-1" align="start">
118
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] min-w-64 p-1" align="start">
122
119
  <Tree
123
120
  id={treeId}
124
121
  nodes={nodes}
@@ -20,12 +20,13 @@ import { ProseLink } from "./prose";
20
20
  afterEach(cleanup);
21
21
 
22
22
  describe("ProseLink — #399 on-surface brand-accent rung", () => {
23
- it("renders text-primary-text, never the text-primary fill rung", () => {
23
+ // `text-link` reads `--link`, which the reference themes alias to `--primary-text`.
24
+ it("renders the link ink, never the text-primary fill rung", () => {
24
25
  render(<ProseLink href="https://example.com">docs</ProseLink>);
25
26
 
26
27
  const link = screen.getByRole("link", { name: "docs" });
27
28
  const classes = link.className.split(/\s+/);
28
- expect(classes).toContain("text-primary-text");
29
+ expect(classes).toContain("text-link");
29
30
  expect(classes).not.toContain("text-primary");
30
31
  });
31
32
 
@@ -116,10 +116,10 @@ export const ProseLink = forwardRef<HTMLAnchorElement, ProseLinkProps>(function
116
116
  ref={ref}
117
117
  href={href}
118
118
  className={cn(
119
- // #399 — `text-primary-text`, not the `text-primary` FILL rung: a prose
120
- // link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
119
+ // #399 — `text-link` (defaults to `--primary-text`), not the `text-primary`
120
+ // FILL rung: a prose link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
121
121
  // fill only guarantees at the 1.4.11 mark bar (3:1).
122
- "font-medium text-primary-text underline underline-offset-4 hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
122
+ "font-medium text-link underline underline-offset-4 hover:no-underline focus-ring",
123
123
  className,
124
124
  )}
125
125
  {...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
@@ -1,6 +1,7 @@
1
1
  import { useLayoutEffect, useRef, useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
3
  import { expect } from "storybook/test";
4
+ import { cn } from "../../lib/cn";
4
5
  import { Heading, Text } from "./typography";
5
6
  import {
6
7
  ProseBlockquote,
@@ -242,3 +243,230 @@ export const Prose: Story = {
242
243
  </div>
243
244
  ),
244
245
  };
246
+
247
+ /**
248
+ * The CJK FONT FALLBACK + LINE-BREAKING seam (#15). `--font-cjk-sans` is a
249
+ * dedicated tail in `--font-sans` (system faces only — never bundled; a CJK
250
+ * face is megabytes and every target platform already ships one) that
251
+ * `:lang(zh)` / `:lang(ko)` re-point per locale (`ja` is the root default);
252
+ * `:lang(ja)`/`:lang(zh)` also get `line-break: strict` and `:lang(ko)` gets
253
+ * `word-break: keep-all` (`themes.css` §§ "CJK PER-LOCALE FONT SEAM" / "CJK
254
+ * line-breaking"). Before this story, no story anywhere rendered CJK text, so
255
+ * none of this was exercised.
256
+ *
257
+ * The designed real-world shape is `<html lang="zh">` for a whole document,
258
+ * with `font-family: var(--font-sans)` declared ONCE at that same theme-root
259
+ * element. That matters for how this story has to be built: a custom
260
+ * property's `var()` references are substituted at the element that declares
261
+ * the property, and the RESULT — not the raw token stream — is what
262
+ * descendants inherit. So `--font-sans`'s `var(--font-cjk-sans)` is resolved
263
+ * once, at the theme root, using whatever `lang` is active THERE; a nested
264
+ * `:lang(zh)` further down only re-points `--font-cjk-sans` on that
265
+ * descendant — it does nothing for `--font-sans`, which the descendant never
266
+ * redeclares, only inherits pre-resolved. To show all three locales side by
267
+ * side under one shared theme root (whose own `lang` cannot be three things
268
+ * at once), each "with the seam" sample below redeclares the SAME expansion
269
+ * `--font-sans` itself uses (see `themes/light.css`/`themes/dark.css`)
270
+ * directly in its `style`, so the `var(--font-cjk-sans)` substitution happens
271
+ * fresh, at that element, against the `--font-cjk-sans` value its own `lang`
272
+ * cascades in via `:lang()`.
273
+ */
274
+ type CjkSample = {
275
+ lang: "ja" | "zh" | "ko";
276
+ label: string;
277
+ rule: string;
278
+ text: string;
279
+ width?: string; // Optional custom width, defaults to w-56
280
+ };
281
+
282
+ const CJK_SAMPLES: readonly CjkSample[] = [
283
+ {
284
+ lang: "ja",
285
+ label: "Japanese (ja)",
286
+ rule: "line-break: strict",
287
+ text:
288
+ "この文章は十分に長く、コンテナの幅を超えるとブラウザが自動的に折り返しを行います。" +
289
+ "折り返しの際に句読点や閉じ括弧(このように)が行頭に来ないようにするのが、" +
290
+ "line-break: strict の役割です。",
291
+ },
292
+ {
293
+ lang: "zh",
294
+ label: "Chinese (zh)",
295
+ rule: "line-break: strict",
296
+ text:
297
+ "这段文字足够长,当容器宽度不足时浏览器会自动换行。line-break: strict 的作用是避免" +
298
+ "标点符号(例如逗号、句号或右括号)出现在行首,让排版更符合中文书写习惯。",
299
+ },
300
+ {
301
+ lang: "ko",
302
+ label: "Korean (ko)",
303
+ rule: "word-break: keep-all",
304
+ text:
305
+ "소프트웨어개발환경구축및정보통신기술표준화를위한국제협력과연구개발사업이진행되고있습니다. " +
306
+ "word-break: keep-all 속성은 긴 복합어 중간에서 줄이 바뀌지 않도록 보장하여 " +
307
+ "가독성을 지켜 줍니다.",
308
+ width: "w-36",
309
+ },
310
+ ];
311
+
312
+ /** One locale panel: the seam ON (real `lang`) next to the seam forced OFF, for comparison. */
313
+ function CjkPanel({ sample }: { sample: CjkSample }) {
314
+ const { lang, label, rule, text, width = "w-56" } = sample;
315
+ const withSeamRef = useRef<HTMLParagraphElement>(null);
316
+ const [measured, setMeasured] = useState("");
317
+ useLayoutEffect(() => {
318
+ const el = withSeamRef.current;
319
+ if (!el) return;
320
+ const { lineBreak, wordBreak, fontFamily } = getComputedStyle(el);
321
+ setMeasured(`line-break: ${lineBreak} · word-break: ${wordBreak} · font-family: ${fontFamily}`);
322
+ }, []);
323
+ return (
324
+ <section aria-labelledby={`cjk-${lang}`} className="space-y-2">
325
+ <h3 id={`cjk-${lang}`} className="text-subtitle text-foreground">
326
+ {label} — <code className="text-code">{rule}</code>
327
+ </h3>
328
+ <div className="grid gap-4 sm:grid-cols-2">
329
+ <figure className="m-0 space-y-1">
330
+ <figcaption className="text-meta text-muted-foreground">
331
+ Without the seam (comparison only — line-break/word-break forced to the CSS-initial
332
+ default)
333
+ </figcaption>
334
+ {/* Comparison-only override to show what the seam replaces; a real
335
+ consumer never does this. Font-family is left alone here — this
336
+ column isolates the LINE-BREAKING half of the seam. */}
337
+ <p
338
+ lang={lang}
339
+ style={{ fontFamily: "var(--font-sans)", lineBreak: "auto", wordBreak: "normal" }}
340
+ className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
341
+ >
342
+ {text}
343
+ </p>
344
+ </figure>
345
+ <figure className="m-0 space-y-1">
346
+ <figcaption className="text-meta text-muted-foreground">
347
+ With the seam (the default result of{" "}
348
+ <code className="text-code">lang=&quot;{lang}&quot;</code>)
349
+ </figcaption>
350
+ <p
351
+ ref={withSeamRef}
352
+ lang={lang}
353
+ data-testid={`cjk-with-seam-${lang}`}
354
+ // Redeclares --font-sans's own expansion (themes/light.css,
355
+ // themes/dark.css) directly, rather than using
356
+ // `var(--font-sans)`, because --font-sans is inherited from the
357
+ // shared theme root as an ALREADY-substituted string — see the
358
+ // comment above CjkSample. Writing the expansion here forces the
359
+ // `var(--font-cjk-sans)` substitution to happen at THIS element,
360
+ // against the value its own `lang` cascades in.
361
+ style={{
362
+ fontFamily:
363
+ 'Inter, var(--font-cjk-sans), "Helvetica Neue", Helvetica, Arial, sans-serif',
364
+ }}
365
+ className={cn("m-0 rounded-md border border-border bg-card p-3 text-body", width)}
366
+ >
367
+ {text}
368
+ </p>
369
+ </figure>
370
+ </div>
371
+ {/* The declared (not rendered) values — a real, un-bundled system CJK
372
+ face never looks identical across two machines, so THIS is what a
373
+ reviewer can actually verify: the correct stack is being asked for.
374
+
375
+ NOTE: document.fonts.check() is NOT used to verify font resolution
376
+ (issue #62). That API reports load status, not face existence — it
377
+ returns true even for nonexistent font names. The measured values
378
+ instead read the browser's own getComputedStyle output, which is
379
+ verifiable. */}
380
+ <p className="m-0 text-meta text-muted-foreground" data-testid={`cjk-measured-${lang}`}>
381
+ {measured || "measuring…"}
382
+ </p>
383
+ </section>
384
+ );
385
+ }
386
+
387
+ export const CJKFontAndLineBreaking: Story = {
388
+ name: "CJK font fallback + line-breaking (#15)",
389
+ parameters: {
390
+ docs: {
391
+ description: {
392
+ story:
393
+ "Japanese, Chinese and Korean samples, each wrapped in a narrow (14rem) column so " +
394
+ "the text wraps across multiple lines — the line-breaking rules need a real wrap " +
395
+ "point to have anything to do.\n\n" +
396
+ "**Verification by locale:**\n" +
397
+ "* **Japanese (`ja`) and Chinese (`zh`):** `line-break: strict` is verified via " +
398
+ "computed style only; no empirically-validated visual wrap difference has been found at " +
399
+ "this column width for any practical sample text in chromium or webkit, so the rendered " +
400
+ "layout is NOT asserted to differ. The rule is confirmed present via `getComputedStyle`.\n" +
401
+ "* **Korean (`ko`):** `word-break: keep-all` is verified both via computed style AND " +
402
+ "an actual rendered wrap-position difference assertion in the play function.\n\n" +
403
+ "The measured line under each pair reads the browser's OWN computed `line-break` / " +
404
+ "`word-break` / `font-family`, live. Rendered glyph shape depends on which CJK system " +
405
+ "fonts are installed on the machine viewing this story (none are bundled, by design " +
406
+ "— #15); the declared font STACK is what is verifiable everywhere.",
407
+ },
408
+ },
409
+ },
410
+ render: () => (
411
+ <div className="max-w-3xl space-y-8">
412
+ {CJK_SAMPLES.map((s) => (
413
+ <CjkPanel key={s.lang} sample={s} />
414
+ ))}
415
+ </div>
416
+ ),
417
+ // Asserts the seam on the REAL rendered surface, mirroring the DensityScale
418
+ // play function above: read the browser's actual computed style rather than
419
+ // re-deriving the CSS by hand.
420
+ play: async ({ canvas }) => {
421
+ const ja = canvas.getByTestId("cjk-with-seam-ja");
422
+ const zh = canvas.getByTestId("cjk-with-seam-zh");
423
+ const ko = canvas.getByTestId("cjk-with-seam-ko");
424
+
425
+ // :lang(ja), :lang(zh) => line-break: strict. :lang(ko) is NOT in that
426
+ // rule, so it stays at the CSS-initial value ("auto").
427
+ expect(getComputedStyle(ja).lineBreak).toBe("strict");
428
+ expect(getComputedStyle(zh).lineBreak).toBe("strict");
429
+ expect(getComputedStyle(ko).lineBreak).toBe("auto");
430
+
431
+ // :lang(ko) => word-break: keep-all. ja/zh are NOT in that rule, so they
432
+ // stay at the CSS-initial value ("normal").
433
+ expect(getComputedStyle(ko).wordBreak).toBe("keep-all");
434
+ expect(getComputedStyle(ja).wordBreak).toBe("normal");
435
+ expect(getComputedStyle(zh).wordBreak).toBe("normal");
436
+
437
+ // --font-cjk-sans is re-pointed per :lang() — the DECLARED face list
438
+ // differs by locale, which is what the fallback seam actually ships.
439
+ expect(getComputedStyle(ja).fontFamily).toContain("Hiragino Sans");
440
+ expect(getComputedStyle(zh).fontFamily).toContain("PingFang SC");
441
+ expect(getComputedStyle(ko).fontFamily).toContain("Apple SD Gothic Neo");
442
+
443
+ // Korean's word-break: keep-all must produce a RENDERED wrap-position
444
+ // difference. Measure bounding height via getClientRects — if the seam
445
+ // (word-break: keep-all) doesn't change the wrap, the height stays the
446
+ // same. Find the corresponding "without the seam" panel by the section's
447
+ // sibling figure.
448
+ const koSection = canvas.getByTestId("cjk-with-seam-ko").closest("section");
449
+ if (!koSection) throw new Error("Korean section not found");
450
+ const koFigures = Array.from(koSection.querySelectorAll("figure"));
451
+ if (koFigures.length < 2) throw new Error("Korean figures not found");
452
+
453
+ const withSeamFigure = koFigures[1]; // "With the seam" is the second figure
454
+ const withoutSeamFigure = koFigures[0]; // "Without the seam" is the first figure
455
+
456
+ const withSeamP = withSeamFigure.querySelector("p");
457
+ const withoutSeamP = withoutSeamFigure.querySelector("p");
458
+
459
+ if (!withSeamP || !withoutSeamP) {
460
+ throw new Error("Korean paragraph elements not found");
461
+ }
462
+
463
+ // Measure bounding height: the word-break property should change how the
464
+ // text wraps, resulting in a different total height.
465
+ const withSeamHeight = withSeamP.getBoundingClientRect().height;
466
+ const withoutSeamHeight = withoutSeamP.getBoundingClientRect().height;
467
+
468
+ // Assert they differ — word-break: keep-all should prevent word breaks
469
+ // that would otherwise occur, changing the layout.
470
+ expect(withSeamHeight).not.toBe(withoutSeamHeight);
471
+ },
472
+ };
@@ -11,7 +11,15 @@ const meta = {
11
11
  title: "Layout/ViewToolbar",
12
12
  component: ViewToolbar,
13
13
  tags: ["autodocs"],
14
- parameters: { layout: "padded" },
14
+ parameters: {
15
+ layout: "padded",
16
+ docs: {
17
+ description: {
18
+ component:
19
+ "The row above a list, table or board — filters and context on the left, actions on the right. Its controls are ordinary tab stops on purpose: a dense secondary control row that should cost ONE tab stop with arrow-key roving is `Layout/Toolbar`, the component that actually claims the `toolbar` ARIA role. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs) and [Docs / View Toolbar Contract](?path=/docs/docs-view-toolbar-contract--docs) (rule R10) for the filter-chip, result-count and control-height rules of this row.",
20
+ },
21
+ },
22
+ },
15
23
  argTypes: {
16
24
  info: {
17
25
  description: "What this view is — shown in a tooltip on the ⓘ trigger.",
@@ -313,6 +321,38 @@ export const KeyboardRemoval: Story = {
313
321
  },
314
322
  };
315
323
 
324
+ /**
325
+ * `FilterChip`'s `trailing` slot (#284) — a short, non-shrinking secondary
326
+ * text segment (a count) that CSS truncation can never reach, unlike `label`.
327
+ * In a narrow container the label truncates with an ellipsis; the count
328
+ * stays fully visible in its own element.
329
+ */
330
+ export const FilterChipWithTrailing: Story = {
331
+ render: () => (
332
+ <div className="w-[220px]">
333
+ <FilterChip
334
+ label="Status: Awaiting downstream reconciliation review"
335
+ trailing="excluded 1,204"
336
+ onRemove={() => undefined}
337
+ />
338
+ </div>
339
+ ),
340
+ play: async ({ canvas, canvasElement }) => {
341
+ const trailing = await canvas.findByText("excluded 1,204");
342
+ await expect(trailing).toBeInTheDocument();
343
+
344
+ const truncating = canvasElement.querySelector('[class*="truncate"]');
345
+ await expect(truncating).not.toBeNull();
346
+ await expect(truncating?.contains(trailing)).toBe(false);
347
+
348
+ await expect(
349
+ canvas.getByRole("button", {
350
+ name: "Remove filter: Status: Awaiting downstream reconciliation review · excluded 1,204",
351
+ }),
352
+ ).toBeInTheDocument();
353
+ },
354
+ };
355
+
316
356
  /** The same row on `dark`. */
317
357
  export const DarkTheme: Story = {
318
358
  globals: { theme: "dark" },
@@ -208,6 +208,39 @@ describe("FilterChip", () => {
208
208
  expect(chip).not.toBeNull();
209
209
  expect(ref.current).toBe(chip);
210
210
  });
211
+
212
+ describe("trailing slot", () => {
213
+ it("renders trailing in its own element, separate from the truncating label span (#284)", () => {
214
+ const { container } = render(
215
+ <FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
216
+ );
217
+ const label = container.querySelector("[data-slot='view-toolbar-filter-chip-label']");
218
+ const trailing = container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']");
219
+ expect(label).not.toBeNull();
220
+ expect(trailing).not.toBeNull();
221
+ expect(label?.textContent).toBe("Status: Failed");
222
+ expect(trailing?.textContent).toBe("excluded 1,204");
223
+ expect(label?.className).toContain("truncate");
224
+ expect(trailing?.contains(label)).toBe(false);
225
+ expect(label?.contains(trailing as Node)).toBe(false);
226
+ });
227
+
228
+ it("composes label and trailing into the accessible name", () => {
229
+ render(
230
+ <FilterChip label="Status: Failed" trailing="excluded 1,204" onRemove={() => undefined} />,
231
+ );
232
+ expect(
233
+ screen.getByRole("button", { name: "Remove filter: Status: Failed · excluded 1,204" }),
234
+ ).toBeInTheDocument();
235
+ });
236
+
237
+ it("renders no trailing element when trailing is omitted", () => {
238
+ const { container } = render(
239
+ <FilterChip label="Status: Failed" onRemove={() => undefined} />,
240
+ );
241
+ expect(container.querySelector("[data-slot='view-toolbar-filter-chip-trailing']")).toBeNull();
242
+ });
243
+ });
211
244
  });
212
245
 
213
246
  describe("ResultCount", () => {
@@ -200,6 +200,22 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
200
200
  * as a sentence fragment out of context (and in a screen reader).
201
201
  */
202
202
  label: string;
203
+ /**
204
+ * Short, non-shrinking secondary text — a count, a unit (`"excluded 1,204"`).
205
+ * A second text slot rather than folding it into `label` (#284): `label` is
206
+ * the shrinkable, safe-to-truncate part; `trailing` is short and fixed-width
207
+ * and must never lose characters to the ellipsis. Same shape as
208
+ * `TurnStatus`'s and `TerminalWorking`'s `trailing` slot — a fixed-width
209
+ * companion beside a truncating label. Composed into the accessible name
210
+ * after `label` (`"Remove filter: <label> · <trailing>"`), so the byte-
211
+ * identical name from before this slot existed is unchanged.
212
+ *
213
+ * Trade-off: `trailing` is `shrink-0`, so an extremely narrow container now
214
+ * truncates `label` down to nothing before it touches `trailing`. That is
215
+ * the right default — the count is short and fixed, the label is the long,
216
+ * prose-shaped part — but it is a choice, not a free win.
217
+ */
218
+ trailing?: string;
203
219
  /** Remove this filter. The WHOLE chip is the remove control — see below. */
204
220
  onRemove: () => void;
205
221
  }
@@ -217,7 +233,7 @@ export interface FilterChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
217
233
  * reach for `FacetFilter` (`@elabs-ai/components-data`), not a variant of this.
218
234
  */
219
235
  export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(function FilterChip(
220
- { label, onRemove, className, onClick, ...props },
236
+ { label, trailing, onRemove, className, onClick, ...props },
221
237
  ref,
222
238
  ) {
223
239
  const { t } = useLocale();
@@ -227,14 +243,16 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
227
243
  ref={ref}
228
244
  type="button"
229
245
  data-slot="view-toolbar-filter-chip"
230
- aria-label={t("ui.viewToolbar.removeFilter", { label })}
246
+ aria-label={t("ui.viewToolbar.removeFilter", {
247
+ label: trailing ? `${label} · ${trailing}` : label,
248
+ })}
231
249
  className={cn(
232
250
  badgeVariants({ variant: "secondary" }),
233
251
  "group min-h-6 max-w-full gap-1 ps-2.5 pe-1.5",
234
252
  "transition-colors duration-fast ease-standard hover:bg-secondary/70",
235
253
  // Badge's own `focus:` ring is dead weight on a <span> but would fire on
236
254
  // every MOUSE click here; neutralise it and keep the focus-visible ring.
237
- "focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
255
+ "focus:ring-0 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
238
256
  "disabled:pointer-events-none disabled:opacity-50",
239
257
  className,
240
258
  )}
@@ -244,7 +262,22 @@ export const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(functio
244
262
  }}
245
263
  {...props}
246
264
  >
247
- <span className="truncate">{label}</span>
265
+ <span data-slot="view-toolbar-filter-chip-label" className="min-w-0 truncate">
266
+ {label}
267
+ </span>
268
+ {trailing ? (
269
+ <>
270
+ <span aria-hidden="true" className="shrink-0 text-muted-foreground">
271
+ ·
272
+ </span>
273
+ <span
274
+ data-slot="view-toolbar-filter-chip-trailing"
275
+ className="shrink-0 whitespace-nowrap tabular-nums"
276
+ >
277
+ {trailing}
278
+ </span>
279
+ </>
280
+ ) : null}
248
281
  <X
249
282
  aria-hidden="true"
250
283
  className="size-3 shrink-0 opacity-60 transition-opacity duration-fast ease-standard group-hover:opacity-100"
@@ -58,6 +58,15 @@ const meta = {
58
58
  },
59
59
  },
60
60
  },
61
+ // The trigger fills its container by default (no hard-fixed width) — cap
62
+ // the docs canvas to a realistic form-field width.
63
+ decorators: [
64
+ (Story) => (
65
+ <div className="w-72">
66
+ <Story />
67
+ </div>
68
+ ),
69
+ ],
61
70
  argTypes: {
62
71
  options: {
63
72
  description:
@@ -10,10 +10,10 @@
10
10
  * - Search filtering while open
11
11
  * - Keyboard navigation (ArrowDown/Up/Enter/Escape) driving aria-activedescendant
12
12
  * - Virtualizer scroll behavior with 10,000 options
13
- * - Six-theme visual correctness
13
+ * - Cross-theme visual correctness
14
14
  */
15
- import { describe, expect, it } from "vitest";
16
- import { render, screen } from "@testing-library/react";
15
+ import { describe, expect, it, vi } from "vitest";
16
+ import { fireEvent, render, screen } from "@testing-library/react";
17
17
  import { VirtualSelect } from "./virtual-select";
18
18
  import type { VirtualSelectOption } from "./virtual-select";
19
19
 
@@ -104,3 +104,29 @@ describe("VirtualSelect defaultValue (uncontrolled)", () => {
104
104
  expect(screen.getByText("Gamma")).toBeInTheDocument();
105
105
  });
106
106
  });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Deferred autofocus timer — cancelled on unmount (#reviewed)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe("VirtualSelect autofocus timer cleanup", () => {
113
+ it("cancels the pending search-input autofocus timeout when unmounted before it fires", () => {
114
+ vi.useFakeTimers();
115
+ try {
116
+ const { unmount } = render(<VirtualSelect options={OPTIONS} />);
117
+ const trigger = screen.getByRole("combobox");
118
+ // Opening schedules a deferred `searchRef.current?.focus()`.
119
+ fireEvent.click(trigger);
120
+ const pendingBeforeUnmount = vi.getTimerCount();
121
+ expect(pendingBeforeUnmount).toBeGreaterThan(0);
122
+
123
+ unmount();
124
+
125
+ // The pending timer must be cancelled on unmount — left uncancelled, it
126
+ // stays scheduled and fires later against a torn-down `searchRef`.
127
+ expect(vi.getTimerCount()).toBeLessThan(pendingBeforeUnmount);
128
+ } finally {
129
+ vi.useRealTimers();
130
+ }
131
+ });
132
+ });