@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
@@ -0,0 +1,70 @@
1
+ import { forwardRef, type ComponentProps } from "react";
2
+ import { Search } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Kbd } from "../kbd";
5
+
6
+ export interface CommandTriggerProps extends ComponentProps<"button"> {
7
+ /** The visible and announced label. Defaults to "Search". */
8
+ label?: string;
9
+ /** The shortcut hint. Defaults to the platform-correct ⌘ K / Ctrl K. */
10
+ shortcut?: string;
11
+ }
12
+
13
+ function platformShortcut(): string {
14
+ if (typeof navigator === "undefined") return "Ctrl K";
15
+ // `userAgentData.platform` where available, else the legacy string. Both are
16
+ // hints, not guarantees — the trigger is a hint too, so a wrong guess is cosmetic.
17
+ const platform =
18
+ (navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ??
19
+ navigator.platform ??
20
+ "";
21
+ return /mac|iphone|ipad/i.test(platform) ? "⌘ K" : "Ctrl K";
22
+ }
23
+
24
+ /**
25
+ * The command-palette opener, shaped like a search field: label left, shortcut
26
+ * pinned right, collapsing to the icon alone under `sm`.
27
+ *
28
+ * The button’s accessible name comes entirely from `aria-label` — that is what
29
+ * stops the `Kbd`’s shortcut glyph from concatenating into it (#117); a
30
+ * non-empty `aria-label` short-circuits accessible-name computation before the
31
+ * DOM is ever consulted. The visible label and the `Kbd` are ALSO marked
32
+ * `aria-hidden`, per `accessibility.md`, as defence in depth: if `aria-label`
33
+ * is ever dropped, that turns a silent regression into a loud one — an empty
34
+ * accessible name rather than a plausible wrong one. They do not do the work
35
+ * themselves.
36
+ *
37
+ * `shortcut` reads `navigator` at render time when omitted, which can differ
38
+ * between server and client — a consumer that server-renders this component
39
+ * should pass `shortcut` explicitly to avoid a hydration mismatch.
40
+ */
41
+ export const CommandTrigger = forwardRef<HTMLButtonElement, CommandTriggerProps>(
42
+ function CommandTrigger({ label = "Search", shortcut, className, ...props }, ref) {
43
+ const hint = shortcut ?? platformShortcut();
44
+ return (
45
+ <button
46
+ ref={ref}
47
+ type="button"
48
+ data-slot="command-trigger"
49
+ aria-label={label}
50
+ className={cn(
51
+ "inline-flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2 text-body text-muted-foreground",
52
+ "hover:bg-accent hover:text-accent-foreground focus-ring",
53
+ "sm:w-56 sm:justify-between sm:ps-2 sm:pe-1.5",
54
+ className,
55
+ )}
56
+ {...props}
57
+ >
58
+ <span className="flex min-w-0 items-center gap-2">
59
+ <Search aria-hidden="true" className="size-4 shrink-0" />
60
+ <span aria-hidden="true" className="hidden truncate sm:inline">
61
+ {label}
62
+ </span>
63
+ </span>
64
+ <Kbd aria-hidden="true" className="hidden sm:inline-flex">
65
+ {hint}
66
+ </Kbd>
67
+ </button>
68
+ );
69
+ },
70
+ );
@@ -0,0 +1 @@
1
+ export * from "./command-trigger";
@@ -21,8 +21,9 @@ export const ContextMenuSubTrigger = forwardRef<
21
21
  return (
22
22
  <ContextMenuPrimitive.SubTrigger
23
23
  ref={ref}
24
+ data-slot="context-menu-sub-trigger"
24
25
  className={cn(
25
- "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
26
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
26
27
  inset && "ps-8",
27
28
  className,
28
29
  )}
@@ -41,6 +42,7 @@ export const ContextMenuSubContent = forwardRef<
41
42
  return (
42
43
  <ContextMenuPrimitive.SubContent
43
44
  ref={ref}
45
+ data-slot="context-menu-sub-content"
44
46
  className={cn(
45
47
  "z-50 min-w-[8rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
46
48
  className,
@@ -58,8 +60,9 @@ export const ContextMenuContent = forwardRef<
58
60
  <ContextMenuPrimitive.Portal>
59
61
  <ContextMenuPrimitive.Content
60
62
  ref={ref}
63
+ data-slot="context-menu-content"
61
64
  className={cn(
62
- "z-50 min-w-[8rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
65
+ "z-50 min-w-[12rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
63
66
  className,
64
67
  )}
65
68
  {...props}
@@ -75,8 +78,9 @@ export const ContextMenuItem = forwardRef<
75
78
  return (
76
79
  <ContextMenuPrimitive.Item
77
80
  ref={ref}
81
+ data-slot="context-menu-item"
78
82
  className={cn(
79
- "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
83
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
80
84
  inset && "ps-8",
81
85
  className,
82
86
  )}
@@ -92,9 +96,10 @@ export const ContextMenuCheckboxItem = forwardRef<
92
96
  return (
93
97
  <ContextMenuPrimitive.CheckboxItem
94
98
  ref={ref}
99
+ data-slot="context-menu-checkbox-item"
95
100
  checked={checked}
96
101
  className={cn(
97
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
102
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
98
103
  className,
99
104
  )}
100
105
  {...props}
@@ -116,8 +121,9 @@ export const ContextMenuRadioItem = forwardRef<
116
121
  return (
117
122
  <ContextMenuPrimitive.RadioItem
118
123
  ref={ref}
124
+ data-slot="context-menu-radio-item"
119
125
  className={cn(
120
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
126
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
121
127
  className,
122
128
  )}
123
129
  {...props}
@@ -139,8 +145,9 @@ export const ContextMenuLabel = forwardRef<
139
145
  return (
140
146
  <ContextMenuPrimitive.Label
141
147
  ref={ref}
148
+ data-slot="context-menu-label"
142
149
  className={cn(
143
- "px-2 py-1.5 text-xs font-semibold text-muted-foreground",
150
+ "px-2 py-1.5 text-meta font-semibold text-muted-foreground",
144
151
  inset && "ps-8",
145
152
  className,
146
153
  )}
@@ -156,6 +163,7 @@ export const ContextMenuSeparator = forwardRef<
156
163
  return (
157
164
  <ContextMenuPrimitive.Separator
158
165
  ref={ref}
166
+ data-slot="context-menu-separator"
159
167
  className={cn("-mx-1 my-1 h-px bg-border", className)}
160
168
  {...props}
161
169
  />
@@ -165,7 +173,8 @@ export const ContextMenuSeparator = forwardRef<
165
173
  export function ContextMenuShortcut({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
166
174
  return (
167
175
  <span
168
- className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
176
+ data-slot="context-menu-shortcut"
177
+ className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
169
178
  {...props}
170
179
  />
171
180
  );
@@ -0,0 +1,285 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { FileText, History, MessageSquare } from "lucide-react";
3
+ import { expect, screen, waitFor } from "storybook/test";
4
+
5
+ import { ContextRail, type ContextRailSection } from "./context-rail";
6
+
7
+ const meta = {
8
+ title: "Layout/ContextRail",
9
+ component: ContextRail,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ layout: "fullscreen",
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "A right-hand rail whose collapsed state is a 48px icon strip that doubles as its own section switcher — click a switcher entry to make it active, click the ACTIVE entry again to collapse/expand. Below `overlayBreakpoint` (default 768) it renders the strip plus a `Sheet` overlay for the expanded body instead of a fixed-width panel. See `docs/ADR/0035-context-rail-and-side-dock.md`.",
17
+ },
18
+ },
19
+ },
20
+ } satisfies Meta<typeof ContextRail>;
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ const sections: ContextRailSection[] = [
25
+ {
26
+ id: "sources",
27
+ label: "Sources",
28
+ icon: <FileText />,
29
+ count: 4,
30
+ content: (
31
+ <ul className="flex flex-col gap-2 text-body">
32
+ <li className="rounded-md border border-border p-2">quarterly-report.pdf — p.12</li>
33
+ <li className="rounded-md border border-border p-2">design-notes.md — §3</li>
34
+ <li className="rounded-md border border-border p-2">onboarding-flow.fig</li>
35
+ <li className="rounded-md border border-border p-2">support-thread-4821</li>
36
+ </ul>
37
+ ),
38
+ },
39
+ {
40
+ id: "comments",
41
+ label: "Comments",
42
+ icon: <MessageSquare />,
43
+ count: 2,
44
+ content: (
45
+ <ul className="flex flex-col gap-2 text-body">
46
+ <li className="rounded-md border border-border p-2">
47
+ &ldquo;Can we tighten this copy?&rdquo; — Priya
48
+ </li>
49
+ <li className="rounded-md border border-border p-2">
50
+ &ldquo;Looks good, shipping.&rdquo; — Sam
51
+ </li>
52
+ </ul>
53
+ ),
54
+ },
55
+ {
56
+ id: "activity",
57
+ label: "Activity",
58
+ icon: <History />,
59
+ content: (
60
+ <ul className="flex flex-col gap-2 text-body text-muted-foreground">
61
+ <li>Created 2 hours ago</li>
62
+ <li>Edited by Priya, 40 minutes ago</li>
63
+ <li>Reviewed by Sam, 5 minutes ago</li>
64
+ </ul>
65
+ ),
66
+ },
67
+ ];
68
+
69
+ /** Expanded (`open`, default), three believable sections, the first active. */
70
+ export const Default: Story = {
71
+ render: () => (
72
+ <div className="flex h-[560px] w-full">
73
+ <div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
74
+ <ContextRail sections={sections} defaultActiveSectionId="sources" />
75
+ </div>
76
+ ),
77
+ play: async ({ canvas }) => {
78
+ await expect(canvas.getByRole("button", { name: "Sources 4 items" })).toBeInTheDocument();
79
+ await expect(canvas.getByRole("button", { name: "Comments 2 items" })).toBeInTheDocument();
80
+ await expect(canvas.getByRole("button", { name: "Activity" })).toBeInTheDocument();
81
+ await expect(canvas.getByText("Sources")).toBeInTheDocument();
82
+ },
83
+ };
84
+
85
+ /** `defaultOpen={false}` — the collapsed 48px icon strip, still fully operable as a switcher (uncontrolled, so the click-to-expand in the play function actually moves state). */
86
+ export const Collapsed: Story = {
87
+ render: () => (
88
+ <div className="flex h-[560px] w-full">
89
+ <div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
90
+ <ContextRail sections={sections} defaultActiveSectionId="sources" defaultOpen={false} />
91
+ </div>
92
+ ),
93
+ play: async ({ canvas, userEvent }) => {
94
+ const sourcesEntry = await canvas.findByRole("button", { name: "Sources 4 items" });
95
+ await expect(sourcesEntry).toBeInTheDocument();
96
+ // Content stays mounted while collapsed (only the ACTIVE section's
97
+ // content is ever mounted at all — collapsing hides it visually via
98
+ // CSS, it doesn't unmount it) — so assert visibility, not presence.
99
+ await expect(canvas.getByText("quarterly-report.pdf — p.12")).not.toBeVisible();
100
+
101
+ // Clicking the ACTIVE entry while collapsed expands the rail again.
102
+ await userEvent.click(sourcesEntry);
103
+ await expect(canvas.getByText("quarterly-report.pdf — p.12")).toBeVisible();
104
+ },
105
+ };
106
+
107
+ /** No sections — the default localized empty state. */
108
+ export const Empty: Story = {
109
+ render: () => (
110
+ <div className="flex h-[560px] w-full">
111
+ <div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
112
+ <ContextRail sections={[]} />
113
+ </div>
114
+ ),
115
+ play: async ({ canvas }) => {
116
+ await expect(await canvas.findByText("No sections")).toBeInTheDocument();
117
+ },
118
+ };
119
+
120
+ /**
121
+ * Below `overlayBreakpoint` the rail mounts its own strip plus a `Sheet`
122
+ * instead of `Sidebar`. A viewport-size story parameter cannot force this
123
+ * branch under a headless run (the composed story's `window.innerWidth`
124
+ * doesn't track the addon's viewport there), so this story forces it
125
+ * deterministically with a very high `overlayBreakpoint` instead — reliable
126
+ * both interactively and under `vitest --project storybook`.
127
+ *
128
+ * Known constraint (task-11f-brief.md Finding 5): this story's own fixed
129
+ * `h-[560px]` host is shorter than the browser viewport, so the expanded
130
+ * body's `Sheet` (always viewport-height) visibly outruns the persistent
131
+ * strip below it — see the seam this reproduces in `ContextRail`'s own doc
132
+ * comment.
133
+ */
134
+ export const Narrow: Story = {
135
+ // The sheet's real entrance/exit transition is load-bearing here: the
136
+ // occlusion hit-test is taken against the SETTLED panel, and the final
137
+ // assertion reads the panel's `data-state="closed"` while its exit animation
138
+ // is still running (Radix unmounts only afterwards). The suite otherwise runs
139
+ // under `prefers-reduced-motion: reduce` (`apps/docs/vitest.config.ts`, #125),
140
+ // which removes that exit window entirely — so this story opts back into full
141
+ // motion rather than the lock being rewritten around a missing animation.
142
+ globals: { motionPref: "full" },
143
+ render: () => (
144
+ <div className="flex h-[560px] w-full">
145
+ <div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
146
+ <ContextRail sections={sections} defaultActiveSectionId="sources" overlayBreakpoint={2000} />
147
+ </div>
148
+ ),
149
+ play: async ({ canvas, canvasElement, userEvent }) => {
150
+ await expect(canvas.getByRole("button", { name: "Sources 4 items" })).toBeInTheDocument();
151
+ await expect(canvas.getByRole("button", { name: "Comments 2 items" })).toBeInTheDocument();
152
+ await expect(canvasElement.querySelector('[data-slot="sidebar"]')).toBeNull();
153
+ await expect(canvasElement.querySelector('[data-slot="context-rail"]')).not.toBeNull();
154
+
155
+ // `ContextRail` has no `open`/`defaultOpen` here, so `SidebarProvider`'s
156
+ // own default (`true`) drives the Sheet `ContextRailNarrow` wires to that
157
+ // same `open` state — the panel renders OPEN at the same time as the
158
+ // persistent 48px strip, which is exactly the configuration the
159
+ // panel-occludes-the-strip regression (fix round 1, task-9b-fix-1.md)
160
+ // needs to reproduce. `userEvent.click` only checks `pointer-events:
161
+ // none` — it does NOT hit-test — so an occluded-but-visible button would
162
+ // still report a passing click. Assert the browser's REAL hit-test
163
+ // (`document.elementFromPoint`) instead, for an INACTIVE entry and for
164
+ // the ACTIVE/toggle entry — the reviewer's raw-coordinate repro landed
165
+ // on the sheet's own close button from exactly the active entry's
166
+ // position, so both are asserted, not just the easy one.
167
+ async function assertHitTestable(name: string) {
168
+ const button = canvas.getByRole("button", { name });
169
+ // The sheet's own entrance/exit transition (`duration-slow`, 380ms —
170
+ // `themes.css`) animates the panel in via `slide-in-from-right`. The
171
+ // panel is `position: fixed`, so mid-transition it sits translated by
172
+ // roughly its own width — a hit-test taken the instant after the click
173
+ // that opened it would see that IN-FLIGHT position (measured: right
174
+ // edge past the viewport, not its resting inset-by-the-strip-width
175
+ // position) and fail for a reason that has nothing to do with the
176
+ // Blocker this lock exists to catch. Wait for the REAL, settled
177
+ // hit-test to succeed instead of sleeping a fixed amount — this reads
178
+ // as fast as the animation actually is, and still fails for real
179
+ // (after timing out) if the panel keeps covering the strip once
180
+ // settled, which is the only case task-9b-fix-1.md's regression is.
181
+ await waitFor(
182
+ () => {
183
+ const rect = button.getBoundingClientRect();
184
+ if (rect.width === 0) {
185
+ throw new Error(`"${name}" has not been laid out yet (zero width)`);
186
+ }
187
+ const x = rect.left + rect.width / 2;
188
+ const y = rect.top + rect.height / 2;
189
+ const hit = document.elementFromPoint(x, y);
190
+ const hitDescription =
191
+ hit == null
192
+ ? "nothing"
193
+ : `<${hit.tagName.toLowerCase()}${
194
+ hit.getAttribute("data-slot")
195
+ ? ` data-slot="${hit.getAttribute("data-slot")}"`
196
+ : ""
197
+ }>`;
198
+ expect(
199
+ hit != null && button.contains(hit),
200
+ `expected the centre of "${name}" (${x}, ${y}) to hit-test to that button, but it ` +
201
+ `hit ${hitDescription} instead — the sheet panel is likely covering the strip`,
202
+ ).toBe(true);
203
+ },
204
+ { timeout: 1000 },
205
+ );
206
+ return button;
207
+ }
208
+
209
+ // The switched-to section's content renders inside `SheetContent`, which
210
+ // Radix portals to `document.body` — a real DOM sibling of
211
+ // `canvasElement`, not a descendant of it — so it must be queried via
212
+ // `screen` (bound to `document.body`), not `canvas` (scoped to
213
+ // `canvasElement`).
214
+
215
+ // Inactive entry: clicking it changes the active section.
216
+ const commentsEntry = await assertHitTestable("Comments 2 items");
217
+ await userEvent.click(commentsEntry);
218
+ await expect(screen.getByText("“Looks good, shipping.” — Sam")).toBeVisible();
219
+
220
+ // Active/toggle entry: clicking it collapses the rail (closes the Sheet)
221
+ // rather than changing the section. Radix flips the panel's own
222
+ // `data-state` synchronously with the click (the exit ANIMATION, and the
223
+ // eventual unmount, follow after) — asserting that attribute is the
224
+ // immediate, non-flaky signal that the toggle actually fired, rather
225
+ // than racing the animation to observe visibility.
226
+ const commentsToggleEntry = await assertHitTestable("Comments 2 items");
227
+ await userEvent.click(commentsToggleEntry);
228
+ await expect(screen.getByRole("dialog", { hidden: true })).toHaveAttribute(
229
+ "data-state",
230
+ "closed",
231
+ );
232
+ },
233
+ };
234
+
235
+ /**
236
+ * Eight sections in the default 20rem rail — the count at which the header's
237
+ * two occupants genuinely compete for the band. `sections` is caller-supplied
238
+ * and unbounded, and the switcher is a fixed-height row of 32px buttons, so
239
+ * left uncapped it reserves its full width before the heading gets any: the
240
+ * active section's label was measured down to roughly 20px of usable width
241
+ * (its own padding included), which is no label at all. The cap keeps the
242
+ * heading's half of the band and moves the overflow into the switcher, which
243
+ * scrolls.
244
+ */
245
+ export const ManySections: Story = {
246
+ render: () => (
247
+ <div className="flex h-[560px] w-full">
248
+ <div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
249
+ <ContextRail
250
+ sections={[
251
+ ...sections,
252
+ { id: "files", label: "Files", icon: <FileText />, content: <p>Files</p> },
253
+ { id: "notes", label: "Notes", icon: <MessageSquare />, content: <p>Notes</p> },
254
+ { id: "audit", label: "Audit", icon: <History />, content: <p>Audit</p> },
255
+ { id: "links", label: "Links", icon: <FileText />, content: <p>Links</p> },
256
+ { id: "tasks", label: "Tasks", icon: <MessageSquare />, content: <p>Tasks</p> },
257
+ ]}
258
+ defaultActiveSectionId="sources"
259
+ />
260
+ </div>
261
+ ),
262
+ play: async ({ canvasElement }) => {
263
+ const header = canvasElement.querySelector<HTMLElement>('[data-slot="context-rail-header"]');
264
+ const heading = canvasElement.querySelector<HTMLElement>('[data-slot="context-rail-heading"]');
265
+ const switcher = canvasElement.querySelector<HTMLElement>(
266
+ '[data-slot="context-rail-switcher"]',
267
+ );
268
+ await expect(header).not.toBeNull();
269
+ await expect(heading).not.toBeNull();
270
+ await expect(switcher).not.toBeNull();
271
+
272
+ const headerWidth = header!.getBoundingClientRect().width;
273
+ const switcherWidth = switcher!.getBoundingClientRect().width;
274
+ const headingWidth = heading!.getBoundingClientRect().width;
275
+
276
+ // The switcher never takes more than half the band…
277
+ await expect(switcherWidth).toBeLessThanOrEqual(headerWidth / 2 + 0.5);
278
+ // …so the heading keeps a width a label can actually render in. The
279
+ // regression this locks measured ~20px here.
280
+ await expect(headingWidth).toBeGreaterThan(80);
281
+ // And the header itself never outgrows the rail (the overflow lives
282
+ // inside the switcher, which scrolls).
283
+ await expect(switcher!.scrollWidth).toBeGreaterThan(switcher!.clientWidth);
284
+ },
285
+ };