@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,678 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ComponentProps,
12
+ type CSSProperties,
13
+ type ReactNode,
14
+ type Ref,
15
+ } from "react";
16
+
17
+ import { cn } from "../../lib/cn";
18
+ import { mergeRefs } from "../../lib/merge-refs";
19
+ import { useIsMobile } from "../../lib/use-mobile";
20
+ import { useLocale } from "../locale-provider/locale-provider";
21
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet";
22
+ import {
23
+ Sidebar,
24
+ SidebarMenu,
25
+ SidebarMenuButton,
26
+ SidebarMenuItem,
27
+ SidebarProvider,
28
+ useSidebar,
29
+ } from "../sidebar";
30
+ import { StatePanel } from "../state-panel";
31
+
32
+ /** One switchable section of a `ContextRail`. */
33
+ export interface ContextRailSection {
34
+ /** Stable identifier — the switcher's `SidebarMenuButton` key and the
35
+ * value reported to `activeSectionId`/`onActiveSectionChange`. */
36
+ id: string;
37
+ /** Visible label. Feeds the switcher entry's accessible name and the
38
+ * body's heading — never rendered as visible text in the switcher
39
+ * itself, which stays icon-only in every state. */
40
+ label: string;
41
+ /** Icon shown in the switcher, in both the expanded and collapsed
42
+ * (48px icon strip) presentations. Decorative — wrapped `aria-hidden`. */
43
+ icon: ReactNode;
44
+ /** Optional count badge. Visible in BOTH states (unlike
45
+ * `SidebarMenuBadge`, which hides under `collapsible="icon"`) and folded
46
+ * into the switcher entry's accessible name, e.g. "Sources 3 items". */
47
+ count?: number;
48
+ /** Rendered only while this section is active — the rail mounts exactly
49
+ * one section's content at a time. */
50
+ content: ReactNode;
51
+ /** Disables the switcher entry; clicking it is a no-op. */
52
+ disabled?: boolean;
53
+ }
54
+
55
+ // #382: this surface is DECLARED, not derived from `Sidebar`
56
+ // (`Omit<ComponentProps<typeof Sidebar>, …>`), on purpose. `ContextRail` has
57
+ // two rendering branches and only the wide one mounts a real `Sidebar` — the
58
+ // narrow branch (`ContextRailNarrow`, below `overlayBreakpoint`) renders a
59
+ // `Sheet` instead and has no `Sidebar` to forward a `Sidebar`-only prop to.
60
+ // Deriving the type from `Sidebar` previously let `variant` leak into the
61
+ // public API and stay silently inert in the narrow branch (no `data-variant`,
62
+ // no layout change) — see #382. Re-deriving from `Sidebar` reintroduces that
63
+ // bug; add members here explicitly instead.
64
+ //
65
+ // `onSelect` (the native div text-selection event) is omitted so it can't
66
+ // collide with this component's own `onSelect`-shaped internals
67
+ // (`ContextRailBranchProps`) when `props` is spread onto
68
+ // `ContextRailWide`/`ContextRailNarrow` below.
69
+ //
70
+ // `children` is omitted for the same reason `variant` is declared out above:
71
+ // both `ContextRailWide` and `ContextRailNarrow` supply their OWN JSX
72
+ // children after spreading `props` onto their root element, so a caller's
73
+ // `children` would be accepted by the type and then silently discarded at
74
+ // render — another advertised-but-inert prop, the exact failure mode #382
75
+ // fixed for `variant`.
76
+ export interface ContextRailProps extends Omit<ComponentProps<"div">, "onSelect" | "children"> {
77
+ /** The sections the switcher can pick between. An empty array renders
78
+ * the `empty` slot instead of a switcher. */
79
+ sections: ContextRailSection[];
80
+ /** Controlled active section id. */
81
+ activeSectionId?: string;
82
+ /** Initial active section id (uncontrolled). Defaults to `sections[0]`. */
83
+ defaultActiveSectionId?: string;
84
+ /** Fires with the clicked section's id — including a re-click of the
85
+ * already-active section (open/close is a separate concern, see `open`). */
86
+ onActiveSectionChange?: (sectionId: string) => void;
87
+ /** Controlled expanded/collapsed state. Clicking the ACTIVE switcher
88
+ * entry toggles this; clicking an INACTIVE entry sets it `true`. */
89
+ open?: boolean;
90
+ /** Initial expanded/collapsed state (uncontrolled). Defaults to `true`. */
91
+ defaultOpen?: boolean;
92
+ onOpenChange?: (open: boolean) => void;
93
+ /** Expanded width, published as `--sidebar-width`. @default "20rem" */
94
+ width?: string;
95
+ /** Replaces the default localized empty state when `sections` is empty. */
96
+ empty?: ReactNode;
97
+ /**
98
+ * Below this viewport width the rail renders its own 48px strip plus a
99
+ * `Sheet` for the expanded body instead of mounting `Sidebar` — dev-only
100
+ * warns if set below 768. @default 768
101
+ */
102
+ overlayBreakpoint?: number;
103
+ }
104
+
105
+ const DEFAULT_WIDTH = "20rem";
106
+ const DEFAULT_OVERLAY_BREAKPOINT = 768;
107
+ // Mirrors sidebar.tsx's private `SIDEBAR_WIDTH_ICON` — the value the nested
108
+ // `SidebarProvider` actually publishes as `--sidebar-width-icon` (ContextRail
109
+ // never overrides that key in its own `style` prop). ADR 0035 §3: "The
110
+ // collapsed strip is 3rem (48px) … Retuning it is a fork, not a prop" — so
111
+ // this is a fixed constant, not something derived from a prop.
112
+ const NARROW_STRIP_WIDTH = "3rem";
113
+
114
+ function resolveActiveId(
115
+ sections: ContextRailSection[],
116
+ candidate: string | undefined,
117
+ ): string | undefined {
118
+ if (candidate != null && sections.some((section) => section.id === candidate)) {
119
+ return candidate;
120
+ }
121
+ return sections[0]?.id;
122
+ }
123
+
124
+ interface ContextRailSwitcherProps {
125
+ sections: ContextRailSection[];
126
+ activeId: string | undefined;
127
+ onSelect: (id: string) => void;
128
+ orientation: "row" | "column";
129
+ }
130
+
131
+ // The count badge's own positioning, kept out of the JSX because the reasoning
132
+ // below is longer than the markup it belongs to.
133
+ //
134
+ // It is rendered as a SIBLING of `SidebarMenuButton`, not a child.
135
+ // `sidebarMenuButtonVariants`' base class string carries `overflow-hidden`
136
+ // (sidebar.tsx), so a child at a negative offset (`-end-1 -top-1`) is clipped
137
+ // by its own parent — which contradicted `ContextRailSection.count`'s promise
138
+ // that the count stays visible in the collapsed 48px strip, the state the badge
139
+ // exists for. `SidebarMenuItem` is already `relative` and does not clip, so the
140
+ // same offsets resolve against the item box instead and paint in full.
141
+ const COUNT_BADGE_BASE =
142
+ "pointer-events-none absolute inline-flex items-center justify-center rounded-full bg-sidebar-accent text-meta text-sidebar-accent-foreground tabular-nums";
143
+ // ONE treatment, both orientations and both states: the icon-sized corner
144
+ // badge. The switcher entry is icon-only everywhere — expanded, it is a fixed
145
+ // cluster in the rail's header band, not a full-width labelled row — so the
146
+ // end-anchored, vertically-centred pill the row used to get landed ON TOP of
147
+ // the entry's own glyph and hid it (measured at 1440px on the dashboard shell:
148
+ // a 20px pill over a 16px icon in a 32px button).
149
+ //
150
+ // The badge hugs the item's own END EDGE (`end-0`) rather than overhanging it
151
+ // (`-end-1`), which is the ordinary corner-badge convention. The reason is this
152
+ // rail's POSITION, not its look: `ContextRail` is the outermost column of the
153
+ // shell, so its end edge can BE the viewport edge — a 4px overhang then lands
154
+ // past `window.innerWidth` and the count is clipped in half by the browser
155
+ // (measured before the strip gained its own padding). `CONTEXT_RAIL_HEADER`'s
156
+ // `px-2` now buys that margin back, so this is belt and braces rather than the
157
+ // only guard — keep it: a host restyling the rail through `className` can put
158
+ // the item back on the edge. The vertical `-top-1` overhang is kept: nothing
159
+ // clips it.
160
+ const COUNT_BADGE_POSITION = "end-0 -top-1 h-4 min-w-4 px-0.5";
161
+
162
+ // The rail's own header band. Two jobs, and both are geometry the rail cannot
163
+ // leave to its children:
164
+ //
165
+ // 1. EXPANDED it is `h-header` (56px by default) — the same height an app shell's top bar carries
166
+ // (`AppShell`, and both shipped shell blocks) — and it holds the section
167
+ // heading AND the switcher in ONE row. Before this, the switcher row (32px,
168
+ // flush at y=0) and a separately-bordered heading (53px) stacked to 85px, so
169
+ // the rail's header stood ~29px taller than the bar it sits beside and
170
+ // nothing in the two headers lined up.
171
+ // 2. COLLAPSED it is the 48px icon strip's own padding. `p-2` is what every
172
+ // other Sidebar region uses (`SidebarHeader`/`SidebarGroup`/`SidebarFooter`),
173
+ // and it is load-bearing here rather than decorative: without it a 32px
174
+ // button sits flush at the strip's start edge, 8px off the strip's centre
175
+ // line and 8px off every icon in the left nav rail, and the active entry's
176
+ // `before:` accent bar paints ON the rail's own boundary. `pt-3` (not `pt-2`)
177
+ // centres the FIRST icon in the same 56px band as the top bar's controls, so
178
+ // the two rails' first rows agree.
179
+ const CONTEXT_RAIL_HEADER =
180
+ "flex h-header shrink-0 items-center gap-2 border-b border-border-strong px-2 group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:items-stretch group-data-[collapsible=icon]:border-b-0 group-data-[collapsible=icon]:px-2 group-data-[collapsible=icon]:pt-3 group-data-[collapsible=icon]:pb-2";
181
+
182
+ function ContextRailSwitcher({
183
+ sections,
184
+ activeId,
185
+ onSelect,
186
+ orientation,
187
+ }: ContextRailSwitcherProps) {
188
+ const { open, setOpen } = useSidebar();
189
+ const { t } = useLocale();
190
+
191
+ const handleClick = (section: ContextRailSection) => {
192
+ const isActive = section.id === activeId;
193
+ if (isActive) {
194
+ setOpen(!open);
195
+ return;
196
+ }
197
+ onSelect(section.id);
198
+ if (!open) setOpen(true);
199
+ };
200
+
201
+ return (
202
+ <SidebarMenu
203
+ data-slot="context-rail-switcher"
204
+ className={
205
+ // Expanded, the switcher is a fixed-width cluster at the END of the
206
+ // header band, so it must NOT take the menu's default `w-full` — that
207
+ // would push the section heading out of the row. It is also capped at
208
+ // HALF the band: `sections` is caller-supplied and unbounded, and an
209
+ // uncapped row of 32px buttons reserves its full width before the
210
+ // heading gets any, so at the default 20rem width eight sections left
211
+ // the active section's label about 20px to render in — effectively
212
+ // gone — and more than that overflowed the rail. Capped, the overflow
213
+ // moves into the cluster itself, which scrolls (its items are the
214
+ // buttons, so the region already has the focusable descendant WCAG
215
+ // 2.1.1 wants) while the heading keeps its half. Collapsed (and in the
216
+ // narrow strip) it is the whole column again, with no cap and no
217
+ // scroller.
218
+ orientation === "row"
219
+ ? "w-auto max-w-1/2 shrink-0 flex-row overflow-x-auto [&>li]:shrink-0 group-data-[collapsible=icon]:w-full group-data-[collapsible=icon]:max-w-none group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:overflow-x-visible"
220
+ : "flex-col"
221
+ }
222
+ >
223
+ {sections.map((section) => {
224
+ const isActive = section.id === activeId;
225
+ const countPhrase =
226
+ section.count != null
227
+ ? t("ui.contextRail.sectionCount", { count: section.count })
228
+ : undefined;
229
+ // The count is deliberately part of the accessible name (one
230
+ // sr-only span authors the whole string) — a screen-reader user
231
+ // switching sections should hear "Sources 3 items", not just
232
+ // "Sources", since the count is the reason to pick that section.
233
+ const accessibleName = countPhrase ? `${section.label} ${countPhrase}` : section.label;
234
+
235
+ return (
236
+ <SidebarMenuItem key={section.id}>
237
+ <SidebarMenuButton
238
+ data-slot="context-rail-switcher-item"
239
+ tooltip={section.label}
240
+ isActive={isActive}
241
+ aria-current={isActive ? "true" : undefined}
242
+ disabled={section.disabled}
243
+ onClick={() => handleClick(section)}
244
+ >
245
+ <span
246
+ aria-hidden="true"
247
+ className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4"
248
+ >
249
+ {section.icon}
250
+ </span>
251
+ <span className="sr-only">{accessibleName}</span>
252
+ </SidebarMenuButton>
253
+ {section.count != null && (
254
+ <span
255
+ data-slot="context-rail-count"
256
+ aria-hidden="true"
257
+ className={cn(COUNT_BADGE_BASE, COUNT_BADGE_POSITION)}
258
+ >
259
+ {section.count}
260
+ </span>
261
+ )}
262
+ </SidebarMenuItem>
263
+ );
264
+ })}
265
+ </SidebarMenu>
266
+ );
267
+ }
268
+
269
+ interface ContextRailBranchProps extends Omit<ComponentProps<"div">, "onSelect"> {
270
+ sections: ContextRailSection[];
271
+ activeId: string | undefined;
272
+ activeSection: ContextRailSection | undefined;
273
+ onSelect: (id: string) => void;
274
+ empty: ReactNode;
275
+ hasSections: boolean;
276
+ }
277
+
278
+ interface ContextRailNarrowProps extends ContextRailBranchProps {
279
+ /**
280
+ * The rail's expanded width (`ContextRailProps.width`), forwarded here
281
+ * because the Sheet's content is portalled to `document.body` and is
282
+ * therefore NOT a DOM descendant of the nested `SidebarProvider` that
283
+ * declares `--sidebar-width` on its own element — custom properties
284
+ * inherit down the real DOM tree, not the React tree, so a portal never
285
+ * sees an ancestor's declaration. Redeclared inline on `SheetContent`
286
+ * itself, mirroring the library's own precedent for the same problem
287
+ * (`sidebar.tsx`'s mobile `Sheet`, `style={{ "--sidebar-width":
288
+ * SIDEBAR_WIDTH_MOBILE }}`).
289
+ */
290
+ width: string;
291
+ }
292
+
293
+ const ContextRailWide = forwardRef<HTMLDivElement, ContextRailBranchProps>(function ContextRailWide(
294
+ { sections, activeId, activeSection, onSelect, empty, hasSections, className, ...props },
295
+ ref,
296
+ ) {
297
+ const { t } = useLocale();
298
+ const headingId = useId();
299
+ const hasHeading = hasSections && activeSection != null;
300
+
301
+ return (
302
+ // A landmark, not a bare `<div>`: this is the THIRD region of a three-region
303
+ // shell and it sits OUTSIDE `<main>`, so without one its content belongs to
304
+ // no region a screen-reader user can navigate to. Same shape and same
305
+ // reasoning as `SideDock`'s own root (`side-dock.tsx`, `<aside
306
+ // aria-labelledby={titleId}>`) and as the blocks' list columns
307
+ // (`app-list-column.tsx`, `mail-list-column.tsx`). Named by the section
308
+ // heading below, so the name tracks the section actually on screen; with no
309
+ // sections there is no heading to point at, so the localized empty-state
310
+ // string names it instead — a landmark with no name is barely better than
311
+ // no landmark.
312
+ <aside
313
+ // Runtime-safe: the ref target is a real element either way; only the TS
314
+ // element type differs, and `ContextRailProps` keeps the `HTMLDivElement`
315
+ // ref it has always published rather than making this a breaking change.
316
+ ref={ref as Ref<HTMLElement>}
317
+ data-slot="context-rail"
318
+ // Which of the two presentations is on screen, as a styling hook. A host
319
+ // that has to compensate for its OWN chrome (the dashboard block pays
320
+ // back an inset card's top gutter so the two headers share a line) needs
321
+ // a different offset per branch, and `className` lands on whichever one
322
+ // is mounted — so without this marker one branch always gets the other's
323
+ // number.
324
+ data-presentation="wide"
325
+ aria-labelledby={hasHeading ? headingId : undefined}
326
+ aria-label={hasHeading ? undefined : t("ui.contextRail.empty")}
327
+ className={cn("flex h-full min-h-0 w-full flex-col", className)}
328
+ {...props}
329
+ >
330
+ {hasHeading ? (
331
+ <>
332
+ {/* Heading and switcher share ONE band, sized to the app shell's own
333
+ top bar — see `CONTEXT_RAIL_HEADER`. With no sections there is no
334
+ switcher and no heading, so the band would be an empty ruled bar:
335
+ it goes with them. */}
336
+ <div data-slot="context-rail-header" className={CONTEXT_RAIL_HEADER}>
337
+ {/* A real heading, not a `text-title` div: the body content sits
338
+ under it, so heading navigation has to be able to reach it (WCAG
339
+ 1.3.1). `h2` is the rung under the host page's own `h1`. */}
340
+ <h2
341
+ id={headingId}
342
+ data-slot="context-rail-heading"
343
+ className="min-w-0 flex-1 truncate px-2 text-title group-data-[collapsible=icon]:hidden"
344
+ >
345
+ {activeSection.label}
346
+ </h2>
347
+ <ContextRailSwitcher
348
+ sections={sections}
349
+ activeId={activeId}
350
+ onSelect={onSelect}
351
+ orientation="row"
352
+ />
353
+ </div>
354
+ <div
355
+ data-slot="context-rail-body"
356
+ // Focusable because it scrolls; `focus-ring-inset` because
357
+ // `Sidebar`'s own frame clips anything drawn outside this box, and
358
+ // both layers of the plain rung are drawn outside it. The content
359
+ // this rail mounts is caller-supplied and routinely has no
360
+ // focusable descendant at all, so without a tab stop there is no
361
+ // keyboard route into the region once it overflows (WCAG 2.1.1,
362
+ // axe `scrollable-region-focusable`).
363
+ tabIndex={0}
364
+ className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset group-data-[collapsible=icon]:hidden"
365
+ >
366
+ {activeSection.content}
367
+ </div>
368
+ </>
369
+ ) : (
370
+ <div className="min-h-0 flex-1 group-data-[collapsible=icon]:hidden">{empty}</div>
371
+ )}
372
+ </aside>
373
+ );
374
+ });
375
+
376
+ const ContextRailNarrow = forwardRef<HTMLDivElement, ContextRailNarrowProps>(
377
+ function ContextRailNarrow(
378
+ { sections, activeId, activeSection, onSelect, empty, hasSections, width, className, ...props },
379
+ ref,
380
+ ) {
381
+ const { open, setOpen } = useSidebar();
382
+ // The strip is the sheet's own persistent companion control (ADR 0035
383
+ // §3), not something genuinely "outside" it — but it IS a DOM sibling
384
+ // outside the Sheet's portalled content, so Radix's DismissableLayer
385
+ // (active even with `modal={false}`; see the comment on `<Sheet>` below)
386
+ // treats a pointerdown on the strip as an outside interaction and calls
387
+ // `onOpenChange(false)` on POINTERDOWN, before this component's own
388
+ // `onClick` runs. MEASURED (fix round 1, task-9b-fix-1.md): that race
389
+ // flips `open` to `false` a tick before `ContextRailSwitcher`'s
390
+ // `handleClick` reads it, so clicking the ACTIVE entry (meant to CLOSE
391
+ // the sheet via `setOpen(!open)`) instead read `open` as already-false
392
+ // and REOPENED it — the sheet never actually closed. `onPointerDownOutside`
393
+ // is the sanctioned Radix escape hatch for exactly this: suppress the
394
+ // auto-dismiss for pointerdowns that land on the strip, and let this
395
+ // component's own click handler keep owning open/close for it — a
396
+ // genuine outside click (the canvas, anywhere else) still dismisses.
397
+ const stripRef = useRef<HTMLDivElement>(null);
398
+ const mergedStripRef = useMemo(() => mergeRefs<HTMLDivElement>(ref, stripRef), [ref]);
399
+ const { t } = useLocale();
400
+
401
+ return (
402
+ <>
403
+ {/* The same landmark the wide branch gets, for the same reason — but
404
+ named with `aria-label`, not `aria-labelledby`. The heading here
405
+ lives inside the Sheet, which is PORTALLED to `document.body` and
406
+ unmounted while the sheet is closed, so an id reference would point
407
+ at nothing in the rail's resting state. The computed name is
408
+ identical either way. */}
409
+ <aside
410
+ // Runtime-safe cast; see the wide branch for why the published ref
411
+ // type stays `HTMLDivElement`.
412
+ ref={mergedStripRef as Ref<HTMLElement>}
413
+ data-slot="context-rail"
414
+ // See the wide branch: the marker is what lets a host target one
415
+ // presentation's padding without disturbing the other's.
416
+ data-presentation="narrow"
417
+ aria-label={activeSection?.label ?? t("ui.contextRail.empty")}
418
+ className={cn(
419
+ // `px-2 pt-3 pb-2`: the same strip padding the wide branch's
420
+ // collapsed header band carries, for the same reason — see
421
+ // `CONTEXT_RAIL_HEADER`. The two branches are one control at two
422
+ // widths, so their resting strip must not shift under the reader.
423
+ "flex h-full w-(--sidebar-width-icon) flex-col bg-sidebar px-2 pt-3 pb-2 text-sidebar-foreground",
424
+ className,
425
+ )}
426
+ {...props}
427
+ >
428
+ <ContextRailSwitcher
429
+ sections={sections}
430
+ activeId={activeId}
431
+ onSelect={onSelect}
432
+ orientation="column"
433
+ />
434
+ </aside>
435
+ {/* `modal={false}`: the 48px strip is a PERSISTENT sibling of this
436
+ sheet, not part of it — Radix's default modal behaviour hides
437
+ every body sibling from assistive tech and traps focus inside
438
+ the dialog while open, which would make the always-visible
439
+ switcher strip unreachable to a screen reader the moment the
440
+ sheet opens. Non-modal keeps the strip operable throughout (and,
441
+ per Radix, means DialogOverlay renders nothing — the panel itself
442
+ is the only thing that can be occluding the strip). */}
443
+ <Sheet open={open} onOpenChange={setOpen} modal={false}>
444
+ <SheetContent
445
+ side="right"
446
+ onPointerDownOutside={(event) => {
447
+ if (event.target instanceof Node && stripRef.current?.contains(event.target)) {
448
+ event.preventDefault();
449
+ }
450
+ }}
451
+ // The strip is a REAL, non-portalled DOM descendant of the nested
452
+ // provider, so `right-0`/`w-3/4`/`max-w-sm` from `sheetVariants`
453
+ // would seat this portalled panel directly on top of it (both
454
+ // anchored to the same right edge, panel wider than the strip).
455
+ // Inset by exactly the strip width so both stay hittable — never
456
+ // hide the strip, never move the switcher into the sheet (ADR
457
+ // 0035 §3: "The collapsed icon strip survives at every viewport
458
+ // width"). MEASURED (fix round 1, task-9b-fix-1.md): this repo's
459
+ // pinned `tailwind-merge@^2.6.0` predates Tailwind v4's `(--var)`
460
+ // parens shorthand and does not recognize it as an arbitrary
461
+ // value for the `right`/`w` groups — `right-(--sidebar-width-icon)`
462
+ // and `w-(--sidebar-width)` were silently left UN-deduped against
463
+ // `sheetVariants`' own `right-0`/`w-3/4` (both ended up in the
464
+ // merged class list, letting the ORIGINAL literal win the
465
+ // cascade — the exact bug this fix exists to close). The bracket
466
+ // form with an explicit `var()` call — already proven to merge
467
+ // correctly two tokens later in this same string, where
468
+ // `max-w-[calc(100vw-var(--sidebar-width-icon))]` DOES dedupe
469
+ // against `max-w-sm` — is what tailwind-merge v2 actually
470
+ // recognizes, so `right`/`w` use that form too.
471
+ className="right-[var(--sidebar-width-icon)] flex w-[var(--sidebar-width)] max-w-[calc(100vw-var(--sidebar-width-icon))] flex-col bg-sidebar p-0 text-sidebar-foreground"
472
+ style={
473
+ {
474
+ // Redeclared here, not inherited — see `ContextRailNarrowProps.width`'s
475
+ // doc comment above for why a portal needs its own copy.
476
+ "--sidebar-width": width,
477
+ "--sidebar-width-icon": NARROW_STRIP_WIDTH,
478
+ } as CSSProperties
479
+ }
480
+ >
481
+ <SheetHeader className="sr-only">
482
+ <SheetTitle>{activeSection?.label ?? ""}</SheetTitle>
483
+ <SheetDescription>
484
+ Displays the context rail&rsquo;s expanded content.
485
+ </SheetDescription>
486
+ </SheetHeader>
487
+ {hasSections && activeSection ? (
488
+ <>
489
+ {/* A real heading for the same reason as the wide branch. The
490
+ Sheet's own name comes from the `sr-only` `SheetTitle`
491
+ above; this is the visible heading the body sits under. */}
492
+ <h2
493
+ data-slot="context-rail-heading"
494
+ // `h-header`, matching the wide branch's header band and the app
495
+ // shell's top bar — the sheet is the same rail at a narrower
496
+ // width, so its header keeps the same height.
497
+ className="flex h-header shrink-0 items-center truncate border-b border-border-strong px-4 text-title"
498
+ >
499
+ {activeSection.label}
500
+ </h2>
501
+ <div
502
+ data-slot="context-rail-body"
503
+ // Focusable because it scrolls; `focus-ring-inset` because the
504
+ // Sheet panel clips anything drawn outside this box. See the
505
+ // wide branch for the full rationale.
506
+ tabIndex={0}
507
+ className="min-h-0 flex-1 overflow-y-auto p-4 focus-ring-inset"
508
+ >
509
+ {activeSection.content}
510
+ </div>
511
+ </>
512
+ ) : (
513
+ <div className="min-h-0 flex-1">{empty}</div>
514
+ )}
515
+ </SheetContent>
516
+ </Sheet>
517
+ </>
518
+ );
519
+ },
520
+ );
521
+
522
+ /**
523
+ * A right-hand rail whose collapsed state is a 48px icon strip that doubles
524
+ * as its own section switcher. Built on `Sidebar`/`SidebarProvider`
525
+ * (`frame="nested"`, ADR 0035 §4) as an implementation detail — the public
526
+ * seam is `sections` + `activeSectionId`/`onActiveSectionChange` (which
527
+ * section) and `open`/`onOpenChange` (expanded or collapsed), both
528
+ * controlled/uncontrolled. Below `overlayBreakpoint` it renders its own
529
+ * strip plus a `Sheet` instead of mounting `Sidebar`. See
530
+ * `docs/ADR/0035-context-rail-and-side-dock.md` §3.
531
+ *
532
+ * Known constraint (task-11f-brief.md Finding 5): below `overlayBreakpoint`
533
+ * the expanded body is a `Sheet`, which always spans the full browser
534
+ * viewport, while the persistent 48px strip is bounded by whatever container
535
+ * the host gives the rail. In a host container shorter than the viewport
536
+ * (any app shell with a toolbar above the rail), the strip stops mid-screen
537
+ * while the sheet keeps going — the two read as unrelated surfaces. Give
538
+ * `ContextRail` a full-viewport-height host container below the breakpoint
539
+ * to avoid this seam.
540
+ */
541
+ export const ContextRail = forwardRef<HTMLDivElement, ContextRailProps>(function ContextRail(
542
+ {
543
+ sections,
544
+ activeSectionId,
545
+ defaultActiveSectionId,
546
+ onActiveSectionChange,
547
+ open,
548
+ defaultOpen,
549
+ onOpenChange,
550
+ width = DEFAULT_WIDTH,
551
+ empty,
552
+ overlayBreakpoint = DEFAULT_OVERLAY_BREAKPOINT,
553
+ className,
554
+ style,
555
+ ...props
556
+ },
557
+ ref,
558
+ ) {
559
+ const { t } = useLocale();
560
+ const isControlled = activeSectionId !== undefined;
561
+ const [internalActiveId, setInternalActiveId] = useState<string | undefined>(
562
+ () => defaultActiveSectionId ?? sections[0]?.id,
563
+ );
564
+ const activeId = resolveActiveId(sections, isControlled ? activeSectionId : internalActiveId);
565
+ const activeSection = sections.find((section) => section.id === activeId);
566
+ const hasSections = sections.length > 0;
567
+
568
+ const handleSelect = useCallback(
569
+ (id: string) => {
570
+ if (!isControlled) setInternalActiveId(id);
571
+ onActiveSectionChange?.(id);
572
+ },
573
+ [isControlled, onActiveSectionChange],
574
+ );
575
+
576
+ useEffect(() => {
577
+ if (process.env.NODE_ENV !== "production" && overlayBreakpoint < 768) {
578
+ // oxlint-disable-next-line no-console
579
+ console.warn(
580
+ `ContextRail: "overlayBreakpoint" (${overlayBreakpoint}) is below 768px. The rail's ` +
581
+ "overlay/Sheet presentation is meant for viewports narrower than the app shell's own " +
582
+ "mobile breakpoint — a lower value can flip the rail to its overlay presentation " +
583
+ "before the rest of the app shell does.",
584
+ );
585
+ }
586
+ }, [overlayBreakpoint]);
587
+
588
+ const isNarrow = useIsMobile(overlayBreakpoint);
589
+
590
+ // Finding 4 (task-11f-brief.md): `StatePanel`'s `empty` variant paints
591
+ // `bg-surface` — a CONTENT-canvas token — which is correct for every other
592
+ // consumer but wrong here: this slot sits inside `--sidebar` CHROME, and in
593
+ // the `light` reference theme `--surface` reads as a near-white rectangle
594
+ // against the rail's own dark ground. Fixed at this call site only (never
595
+ // in `state-panel.tsx`, which stays canvas-tuned for its other consumers):
596
+ // `bg-sidebar-accent` moves the panel onto a sidebar-family surface (no new
597
+ // token minted), and the `--foreground` / `--muted-foreground` custom-
598
+ // property retarget makes `StatePanel`'s own (unexported)
599
+ // `text-foreground`/`text-muted-foreground` title and description ink
600
+ // resolve against the RAIL's ink instead of the page's — in `light`,
601
+ // `--foreground` and `--sidebar` are the same colour, so without this the
602
+ // title would render at ~1.3:1 against its own background. Same idiom as
603
+ // `focus-ring [--focus-ring-color:…]` elsewhere in this package: retarget
604
+ // the variable the shared component already reads, don't fork the
605
+ // component.
606
+ //
607
+ // Fix round 1 (task-11f-fix-1.md, Moderate M1): this block used to also add
608
+ // `border-sidebar-border`. Measured against the `bg-sidebar-accent` fill
609
+ // above, that border read at 1.09:1 (light) / 1.07:1 (dark) — effectively
610
+ // invisible, and a pairing no other Sidebar-family surface uses (every
611
+ // other use of `--sidebar-border` sits on the ambient `--sidebar` chrome,
612
+ // not on this fill).
613
+ //
614
+ // Fix round 2 (task-11f-fix-2.md, Finding B): dropping the colour class
615
+ // wasn't enough — `StatePanel`'s OWN `border border-dashed` still rendered,
616
+ // falling through to the base `--border` token instead of a sidebar one.
617
+ // Measured: 7.56:1 (light) — a canvas-tuned border drawing inside sidebar
618
+ // chrome, a semantic mismatch — and 1.15:1 (dark) — still effectively
619
+ // invisible, M1's original problem reached through a different token. Fixed
620
+ // by removing the border ELEMENT itself, not just its colour: `border-0`
621
+ // here (call-site `className`, merged last by `cn()`) beats `StatePanel`'s
622
+ // own `border border-dashed` with no edit to the shared component and no
623
+ // effect on any other consumer. The fill (`bg-sidebar-accent`) plus the
624
+ // title/description ink retargets are left exactly as they were — verified
625
+ // by eye in both `layout-context-rail--empty` theme slugs that the panel
626
+ // still reads as a bounded region on fill and title alone.
627
+ const emptyContent = empty ?? (
628
+ <StatePanel
629
+ kind="empty"
630
+ title={t("ui.contextRail.empty")}
631
+ className="border-0 bg-sidebar-accent [--foreground:var(--sidebar-foreground)] [--muted-foreground:var(--sidebar-muted-foreground)]"
632
+ />
633
+ );
634
+ const emptySlot = <div data-slot="context-rail-empty">{emptyContent}</div>;
635
+
636
+ return (
637
+ <SidebarProvider
638
+ frame="nested"
639
+ open={open}
640
+ defaultOpen={defaultOpen}
641
+ onOpenChange={onOpenChange}
642
+ style={{ "--sidebar-width": width } as CSSProperties}
643
+ >
644
+ {isNarrow ? (
645
+ <ContextRailNarrow
646
+ ref={ref}
647
+ sections={sections}
648
+ activeId={activeId}
649
+ activeSection={activeSection}
650
+ onSelect={handleSelect}
651
+ empty={emptySlot}
652
+ hasSections={hasSections}
653
+ width={width}
654
+ className={className}
655
+ style={style}
656
+ {...props}
657
+ />
658
+ ) : (
659
+ // `variant` is hardcoded, not a public prop (#382) — see the doc
660
+ // comment on `ContextRailProps` above.
661
+ <Sidebar side="right" collapsible="icon" variant="sidebar">
662
+ <ContextRailWide
663
+ ref={ref}
664
+ sections={sections}
665
+ activeId={activeId}
666
+ activeSection={activeSection}
667
+ onSelect={handleSelect}
668
+ empty={emptySlot}
669
+ hasSections={hasSections}
670
+ className={className}
671
+ style={style}
672
+ {...props}
673
+ />
674
+ </Sidebar>
675
+ )}
676
+ </SidebarProvider>
677
+ );
678
+ });
@@ -0,0 +1 @@
1
+ export { ContextRail, type ContextRailProps, type ContextRailSection } from "./context-rail";