@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.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 +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -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
+ };
@@ -0,0 +1,263 @@
1
+ import type { ReactNode } from "react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen, within } from "@testing-library/react";
4
+ import { FileText, Quote } from "lucide-react";
5
+
6
+ import { ContextRail, type ContextRailSection } from "./context-rail";
7
+
8
+ const sections: ContextRailSection[] = [
9
+ {
10
+ id: "sources",
11
+ label: "Sources",
12
+ icon: <FileText />,
13
+ count: 3,
14
+ content: <p>Sources body</p>,
15
+ },
16
+ {
17
+ id: "quotes",
18
+ label: "Quotes",
19
+ icon: <Quote />,
20
+ content: <p>Quotes body</p>,
21
+ },
22
+ ];
23
+
24
+ const ORIGINAL_INNER_WIDTH = window.innerWidth;
25
+
26
+ function setViewportWidth(width: number) {
27
+ Object.defineProperty(window, "innerWidth", {
28
+ writable: true,
29
+ configurable: true,
30
+ value: width,
31
+ });
32
+ }
33
+
34
+ describe("ContextRail", () => {
35
+ afterEach(() => {
36
+ setViewportWidth(ORIGINAL_INNER_WIDTH);
37
+ });
38
+
39
+ it("1. is reachable by exact accessible name when collapsed", () => {
40
+ render(<ContextRail sections={sections} open={false} />);
41
+ expect(screen.getByRole("button", { name: "Sources 3 items" })).toBeInTheDocument();
42
+ expect(screen.getByRole("button", { name: "Quotes" })).toBeInTheDocument();
43
+ });
44
+
45
+ it("2. mounts only the active section's content", () => {
46
+ render(<ContextRail sections={sections} activeSectionId="sources" />);
47
+ expect(screen.getByText("Sources body")).toBeInTheDocument();
48
+ expect(screen.queryByText("Quotes body")).not.toBeInTheDocument();
49
+ });
50
+
51
+ it("3. calls onActiveSectionChange with the clicked section's id", () => {
52
+ const onActiveSectionChange = vi.fn();
53
+ render(
54
+ <ContextRail
55
+ sections={sections}
56
+ activeSectionId="sources"
57
+ onActiveSectionChange={onActiveSectionChange}
58
+ />,
59
+ );
60
+ fireEvent.click(screen.getByRole("button", { name: "Quotes" }));
61
+ expect(onActiveSectionChange).toHaveBeenCalledWith("quotes");
62
+ });
63
+
64
+ it("4. defaults to sections[0] uncontrolled, honours defaultActiveSectionId, and updates on click with no controlled prop", () => {
65
+ const { unmount } = render(<ContextRail sections={sections} />);
66
+ expect(screen.getByText("Sources body")).toBeInTheDocument();
67
+ unmount();
68
+
69
+ render(<ContextRail sections={sections} defaultActiveSectionId="quotes" />);
70
+ expect(screen.getByText("Quotes body")).toBeInTheDocument();
71
+ fireEvent.click(screen.getByRole("button", { name: "Sources 3 items" }));
72
+ expect(screen.getByText("Sources body")).toBeInTheDocument();
73
+ expect(screen.queryByText("Quotes body")).not.toBeInTheDocument();
74
+ });
75
+
76
+ it("5. gives the active entry aria-current=true", () => {
77
+ render(<ContextRail sections={sections} activeSectionId="quotes" />);
78
+ expect(screen.getByRole("button", { name: "Quotes" })).toHaveAttribute("aria-current", "true");
79
+ expect(screen.getByRole("button", { name: "Sources 3 items" })).not.toHaveAttribute(
80
+ "aria-current",
81
+ );
82
+ });
83
+
84
+ // Finding 1 (task-11f-brief.md, BLOCKER): `isActive` was computed but never
85
+ // passed to `SidebarMenuButton`, so `data-active` defaulted to `false` on
86
+ // every entry and the ENTIRE active-state stylesheet (fill, label weight,
87
+ // icon colour, accent bar — all gated on `data-[active=true]:…` in
88
+ // `sidebarMenuButtonVariants`) never applied. Assert BOTH halves: the
89
+ // active entry carries `data-active="true"` AND an inactive one does not —
90
+ // asserting only the first half would pass against code that marks every
91
+ // entry active.
92
+ it("5b. gives the active entry data-active=true and an inactive one data-active=false", () => {
93
+ render(<ContextRail sections={sections} activeSectionId="quotes" />);
94
+ expect(screen.getByRole("button", { name: "Quotes" })).toHaveAttribute("data-active", "true");
95
+ expect(screen.getByRole("button", { name: "Sources 3 items" })).toHaveAttribute(
96
+ "data-active",
97
+ "false",
98
+ );
99
+ });
100
+
101
+ it("6. clicking the active entry toggles open; clicking an inactive entry opens and reports the new section", () => {
102
+ const onOpenChange = vi.fn();
103
+ const { rerender } = render(
104
+ <ContextRail
105
+ sections={sections}
106
+ activeSectionId="sources"
107
+ open={true}
108
+ onOpenChange={onOpenChange}
109
+ />,
110
+ );
111
+ fireEvent.click(screen.getByRole("button", { name: "Sources 3 items" }));
112
+ expect(onOpenChange).toHaveBeenCalledWith(false);
113
+
114
+ onOpenChange.mockClear();
115
+ const onActiveSectionChange = vi.fn();
116
+ rerender(
117
+ <ContextRail
118
+ sections={sections}
119
+ activeSectionId="sources"
120
+ open={false}
121
+ onOpenChange={onOpenChange}
122
+ onActiveSectionChange={onActiveSectionChange}
123
+ />,
124
+ );
125
+ fireEvent.click(screen.getByRole("button", { name: "Quotes" }));
126
+ expect(onOpenChange).toHaveBeenCalledWith(true);
127
+ expect(onActiveSectionChange).toHaveBeenCalledWith("quotes");
128
+ });
129
+
130
+ it("7. disabled sections render a disabled button and do not fire onActiveSectionChange", () => {
131
+ const onActiveSectionChange = vi.fn();
132
+ const disabledSections: ContextRailSection[] = [
133
+ sections[0]!,
134
+ { ...sections[1]!, disabled: true },
135
+ ];
136
+ render(
137
+ <ContextRail
138
+ sections={disabledSections}
139
+ activeSectionId="sources"
140
+ onActiveSectionChange={onActiveSectionChange}
141
+ />,
142
+ );
143
+ const quotesButton = screen.getByRole("button", { name: "Quotes" });
144
+ expect(quotesButton).toBeDisabled();
145
+ fireEvent.click(quotesButton);
146
+ expect(onActiveSectionChange).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("8. renders the empty slot for an empty sections array, localized by default, overridable via `empty`", () => {
150
+ const { container, rerender } = render(<ContextRail sections={[]} />);
151
+ const emptySlot = container.querySelector('[data-slot="context-rail-empty"]');
152
+ expect(emptySlot).not.toBeNull();
153
+ expect(within(emptySlot as HTMLElement).getByText("No sections")).toBeInTheDocument();
154
+
155
+ const custom: ReactNode = <p>Nothing to show here</p>;
156
+ rerender(<ContextRail sections={[]} empty={custom} />);
157
+ const customEmptySlot = container.querySelector('[data-slot="context-rail-empty"]');
158
+ expect(customEmptySlot).not.toBeNull();
159
+ expect(screen.getByText("Nothing to show here")).toBeInTheDocument();
160
+ expect(screen.queryByText("No sections")).not.toBeInTheDocument();
161
+ });
162
+
163
+ it("9. publishes `width` as --sidebar-width on the provider element, defaulting to 20rem", () => {
164
+ const { container, rerender } = render(<ContextRail sections={sections} />);
165
+ const provider = container.firstElementChild as HTMLElement;
166
+ expect(provider.getAttribute("style") ?? "").toContain("--sidebar-width: 20rem");
167
+
168
+ rerender(<ContextRail sections={sections} width="24rem" />);
169
+ const providerAfter = container.firstElementChild as HTMLElement;
170
+ expect(providerAfter.getAttribute("style") ?? "").toContain("--sidebar-width: 24rem");
171
+ });
172
+
173
+ it("10. warns exactly once when overlayBreakpoint < 768, and not when >= 768", () => {
174
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
175
+ const { unmount } = render(<ContextRail sections={sections} overlayBreakpoint={500} />);
176
+ expect(warnSpy).toHaveBeenCalledTimes(1);
177
+ unmount();
178
+ warnSpy.mockClear();
179
+
180
+ render(<ContextRail sections={sections} overlayBreakpoint={768} />);
181
+ expect(warnSpy).not.toHaveBeenCalled();
182
+ warnSpy.mockRestore();
183
+ });
184
+
185
+ it("11. below overlayBreakpoint renders the narrow strip (no [data-slot=sidebar]) with the same switcher names", () => {
186
+ setViewportWidth(500);
187
+ const { container } = render(<ContextRail sections={sections} />);
188
+ expect(container.querySelector('[data-slot="sidebar"]')).toBeNull();
189
+ expect(container.querySelector('[data-slot="context-rail"]')).not.toBeNull();
190
+ expect(screen.getByRole("button", { name: "Sources 3 items" })).toBeInTheDocument();
191
+ expect(screen.getByRole("button", { name: "Quotes" })).toBeInTheDocument();
192
+ });
193
+
194
+ it("12. renders all seven data-slot values on an open, non-empty rail", () => {
195
+ const { container } = render(
196
+ <ContextRail sections={sections} open={true} activeSectionId="sources" />,
197
+ );
198
+ for (const slot of [
199
+ "context-rail",
200
+ "context-rail-switcher",
201
+ "context-rail-switcher-item",
202
+ "context-rail-count",
203
+ "context-rail-heading",
204
+ "context-rail-body",
205
+ ]) {
206
+ expect(
207
+ container.querySelector(`[data-slot="${slot}"]`),
208
+ `expected [data-slot="${slot}"] to be present`,
209
+ ).not.toBeNull();
210
+ }
211
+ });
212
+
213
+ // #382 — `variant` used to be a derived (`Omit<ComponentProps<typeof
214
+ // Sidebar>, …>`) member of `ContextRailProps`, honoured only in the wide
215
+ // (`Sidebar`) branch and silently inert below `overlayBreakpoint` (the
216
+ // narrow branch has no `Sidebar` to forward it to). Fixed by declaring the
217
+ // props surface instead of deriving it — `variant` is no longer a public
218
+ // prop at all. This is a compile-time lock: it fails the moment someone
219
+ // re-derives `ContextRailProps` from `Sidebar`, which is the only way the
220
+ // prop returns.
221
+ it("13. (type-level) does not accept a `variant` prop", () => {
222
+ function typeOnly() {
223
+ // @ts-expect-error — `variant` was removed from `ContextRailProps`; a
224
+ // consumer can no longer pass it, and it can no longer be silently
225
+ // inert in the narrow branch.
226
+ return <ContextRail sections={[]} variant="floating" />;
227
+ }
228
+ expect(typeof typeOnly).toBe("function");
229
+ });
230
+
231
+ // Companion to #13: the type was re-derived from `ComponentProps<"div">`
232
+ // and stopped omitting `children` alongside `onSelect`, unintentionally
233
+ // making `children` a public prop again even though both `ContextRailWide`
234
+ // and `ContextRailNarrow` supply their own JSX children after spreading
235
+ // `props` — a caller's `children` type-checks and is then silently
236
+ // discarded at render, the same advertised-but-inert failure #382 fixed
237
+ // for `variant`. Compile-time lock: fails the moment `children` is
238
+ // dropped from the `Omit` again.
239
+ it("15. (type-level) does not accept a `children` prop", () => {
240
+ function typeOnly() {
241
+ // @ts-expect-error — `children` is omitted from `ContextRailProps`;
242
+ // both render branches discard it after spreading `props`, so it
243
+ // must not be publicly accepted.
244
+ return <ContextRail sections={[]}>discarded</ContextRail>;
245
+ }
246
+ expect(typeof typeOnly).toBe("function");
247
+ });
248
+
249
+ it("14. the wide branch renders data-variant=sidebar on the sidebar root; the narrow branch renders no data-variant at all", () => {
250
+ const wide = render(<ContextRail sections={sections} open={true} activeSectionId="sources" />);
251
+ const sidebarRoot = wide.container.querySelector('[data-slot="sidebar"]');
252
+ expect(sidebarRoot).not.toBeNull();
253
+ expect(sidebarRoot).toHaveAttribute("data-variant", "sidebar");
254
+ wide.unmount();
255
+
256
+ setViewportWidth(500);
257
+ const narrow = render(
258
+ <ContextRail sections={sections} open={true} activeSectionId="sources" />,
259
+ );
260
+ expect(narrow.container.querySelector("[data-variant]")).toBeNull();
261
+ narrow.unmount();
262
+ });
263
+ });