@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
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { Home, Inbox, Search, Settings } from "lucide-react";
3
+ import { expect, userEvent, waitFor } from "storybook/test";
3
4
  import { Sidebar } from "./sidebar";
4
5
  import {
5
6
  SidebarContent,
@@ -11,6 +12,9 @@ import {
11
12
  SidebarMenu,
12
13
  SidebarMenuButton,
13
14
  SidebarMenuItem,
15
+ SidebarMenuSub,
16
+ SidebarMenuSubButton,
17
+ SidebarMenuSubItem,
14
18
  SidebarProvider,
15
19
  SidebarTrigger,
16
20
  } from "./sidebar";
@@ -18,7 +22,15 @@ import {
18
22
  const meta = {
19
23
  title: "Layout/Sidebar",
20
24
  component: Sidebar,
21
- parameters: { layout: "fullscreen" },
25
+ parameters: {
26
+ layout: "fullscreen",
27
+ docs: {
28
+ description: {
29
+ component:
30
+ "The sidebar PRIMITIVE set you assemble yourself — an application sidebar with typed `header` / `footer` / nav slots is `Layout/AppSidebar`, which composes exactly these parts; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `SidebarProvider` owns the open state (⌘B / Ctrl+B, persisted in a `sidebar_state` cookie); `Sidebar` takes `side`, `variant` and a `collapsible` mode of `offcanvas` / `icon` / `none`; `SidebarHeader` / `SidebarContent` / `SidebarFooter` / `SidebarMenu*` are the parts. Reach for these only when the shell is bespoke.",
31
+ },
32
+ },
33
+ },
22
34
  } satisfies Meta<typeof Sidebar>;
23
35
  export default meta;
24
36
  type Story = StoryObj<typeof meta>;
@@ -30,6 +42,7 @@ const items = [
30
42
  { title: "Settings", icon: Settings },
31
43
  ];
32
44
 
45
+ /** A collapsible icon-rail sidebar assembled from the primitive parts, with one active nav item. */
33
46
  export const Default: Story = {
34
47
  render: () => (
35
48
  <div className="h-[480px]">
@@ -67,3 +80,410 @@ export const Default: Story = {
67
80
  </div>
68
81
  ),
69
82
  };
83
+
84
+ /** The active item is distinguishable without colour: an accent bar plus a heavier label. */
85
+ export const ActiveIndicator: Story = {
86
+ render: () => (
87
+ <SidebarProvider>
88
+ <Sidebar>
89
+ <SidebarContent>
90
+ <SidebarMenu>
91
+ <SidebarMenuItem>
92
+ <SidebarMenuButton isActive data-testid="active">
93
+ Overview
94
+ </SidebarMenuButton>
95
+ </SidebarMenuItem>
96
+ <SidebarMenuItem>
97
+ <SidebarMenuButton data-testid="resting">Reports</SidebarMenuButton>
98
+ </SidebarMenuItem>
99
+ </SidebarMenu>
100
+ </SidebarContent>
101
+ </Sidebar>
102
+ </SidebarProvider>
103
+ ),
104
+ play: async ({ canvasElement }) => {
105
+ const active = canvasElement.querySelector('[data-testid="active"]') as HTMLElement;
106
+ const resting = canvasElement.querySelector('[data-testid="resting"]') as HTMLElement;
107
+ const bar = getComputedStyle(active, "::before");
108
+ const none = getComputedStyle(resting, "::before");
109
+ // Geometry, not hue: the bar has real width on the active item and none on the resting one.
110
+ await expect(parseFloat(bar.width)).toBeGreaterThan(0);
111
+ await expect(parseFloat(none.width) || 0).toBe(0);
112
+ await expect(getComputedStyle(active).fontWeight).not.toBe(
113
+ getComputedStyle(resting).fontWeight,
114
+ );
115
+ },
116
+ };
117
+
118
+ /**
119
+ * The same non-colour cue one level down, at the sub-item: `SidebarMenuSubButton`
120
+ * renders an anchor rather than a button, so the `::before` bar's containing block
121
+ * is the anchor's own (relatively positioned) box, not `SidebarMenuSubItem`'s `<li>`.
122
+ */
123
+ export const ActiveSubItemIndicator: Story = {
124
+ render: () => (
125
+ <SidebarProvider>
126
+ <Sidebar>
127
+ <SidebarContent>
128
+ <SidebarMenu>
129
+ <SidebarMenuItem>
130
+ <SidebarMenuButton>Platform</SidebarMenuButton>
131
+ <SidebarMenuSub>
132
+ <SidebarMenuSubItem>
133
+ <SidebarMenuSubButton href="#overview" isActive data-testid="sub-active">
134
+ Overview
135
+ </SidebarMenuSubButton>
136
+ </SidebarMenuSubItem>
137
+ <SidebarMenuSubItem>
138
+ <SidebarMenuSubButton href="#reports" data-testid="sub-resting">
139
+ Reports
140
+ </SidebarMenuSubButton>
141
+ </SidebarMenuSubItem>
142
+ </SidebarMenuSub>
143
+ </SidebarMenuItem>
144
+ </SidebarMenu>
145
+ </SidebarContent>
146
+ </Sidebar>
147
+ </SidebarProvider>
148
+ ),
149
+ play: async ({ canvasElement }) => {
150
+ const active = canvasElement.querySelector('[data-testid="sub-active"]') as HTMLElement;
151
+ const resting = canvasElement.querySelector('[data-testid="sub-resting"]') as HTMLElement;
152
+ // Still a real link with a real accessible name, not just a styled div.
153
+ await expect(active).toHaveAccessibleName("Overview");
154
+ await expect(resting).toHaveAccessibleName("Reports");
155
+ const bar = getComputedStyle(active, "::before");
156
+ const none = getComputedStyle(resting, "::before");
157
+ // Geometry, not hue: the bar has real width on the active sub-item and none on the resting one.
158
+ await expect(parseFloat(bar.width)).toBeGreaterThan(0);
159
+ await expect(parseFloat(none.width) || 0).toBe(0);
160
+ await expect(getComputedStyle(active).fontWeight).not.toBe(
161
+ getComputedStyle(resting).fontWeight,
162
+ );
163
+ },
164
+ };
165
+
166
+ /**
167
+ * A right-hand panel can drive the floating inset surface — sibling order must
168
+ * not decide it (#342). `SidebarProvider variant="inset"` writes `data-variant`
169
+ * on the frame wrapper, an ANCESTOR of both `SidebarInset` and the right-hand
170
+ * `Sidebar`, so `SidebarInset` reaches it regardless of where the rail sits in
171
+ * the DOM — unlike the old `peer-*` combinator, which only matches a sibling
172
+ * that comes AFTER.
173
+ */
174
+ export const RightHandInset: Story = {
175
+ render: () => (
176
+ <SidebarProvider variant="inset">
177
+ <SidebarInset data-testid="inset" gutter={{ start: true, bottom: true }}>
178
+ <div className="p-6 text-body">content</div>
179
+ </SidebarInset>
180
+ <Sidebar side="right" variant="inset">
181
+ <SidebarContent />
182
+ </Sidebar>
183
+ </SidebarProvider>
184
+ ),
185
+ play: async ({ canvasElement }) => {
186
+ const inset = canvasElement.querySelector('[data-testid="inset"]') as HTMLElement;
187
+ const s = getComputedStyle(inset);
188
+ await expect(parseFloat(s.borderTopLeftRadius)).toBeGreaterThan(0);
189
+ await expect(parseFloat(s.marginInlineStart)).toBeGreaterThan(0);
190
+ // The §2.2 geometry: leading + bottom only. A tab must touch the page it belongs to.
191
+ await expect(parseFloat(s.marginTop)).toBe(0);
192
+ await expect(parseFloat(s.marginInlineEnd)).toBe(0);
193
+ },
194
+ };
195
+
196
+ /**
197
+ * A LEFT sidebar composing BOTH inset mechanisms at once — `SidebarProvider
198
+ * variant="inset"` AND the legacy `Sidebar variant="inset"` on the same
199
+ * frame, the exact shape a left-hand shell (e.g. the sidebar-02 rebuild)
200
+ * uses — must resolve to the explicit `gutter`'s geometry, deterministically
201
+ * (fix round 1, #342, Ruling 18). Unlike `RightHandInset`, DOM order here
202
+ * lets BOTH the ancestor-scoped and the legacy peer-scoped selectors match
203
+ * simultaneously, so this is the one shape that could have produced a
204
+ * class-order race; the pinning assertion below is what proves it doesn't.
205
+ */
206
+ export const LeftHandDualVariantInset: Story = {
207
+ render: () => (
208
+ <SidebarProvider variant="inset">
209
+ <Sidebar variant="inset">
210
+ <SidebarContent />
211
+ </Sidebar>
212
+ <SidebarInset data-testid="inset" gutter={{ start: true, bottom: true }}>
213
+ <div className="p-6 text-body">content</div>
214
+ </SidebarInset>
215
+ </SidebarProvider>
216
+ ),
217
+ play: async ({ canvasElement }) => {
218
+ const inset = canvasElement.querySelector('[data-testid="inset"]') as HTMLElement;
219
+ const s = getComputedStyle(inset);
220
+ // The explicit gutter's geometry: leading + bottom only.
221
+ await expect(parseFloat(s.marginInlineStart)).toBeGreaterThan(0);
222
+ await expect(parseFloat(s.marginBottom)).toBeGreaterThan(0);
223
+ // NOT the legacy rule's hardcoded "auto" shape, which would set marginTop
224
+ // > 0 and marginInlineStart === 0 — the opposite signature.
225
+ await expect(parseFloat(s.marginTop)).toBe(0);
226
+ await expect(parseFloat(s.marginInlineEnd)).toBe(0);
227
+ },
228
+ };
229
+
230
+ /**
231
+ * `SidebarInset`'s default gutter ("auto") recovers its leading margin once
232
+ * the sidebar collapses — driven purely by `SidebarProvider`'s own
233
+ * `data-state` on the ancestor wrapper (ADR 0035 §8 refinement 2), with NO
234
+ * `variant` set on `Sidebar` itself (so the legacy peer rule cannot be what
235
+ * produces this margin — only the ancestor path can).
236
+ */
237
+ export const AutoGutterRecoversOnCollapse: Story = {
238
+ render: () => (
239
+ <SidebarProvider variant="inset" defaultOpen={false}>
240
+ <Sidebar collapsible="icon">
241
+ <SidebarContent />
242
+ </Sidebar>
243
+ <SidebarInset data-testid="inset">
244
+ <div className="p-6 text-body">content</div>
245
+ </SidebarInset>
246
+ </SidebarProvider>
247
+ ),
248
+ play: async ({ canvasElement }) => {
249
+ const inset = canvasElement.querySelector('[data-testid="inset"]') as HTMLElement;
250
+ const s = getComputedStyle(inset);
251
+ await expect(parseFloat(s.marginInlineStart)).toBeGreaterThan(0);
252
+ },
253
+ };
254
+
255
+ /**
256
+ * A nested rail's own `variant="inset"` must never repaint the FRAME's ground
257
+ * once the frame declares its own `variant` (ADR 0035 §8 refinement 4) — the
258
+ * old rule read `has-data-[variant=inset]:bg-sidebar`, and `:has()` is
259
+ * depth-unlimited, so a descendant's variant used to leak up through it.
260
+ */
261
+ export const FrameVariantWinsOverNestedInset: Story = {
262
+ render: () => (
263
+ <SidebarProvider variant="sidebar">
264
+ <Sidebar variant="inset">
265
+ <SidebarContent />
266
+ </Sidebar>
267
+ <SidebarInset>
268
+ <div className="p-6 text-body">content</div>
269
+ </SidebarInset>
270
+ </SidebarProvider>
271
+ ),
272
+ play: async ({ canvasElement }) => {
273
+ const wrapper = canvasElement.querySelector('[data-slot="sidebar-wrapper"]') as HTMLElement;
274
+ // The frame explicitly says "sidebar", so the ground must stay
275
+ // transparent even though a nested `Sidebar` renders `variant="inset"`.
276
+ await expect(getComputedStyle(wrapper).backgroundColor).toBe("rgba(0, 0, 0, 0)");
277
+ },
278
+ };
279
+
280
+ /**
281
+ * The control for `FrameVariantWinsOverNestedInset`: when the frame's own
282
+ * `variant` is UNSET (every caller before #342), the descendant `:has()`
283
+ * fallback still applies — so existing callers render exactly as today.
284
+ */
285
+ export const UnsetFrameVariantFallsBackToDescendant: Story = {
286
+ render: () => (
287
+ <SidebarProvider>
288
+ <Sidebar variant="inset">
289
+ <SidebarContent />
290
+ </Sidebar>
291
+ <SidebarInset>
292
+ <div className="p-6 text-body">content</div>
293
+ </SidebarInset>
294
+ </SidebarProvider>
295
+ ),
296
+ play: async ({ canvasElement }) => {
297
+ const wrapper = canvasElement.querySelector('[data-slot="sidebar-wrapper"]') as HTMLElement;
298
+ await expect(getComputedStyle(wrapper).backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
299
+ },
300
+ };
301
+
302
+ /** Collapsed to the icon rail, a group label takes no space at all — not an invisible box. */
303
+ export const CollapsedGroupLabel: Story = {
304
+ render: () => (
305
+ <SidebarProvider defaultOpen={false}>
306
+ <Sidebar collapsible="icon">
307
+ <SidebarContent>
308
+ <SidebarGroup>
309
+ <SidebarGroupLabel data-testid="label">Platform</SidebarGroupLabel>
310
+ <SidebarGroupContent>
311
+ <SidebarMenu>
312
+ <SidebarMenuItem>
313
+ <SidebarMenuButton tooltip="Overview">Overview</SidebarMenuButton>
314
+ </SidebarMenuItem>
315
+ </SidebarMenu>
316
+ </SidebarGroupContent>
317
+ </SidebarGroup>
318
+ </SidebarContent>
319
+ </Sidebar>
320
+ </SidebarProvider>
321
+ ),
322
+ play: async ({ canvasElement }) => {
323
+ const label = canvasElement.querySelector('[data-testid="label"]') as HTMLElement;
324
+ // `opacity: 0` leaves the box in flow — the gap users see. `display: none` does not.
325
+ await expect(getComputedStyle(label).display).toBe("none");
326
+ await expect(label.getBoundingClientRect().height).toBe(0);
327
+ },
328
+ };
329
+
330
+ /**
331
+ * `SidebarProvider frame="nested"` (ADR 0035 §4, Task 9A) is the shared
332
+ * primitive a compound component (e.g. the future `ContextRail`) mounts to
333
+ * give a second `Sidebar` its own `useSidebar()` state without behaving like
334
+ * a second app frame — the smallest real composition that proves it in a
335
+ * browser rather than in jsdom: an ordinary left-hand app frame whose inset
336
+ * content nests a `frame="nested"` provider around a right-hand icon rail.
337
+ */
338
+ export const NestedFrameProvider: Story = {
339
+ render: () => (
340
+ <div className="h-[480px]">
341
+ <SidebarProvider>
342
+ <Sidebar collapsible="icon">
343
+ <SidebarHeader className="px-3 py-2 font-semibold">Brand UI</SidebarHeader>
344
+ <SidebarContent>
345
+ <SidebarGroup>
346
+ <SidebarGroupContent>
347
+ <SidebarMenu>
348
+ <SidebarMenuItem>
349
+ <SidebarMenuButton isActive tooltip="Home">
350
+ <Home />
351
+ <span>Home</span>
352
+ </SidebarMenuButton>
353
+ </SidebarMenuItem>
354
+ </SidebarMenu>
355
+ </SidebarGroupContent>
356
+ </SidebarGroup>
357
+ </SidebarContent>
358
+ </Sidebar>
359
+ <SidebarInset>
360
+ <header className="flex h-14 items-center gap-2 border-b px-4">
361
+ <SidebarTrigger />
362
+ <span className="text-body font-medium">Dashboard</span>
363
+ </header>
364
+ <div className="flex h-[calc(100%-3.5rem)]">
365
+ <div className="flex-1 p-6 text-body text-muted-foreground">Main content.</div>
366
+ <SidebarProvider frame="nested" data-testid="nested-provider">
367
+ <Sidebar side="right" collapsible="icon">
368
+ <SidebarContent>
369
+ <SidebarGroup>
370
+ <SidebarGroupContent>
371
+ <SidebarMenu>
372
+ <SidebarMenuItem>
373
+ <SidebarMenuButton tooltip="Details">
374
+ <Inbox />
375
+ <span>Details</span>
376
+ </SidebarMenuButton>
377
+ </SidebarMenuItem>
378
+ <SidebarMenuItem>
379
+ <SidebarMenuButton tooltip="Settings">
380
+ <Settings />
381
+ <span>Settings</span>
382
+ </SidebarMenuButton>
383
+ </SidebarMenuItem>
384
+ </SidebarMenu>
385
+ </SidebarGroupContent>
386
+ </SidebarGroup>
387
+ </SidebarContent>
388
+ </Sidebar>
389
+ </SidebarProvider>
390
+ </div>
391
+ </SidebarInset>
392
+ </SidebarProvider>
393
+ </div>
394
+ ),
395
+ play: async ({ canvasElement }) => {
396
+ // Exactly one frame in the whole composition — the nested provider does
397
+ // not duplicate the "sidebar wrapper" slot.
398
+ await expect(canvasElement.querySelectorAll('[data-slot="sidebar-wrapper"]')).toHaveLength(1);
399
+ const nestedProviderEl = canvasElement.querySelector(
400
+ '[data-testid="nested-provider"]',
401
+ ) as HTMLElement;
402
+ await expect(nestedProviderEl).not.toBeNull();
403
+ // `display: contents` — resolved from the story's own DOM, not a guessed
404
+ // ancestor.
405
+ await expect(getComputedStyle(nestedProviderEl).display).toBe("contents");
406
+ // The custom property survives `display: contents`, which is how the
407
+ // nested provider publishes its width to the `Sidebar` beneath it.
408
+ await expect(getComputedStyle(nestedProviderEl).getPropertyValue("--sidebar-width")).not.toBe(
409
+ "",
410
+ );
411
+ },
412
+ };
413
+
414
+ /**
415
+ * Below the mobile breakpoint, `Sidebar` renders its own `Sheet` at
416
+ * `SIDEBAR_WIDTH_MOBILE` (18rem = 288px), not `SheetContent`'s hardcoded
417
+ * `w-3/4 max-w-sm` default (#387). Unlike `ContextRail`'s `Narrow` story,
418
+ * `Sidebar` has no `overlayBreakpoint`-style prop to force the branch
419
+ * deterministically, and a display-only `parameters.viewport` global does
420
+ * not move `window.innerWidth` under a headless run (see `ContextRail`'s
421
+ * `Narrow` story comment) — `useIsMobile()` reads the real viewport, so this
422
+ * resizes the actual test browser instead, via `@vitest/browser/context`'s
423
+ * `page.viewport`.
424
+ */
425
+ export const Mobile: Story = {
426
+ render: () => (
427
+ <SidebarProvider>
428
+ <Sidebar>
429
+ <SidebarContent>
430
+ <SidebarMenu>
431
+ <SidebarMenuItem>
432
+ <SidebarMenuButton>Overview</SidebarMenuButton>
433
+ </SidebarMenuItem>
434
+ </SidebarMenu>
435
+ </SidebarContent>
436
+ </Sidebar>
437
+ <SidebarInset>
438
+ <header className="flex h-14 items-center gap-2 border-b px-4">
439
+ <SidebarTrigger />
440
+ </header>
441
+ </SidebarInset>
442
+ </SidebarProvider>
443
+ ),
444
+ play: async ({ canvasElement, canvas }) => {
445
+ // `@vitest/browser/context` is a virtual module that only resolves
446
+ // inside Vitest's browser-mode test runner (`pnpm --filter
447
+ // @elabs-ai/components-docs test-storybook` / `vitest --project
448
+ // storybook run`) — see `slider.stories.tsx`'s identical guard.
449
+ let browserContext: typeof import("@vitest/browser/context") | undefined;
450
+ try {
451
+ browserContext = await import("@vitest/browser/context");
452
+ } catch {
453
+ browserContext = undefined;
454
+ }
455
+ if (!browserContext) return;
456
+
457
+ // Vitest isolates every story FILE in its own iframe (reset to the
458
+ // configured default viewport before the file runs), so this resize
459
+ // cannot leak into another file's test. It CAN leak into a later story
460
+ // in this same file, though — restore it in `finally` so a throwing
461
+ // assertion still leaves the shared iframe how the next story expects it.
462
+ const originalWidth = window.innerWidth;
463
+ const originalHeight = window.innerHeight;
464
+ await browserContext.page.viewport(375, 800);
465
+
466
+ try {
467
+ const trigger = canvas.getByRole("button", { name: "Toggle Sidebar" });
468
+ // Below the mobile breakpoint the trigger flips from driving the
469
+ // desktop `open` state (default `true`) to the mobile `openMobile`
470
+ // state (default `false`) — this is the earliest observable signal that
471
+ // `useIsMobile()`'s `matchMedia` listener actually caught the resize.
472
+ await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"), {
473
+ timeout: 3000,
474
+ });
475
+ await userEvent.click(trigger);
476
+
477
+ // `SheetContent` portals to `document.body`, a sibling of
478
+ // `canvasElement`, not a descendant of it.
479
+ const sheet = await waitFor(() => {
480
+ const el = canvasElement.ownerDocument.querySelector('[data-mobile="true"]');
481
+ if (!el) throw new Error("mobile sheet not rendered yet");
482
+ return el as HTMLElement;
483
+ });
484
+ await waitFor(() => expect(sheet.getBoundingClientRect().width).toBe(288));
485
+ } finally {
486
+ await browserContext.page.viewport(originalWidth, originalHeight);
487
+ }
488
+ },
489
+ };
@@ -1,7 +1,15 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
3
 
4
- import { Sidebar, SidebarInset, SidebarProvider } from "./sidebar";
4
+ import {
5
+ Sidebar,
6
+ SidebarContent,
7
+ SidebarInset,
8
+ SidebarMenu,
9
+ SidebarMenuButton,
10
+ SidebarMenuItem,
11
+ SidebarProvider,
12
+ } from "./sidebar";
5
13
 
6
14
  function renderFrame() {
7
15
  return render(
@@ -40,4 +48,31 @@ describe("SidebarProvider", () => {
40
48
  const chrome = screen.getByText("chrome").closest('[class*="text-sidebar-foreground"]');
41
49
  expect(chrome).not.toBeNull();
42
50
  });
51
+
52
+ it("marks the active menu button with a non-colour cue, not hue alone", () => {
53
+ render(
54
+ <SidebarProvider>
55
+ <Sidebar>
56
+ <SidebarContent>
57
+ <SidebarMenu>
58
+ <SidebarMenuItem>
59
+ <SidebarMenuButton isActive>Active item</SidebarMenuButton>
60
+ </SidebarMenuItem>
61
+ <SidebarMenuItem>
62
+ <SidebarMenuButton>Resting item</SidebarMenuButton>
63
+ </SidebarMenuItem>
64
+ </SidebarMenu>
65
+ </SidebarContent>
66
+ </Sidebar>
67
+ </SidebarProvider>,
68
+ );
69
+ const active = screen.getByRole("button", { name: "Active item" });
70
+ const resting = screen.getByRole("button", { name: "Resting item" });
71
+ // Asserting the two class strings merely DIFFER passes on colour-only code and
72
+ // is not sufficient (.claude/rules/accessibility.md §1.4.1). Assert the cues
73
+ // that survive greyscale: a drawn bar, and a heavier weight.
74
+ expect(active.className).toContain("data-[active=true]:before:w-1");
75
+ expect(active.className).toContain("data-[active=true]:font-semibold");
76
+ expect(resting).toHaveAttribute("data-active", "false");
77
+ });
43
78
  });