@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.
- package/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- 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: {
|
|
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 {
|
|
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
|
});
|