@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,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
2
3
|
import { Button } from "../button";
|
|
3
4
|
import { SectionHeader } from "./section-header";
|
|
4
5
|
|
|
@@ -18,3 +19,39 @@ export const Default: Story = {
|
|
|
18
19
|
actions: <Button size="sm">New project</Button>,
|
|
19
20
|
},
|
|
20
21
|
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `as` picks the heading LEVEL, never the size — the title renders at
|
|
25
|
+
* `text-title` for every value. Reach for `as="h1"` when this header titles the
|
|
26
|
+
* page itself; a screen whose highest heading is an `<h2>` has no document
|
|
27
|
+
* outline root at all (WCAG 1.3.1).
|
|
28
|
+
*/
|
|
29
|
+
export const PageTitle: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
eyebrow: "Workspace",
|
|
32
|
+
as: "h1",
|
|
33
|
+
title: "Projects",
|
|
34
|
+
description: "The page’s own title, so it owns the h1.",
|
|
35
|
+
},
|
|
36
|
+
play: async ({ canvasElement }) => {
|
|
37
|
+
const canvas = within(canvasElement);
|
|
38
|
+
// Resolve BY ROLE AND LEVEL: `getByText` would find the same node whatever
|
|
39
|
+
// tag it rendered as, so it could not tell an <h1> from the <h2> default.
|
|
40
|
+
const h1 = canvas.getByRole("heading", { level: 1, name: "Projects" });
|
|
41
|
+
await expect(h1.tagName).toBe("H1");
|
|
42
|
+
// The visual is unchanged by the level — that is the whole point of the
|
|
43
|
+
// prop, and the reason nobody should reach for it to get a bigger title.
|
|
44
|
+
await expect(h1.classList.contains("text-title")).toBe(true);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The default is still `<h2>`, so every existing caller is untouched. */
|
|
49
|
+
export const DefaultLevelIsH2: Story = {
|
|
50
|
+
args: { title: "Projects" },
|
|
51
|
+
play: async ({ canvasElement }) => {
|
|
52
|
+
const canvas = within(canvasElement);
|
|
53
|
+
const heading = canvas.getByRole("heading", { name: "Projects" });
|
|
54
|
+
await expect(heading.tagName).toBe("H2");
|
|
55
|
+
await expect(canvas.queryByRole("heading", { level: 1 })).toBeNull();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -8,6 +8,16 @@ export interface SectionHeaderProps {
|
|
|
8
8
|
actions?: ReactNode;
|
|
9
9
|
/** Optional element rendered above the title (eyebrow / breadcrumbs). */
|
|
10
10
|
eyebrow?: ReactNode;
|
|
11
|
+
/**
|
|
12
|
+
* The heading LEVEL the title contributes to the document outline. The
|
|
13
|
+
* visual (`text-title`) is identical for every value — pick the level the
|
|
14
|
+
* page's structure calls for, not the size you want (WCAG 1.3.1).
|
|
15
|
+
*
|
|
16
|
+
* Use `as="h1"` when this header titles the PAGE (a screen whose route it
|
|
17
|
+
* names); leave it at the default when it titles a section INSIDE a page
|
|
18
|
+
* that already has an `<h1>` elsewhere. @default "h2"
|
|
19
|
+
*/
|
|
20
|
+
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
11
21
|
className?: string;
|
|
12
22
|
}
|
|
13
23
|
|
|
@@ -17,6 +27,7 @@ export function SectionHeader({
|
|
|
17
27
|
description,
|
|
18
28
|
actions,
|
|
19
29
|
eyebrow,
|
|
30
|
+
as: TitleTag = "h2",
|
|
20
31
|
className,
|
|
21
32
|
}: SectionHeaderProps) {
|
|
22
33
|
return (
|
|
@@ -27,7 +38,7 @@ export function SectionHeader({
|
|
|
27
38
|
{eyebrow}
|
|
28
39
|
</div>
|
|
29
40
|
) : null}
|
|
30
|
-
<
|
|
41
|
+
<TitleTag className="text-title text-foreground">{title}</TitleTag>
|
|
31
42
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
|
32
43
|
</div>
|
|
33
44
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
|
@@ -123,11 +123,15 @@ export const SelectTrigger = forwardRef<
|
|
|
123
123
|
className={cn(
|
|
124
124
|
"flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm",
|
|
125
125
|
size === "sm" ? "h-8" : "h-9",
|
|
126
|
-
"placeholder:text-muted-foreground focus
|
|
126
|
+
"placeholder:text-muted-foreground focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
127
127
|
// No `disabled:cursor-*` override — disabled controls keep the arrow
|
|
128
128
|
// automatically (interaction-guidelines.md), matching Combobox's
|
|
129
129
|
// Button-based trigger (`disabled:pointer-events-none`) (#343).
|
|
130
|
-
|
|
130
|
+
// `truncate`, not `line-clamp-1`: a clamp only prints its ellipsis when it has
|
|
131
|
+
// dropped a LINE, so a single-line value too wide for a narrow trigger was cut
|
|
132
|
+
// mid-word with no ellipsis at all ("Occurren"). `autoTitle` above puts the full
|
|
133
|
+
// text in the tooltip, so the ellipsis is the visible half of that pair.
|
|
134
|
+
"disabled:opacity-50 disabled:border-border [&>span]:truncate",
|
|
131
135
|
className,
|
|
132
136
|
)}
|
|
133
137
|
{...props}
|
|
@@ -66,7 +66,7 @@ export const SheetContent = forwardRef<
|
|
|
66
66
|
>
|
|
67
67
|
{children}
|
|
68
68
|
<SheetPrimitive.Close
|
|
69
|
-
className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus
|
|
69
|
+
className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-ring"
|
|
70
70
|
aria-label="Close"
|
|
71
71
|
>
|
|
72
72
|
<X className="size-4" />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SideDock, type SideDockProps } from "./side-dock";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
3
|
+
|
|
4
|
+
import { SideDock } from "./side-dock";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Layout/SideDock",
|
|
8
|
+
component: SideDock,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: "fullscreen",
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
"A summoned, resizable panel that docks to one edge of the content area — closed by default, opened by something the user does, resizable by pointer and keyboard, and rendered as an overlay `Sheet` below `overlayBreakpoint` (default 1100) instead of a fixed-width column. Unlike `ContextRail` (a persistent rail with a 48px icon collapsed state), `SideDock` has no persistent strip: closed means gone. See `docs/ADR/0035-context-rail-and-side-dock.md` §5.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof SideDock>;
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
const dockBody = <p className="text-body text-muted-foreground">Dock body content.</p>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open, right-hand (the default `side`). The play function tabs from the
|
|
27
|
+
* close button to the resize handle, widens it twice with the keyboard
|
|
28
|
+
* (`ArrowLeft` on a right-hand dock), and asserts the CONTAINER's actual
|
|
29
|
+
* rendered width grew — a real layout measurement, not just the `aria-*`
|
|
30
|
+
* bookkeeping the unit tests already cover.
|
|
31
|
+
*/
|
|
32
|
+
export const Default: Story = {
|
|
33
|
+
render: () => (
|
|
34
|
+
<div className="flex h-[560px] w-full">
|
|
35
|
+
<div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
|
|
36
|
+
<SideDock title="Assistant" open>
|
|
37
|
+
{dockBody}
|
|
38
|
+
</SideDock>
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
play: async ({ canvas, canvasElement, userEvent }) => {
|
|
42
|
+
const closeButton = canvas.getByRole("button", { name: "Close" });
|
|
43
|
+
const handle = canvas.getByRole("separator");
|
|
44
|
+
const container = canvasElement.querySelector(
|
|
45
|
+
'[data-slot="side-dock-container"]',
|
|
46
|
+
) as HTMLElement;
|
|
47
|
+
|
|
48
|
+
await userEvent.tab();
|
|
49
|
+
await expect(closeButton).toHaveFocus();
|
|
50
|
+
|
|
51
|
+
/* The scroll port sits BETWEEN the two, and that is the point: a dock's
|
|
52
|
+
* children are caller-supplied and routinely have no focusable descendant,
|
|
53
|
+
* so before this stop existed a keyboard user could reach the close button
|
|
54
|
+
* and the resize handle but never the region that actually scrolls (WCAG
|
|
55
|
+
* 2.1.1, axe `scrollable-region-focusable`). `getAttribute`, not the
|
|
56
|
+
* `tabIndex` IDL getter, which reads -1 whether or not the attribute is
|
|
57
|
+
* there; and `classList`, because `toHaveClass` is a subset check that
|
|
58
|
+
* would pass with BOTH rungs applied — the inset rung is required here
|
|
59
|
+
* because the dock container clips anything drawn outside this box. */
|
|
60
|
+
const body = canvasElement.querySelector('[data-slot="side-dock-body"]') as HTMLElement;
|
|
61
|
+
await userEvent.tab();
|
|
62
|
+
await expect(body).toHaveFocus();
|
|
63
|
+
await expect(body.getAttribute("tabindex")).toBe("0");
|
|
64
|
+
await expect(body.classList.contains("focus-ring-inset")).toBe(true);
|
|
65
|
+
await expect(body.classList.contains("focus-ring")).toBe(false);
|
|
66
|
+
|
|
67
|
+
await userEvent.tab();
|
|
68
|
+
await expect(handle).toHaveFocus();
|
|
69
|
+
|
|
70
|
+
const before = container.getBoundingClientRect().width;
|
|
71
|
+
await userEvent.keyboard("{ArrowLeft}");
|
|
72
|
+
await userEvent.keyboard("{ArrowLeft}");
|
|
73
|
+
|
|
74
|
+
await waitFor(() => {
|
|
75
|
+
const after = container.getBoundingClientRect().width;
|
|
76
|
+
expect(after).toBeGreaterThan(before);
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** `open={false}` (the default) — collapsed, `inert`, off-screen; its body content stays mounted for the closing transition. */
|
|
82
|
+
export const Closed: Story = {
|
|
83
|
+
render: () => (
|
|
84
|
+
<div className="flex h-[560px] w-full">
|
|
85
|
+
<div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
|
|
86
|
+
<SideDock title="Assistant">{dockBody}</SideDock>
|
|
87
|
+
</div>
|
|
88
|
+
),
|
|
89
|
+
play: async ({ canvasElement }) => {
|
|
90
|
+
const root = canvasElement.querySelector('[data-slot="side-dock"]');
|
|
91
|
+
const container = canvasElement.querySelector('[data-slot="side-dock-container"]');
|
|
92
|
+
await expect(root).toHaveAttribute("data-state", "collapsed");
|
|
93
|
+
await expect(container).toHaveAttribute("inert");
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** `side="left"` — the handle sits on the trailing edge and the keyboard direction inverts (`ArrowRight` widens). */
|
|
98
|
+
export const LeftHanded: Story = {
|
|
99
|
+
render: () => (
|
|
100
|
+
<div className="flex h-[560px] w-full">
|
|
101
|
+
<SideDock title="Assistant" open side="left">
|
|
102
|
+
{dockBody}
|
|
103
|
+
</SideDock>
|
|
104
|
+
<div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
|
|
105
|
+
</div>
|
|
106
|
+
),
|
|
107
|
+
play: async ({ canvas }) => {
|
|
108
|
+
const handle = canvas.getByRole("separator");
|
|
109
|
+
await expect(handle).toHaveAttribute("aria-orientation", "vertical");
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Below `overlayBreakpoint` the dock renders as an overlay `Sheet` instead of
|
|
115
|
+
* a column, with no resize handle. A very high `overlayBreakpoint` forces
|
|
116
|
+
* this branch deterministically under a headless run, mirroring
|
|
117
|
+
* `ContextRail`'s own `Narrow` story.
|
|
118
|
+
*/
|
|
119
|
+
export const Overlay: Story = {
|
|
120
|
+
render: () => (
|
|
121
|
+
<div className="flex h-[560px] w-full">
|
|
122
|
+
<div className="flex-1 bg-background p-6 text-muted-foreground">Canvas content</div>
|
|
123
|
+
<SideDock title="Assistant" open overlayBreakpoint={4000} description="A helper panel.">
|
|
124
|
+
{dockBody}
|
|
125
|
+
</SideDock>
|
|
126
|
+
</div>
|
|
127
|
+
),
|
|
128
|
+
play: async ({ canvasElement }) => {
|
|
129
|
+
const dialog = await waitFor(() => {
|
|
130
|
+
const el = document.querySelector('[role="dialog"]');
|
|
131
|
+
if (!el) throw new Error("overlay Sheet has not mounted yet");
|
|
132
|
+
return el;
|
|
133
|
+
});
|
|
134
|
+
await expect(dialog).toHaveAccessibleName("Assistant");
|
|
135
|
+
await expect(canvasElement.querySelector('[data-slot="side-dock-resize-handle"]')).toBeNull();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { SideDock } from "./side-dock";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every test that wants the COLUMN branch passes `overlayBreakpoint={800}` —
|
|
8
|
+
* jsdom's `window.innerWidth` is 1024 and its stubbed `matchMedia` always
|
|
9
|
+
* reports `matches: false`, so the default `overlayBreakpoint` (1100) would
|
|
10
|
+
* render the OVERLAY branch in every test (see task-10-brief.md, Mechanism §2).
|
|
11
|
+
* The one test that wants the overlay branch passes `overlayBreakpoint={2000}`.
|
|
12
|
+
*/
|
|
13
|
+
const COLUMN_BREAKPOINT = 800;
|
|
14
|
+
|
|
15
|
+
describe("SideDock", () => {
|
|
16
|
+
// 1. Names the region.
|
|
17
|
+
it("names the complementary region from `title`", () => {
|
|
18
|
+
render(
|
|
19
|
+
<SideDock title="Assistant" open overlayBreakpoint={COLUMN_BREAKPOINT}>
|
|
20
|
+
Body
|
|
21
|
+
</SideDock>,
|
|
22
|
+
);
|
|
23
|
+
expect(screen.getByRole("complementary")).toHaveAccessibleName("Assistant");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// 2. Names the handle and reports its value.
|
|
27
|
+
it("names the resize handle and reports its current value", () => {
|
|
28
|
+
render(
|
|
29
|
+
<SideDock title="Assistant" open overlayBreakpoint={COLUMN_BREAKPOINT}>
|
|
30
|
+
Body
|
|
31
|
+
</SideDock>,
|
|
32
|
+
);
|
|
33
|
+
const handle = screen.getByRole("separator");
|
|
34
|
+
expect(handle).toHaveAccessibleName("Resize Assistant");
|
|
35
|
+
expect(handle).toHaveAttribute("aria-orientation", "vertical");
|
|
36
|
+
expect(handle).toHaveAttribute("aria-valuenow", "400");
|
|
37
|
+
expect(handle).toHaveAttribute("aria-valuemin", "320");
|
|
38
|
+
expect(handle.getAttribute("aria-valuetext")).toContain("400 pixels");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 3. Keyboard widens (right-hand dock: ArrowLeft widens).
|
|
42
|
+
it("widens on ArrowLeft and commits once on keyup", () => {
|
|
43
|
+
const onWidthChange = vi.fn();
|
|
44
|
+
const onWidthCommit = vi.fn();
|
|
45
|
+
render(
|
|
46
|
+
<SideDock
|
|
47
|
+
title="Assistant"
|
|
48
|
+
open
|
|
49
|
+
overlayBreakpoint={COLUMN_BREAKPOINT}
|
|
50
|
+
onWidthChange={onWidthChange}
|
|
51
|
+
onWidthCommit={onWidthCommit}
|
|
52
|
+
>
|
|
53
|
+
Body
|
|
54
|
+
</SideDock>,
|
|
55
|
+
);
|
|
56
|
+
const handle = screen.getByRole("separator");
|
|
57
|
+
handle.focus();
|
|
58
|
+
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
|
59
|
+
expect(onWidthChange).toHaveBeenCalledTimes(1);
|
|
60
|
+
const widened = onWidthChange.mock.calls[0]![0] as number;
|
|
61
|
+
expect(widened).toBeGreaterThan(400);
|
|
62
|
+
expect(onWidthCommit).not.toHaveBeenCalled();
|
|
63
|
+
fireEvent.keyUp(handle, { key: "ArrowLeft" });
|
|
64
|
+
expect(onWidthCommit).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(onWidthCommit).toHaveBeenCalledWith(widened);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// 4. Keyboard narrows; Home/End jump to the effective bounds.
|
|
69
|
+
// CORRECTED 2026-09-05 (Ruling 29): under these defaults (minContentWidth
|
|
70
|
+
// 480 against jsdom's 1024px viewport) the effective maximum is 544, not
|
|
71
|
+
// maxWidth (640) — see clampWidth's viewport term.
|
|
72
|
+
it("narrows on ArrowRight and End jumps to the effective maximum (544, not maxWidth)", () => {
|
|
73
|
+
render(
|
|
74
|
+
<SideDock title="Assistant" open overlayBreakpoint={COLUMN_BREAKPOINT}>
|
|
75
|
+
Body
|
|
76
|
+
</SideDock>,
|
|
77
|
+
);
|
|
78
|
+
const handle = screen.getByRole("separator");
|
|
79
|
+
handle.focus();
|
|
80
|
+
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
|
81
|
+
fireEvent.keyUp(handle, { key: "ArrowRight" });
|
|
82
|
+
expect(handle).toHaveAttribute("aria-valuenow", "384");
|
|
83
|
+
fireEvent.keyDown(handle, { key: "End" });
|
|
84
|
+
fireEvent.keyUp(handle, { key: "End" });
|
|
85
|
+
expect(handle).toHaveAttribute("aria-valuenow", "544");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// 5. A no-op is silent.
|
|
89
|
+
it("fires no callback and leaves the value unchanged when the narrowing key is a no-op at minWidth", () => {
|
|
90
|
+
const onWidthChange = vi.fn();
|
|
91
|
+
const onWidthCommit = vi.fn();
|
|
92
|
+
render(
|
|
93
|
+
<SideDock
|
|
94
|
+
title="Assistant"
|
|
95
|
+
open
|
|
96
|
+
overlayBreakpoint={COLUMN_BREAKPOINT}
|
|
97
|
+
width={320}
|
|
98
|
+
minWidth={320}
|
|
99
|
+
onWidthChange={onWidthChange}
|
|
100
|
+
onWidthCommit={onWidthCommit}
|
|
101
|
+
>
|
|
102
|
+
Body
|
|
103
|
+
</SideDock>,
|
|
104
|
+
);
|
|
105
|
+
const handle = screen.getByRole("separator");
|
|
106
|
+
handle.focus();
|
|
107
|
+
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
|
108
|
+
fireEvent.keyUp(handle, { key: "ArrowRight" });
|
|
109
|
+
expect(onWidthChange).not.toHaveBeenCalled();
|
|
110
|
+
expect(onWidthCommit).not.toHaveBeenCalled();
|
|
111
|
+
expect(handle).toHaveAttribute("aria-valuenow", "320");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// 6. A controlled out-of-range width converges (maxWidth as the sole
|
|
115
|
+
// upper bound — minContentWidth={0} zeroes the viewport bound, per
|
|
116
|
+
// Ruling 29; assertion 7 below is what covers the viewport bound).
|
|
117
|
+
it("converges a controlled width above maxWidth, emitting each callback once", () => {
|
|
118
|
+
const onWidthChange = vi.fn();
|
|
119
|
+
const onWidthCommit = vi.fn();
|
|
120
|
+
render(
|
|
121
|
+
<SideDock
|
|
122
|
+
title="Assistant"
|
|
123
|
+
open
|
|
124
|
+
overlayBreakpoint={COLUMN_BREAKPOINT}
|
|
125
|
+
width={9999}
|
|
126
|
+
maxWidth={640}
|
|
127
|
+
minContentWidth={0}
|
|
128
|
+
onWidthChange={onWidthChange}
|
|
129
|
+
onWidthCommit={onWidthCommit}
|
|
130
|
+
>
|
|
131
|
+
Body
|
|
132
|
+
</SideDock>,
|
|
133
|
+
);
|
|
134
|
+
expect(screen.getByRole("separator")).toHaveAttribute("aria-valuenow", "640");
|
|
135
|
+
expect(onWidthChange).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(onWidthChange).toHaveBeenCalledWith(640);
|
|
137
|
+
expect(onWidthCommit).toHaveBeenCalledTimes(1);
|
|
138
|
+
expect(onWidthCommit).toHaveBeenCalledWith(640);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 7. minContentWidth binds against the live viewport.
|
|
142
|
+
it("binds minContentWidth against jsdom's 1024px viewport", () => {
|
|
143
|
+
render(
|
|
144
|
+
<SideDock
|
|
145
|
+
title="Assistant"
|
|
146
|
+
open
|
|
147
|
+
overlayBreakpoint={COLUMN_BREAKPOINT}
|
|
148
|
+
width={640}
|
|
149
|
+
minContentWidth={480}
|
|
150
|
+
>
|
|
151
|
+
Body
|
|
152
|
+
</SideDock>,
|
|
153
|
+
);
|
|
154
|
+
expect(screen.getByRole("separator")).toHaveAttribute("aria-valuenow", "544");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// 8. Uncontrolled default is closed.
|
|
158
|
+
// `data-state` lives on the GROUP element per task-10-brief.md Mechanism §1
|
|
159
|
+
// ("attrs ... goes on the group element that wraps both fragments" —
|
|
160
|
+
// mirrors Sidebar, which puts `data-state` on `data-slot="sidebar"`, never
|
|
161
|
+
// on `sidebar-container`); `inert` is the CONTAINER's own attribute (the
|
|
162
|
+
// fixed contract's "the collapsed container is inert").
|
|
163
|
+
it("is closed by default, inert, with its body content still mounted", () => {
|
|
164
|
+
render(
|
|
165
|
+
<SideDock title="Assistant" overlayBreakpoint={COLUMN_BREAKPOINT}>
|
|
166
|
+
Body content
|
|
167
|
+
</SideDock>,
|
|
168
|
+
);
|
|
169
|
+
const root = document.querySelector('[data-slot="side-dock"]');
|
|
170
|
+
const container = document.querySelector('[data-slot="side-dock-container"]');
|
|
171
|
+
expect(root).toHaveAttribute("data-state", "collapsed");
|
|
172
|
+
// jsdom does not implement the `.inert` IDL property (it reads back
|
|
173
|
+
// `undefined` even when the attribute is present) — assert the
|
|
174
|
+
// ATTRIBUTE, which is what React 19 actually renders for `inert={true}`.
|
|
175
|
+
expect(container).toHaveAttribute("inert");
|
|
176
|
+
expect(screen.getByText("Body content")).toBeInTheDocument();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// 9. The close control closes it.
|
|
180
|
+
it("closes on clicking the close button", () => {
|
|
181
|
+
const onOpenChange = vi.fn();
|
|
182
|
+
render(
|
|
183
|
+
<SideDock
|
|
184
|
+
title="Assistant"
|
|
185
|
+
defaultOpen
|
|
186
|
+
overlayBreakpoint={COLUMN_BREAKPOINT}
|
|
187
|
+
onOpenChange={onOpenChange}
|
|
188
|
+
>
|
|
189
|
+
Body
|
|
190
|
+
</SideDock>,
|
|
191
|
+
);
|
|
192
|
+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
193
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
194
|
+
const root = document.querySelector('[data-slot="side-dock"]');
|
|
195
|
+
expect(root).toHaveAttribute("data-state", "collapsed");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// 10. The overlay branch.
|
|
199
|
+
it("renders as an overlay Sheet below overlayBreakpoint, with no resize handle", () => {
|
|
200
|
+
render(
|
|
201
|
+
<SideDock title="Assistant" open overlayBreakpoint={2000} description="A helper panel.">
|
|
202
|
+
Body
|
|
203
|
+
</SideDock>,
|
|
204
|
+
);
|
|
205
|
+
const dialog = screen.getByRole("dialog");
|
|
206
|
+
expect(dialog).toHaveAccessibleName("Assistant");
|
|
207
|
+
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
|
208
|
+
expect(screen.getByText("A helper panel.")).toBeInTheDocument();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 11. side="left" inverts the keyboard direction.
|
|
212
|
+
it("inverts ArrowRight to widen on a left-hand dock", () => {
|
|
213
|
+
const onWidthChange = vi.fn();
|
|
214
|
+
render(
|
|
215
|
+
<SideDock
|
|
216
|
+
title="Assistant"
|
|
217
|
+
open
|
|
218
|
+
side="left"
|
|
219
|
+
overlayBreakpoint={COLUMN_BREAKPOINT}
|
|
220
|
+
onWidthChange={onWidthChange}
|
|
221
|
+
>
|
|
222
|
+
Body
|
|
223
|
+
</SideDock>,
|
|
224
|
+
);
|
|
225
|
+
const handle = screen.getByRole("separator");
|
|
226
|
+
handle.focus();
|
|
227
|
+
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
|
228
|
+
expect(onWidthChange).toHaveBeenCalledTimes(1);
|
|
229
|
+
expect(onWidthChange.mock.calls[0]![0] as number).toBeGreaterThan(400);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// 12. Finding 2 (task-11f-brief.md): `inert={!panel.open}` on the column
|
|
233
|
+
// `<aside>` means, per the HTML focus-fixup rule, that a focused
|
|
234
|
+
// descendant (the close button) loses focus the instant the close click
|
|
235
|
+
// makes the container inert — the browser's default is to drop focus to
|
|
236
|
+
// `<body>`. This asserts the repair: closing via keyboard/click restores
|
|
237
|
+
// focus to whatever opened the dock, and explicitly never to `<body>`.
|
|
238
|
+
it("restores focus to the opener when the close button closes the dock", () => {
|
|
239
|
+
function Harness() {
|
|
240
|
+
const [open, setOpen] = useState(false);
|
|
241
|
+
return (
|
|
242
|
+
<>
|
|
243
|
+
<button type="button" onClick={() => setOpen(true)}>
|
|
244
|
+
Open dock
|
|
245
|
+
</button>
|
|
246
|
+
<SideDock
|
|
247
|
+
title="Assistant"
|
|
248
|
+
open={open}
|
|
249
|
+
onOpenChange={setOpen}
|
|
250
|
+
overlayBreakpoint={COLUMN_BREAKPOINT}
|
|
251
|
+
>
|
|
252
|
+
Body
|
|
253
|
+
</SideDock>
|
|
254
|
+
</>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
render(<Harness />);
|
|
258
|
+
|
|
259
|
+
const trigger = screen.getByRole("button", { name: "Open dock" });
|
|
260
|
+
trigger.focus();
|
|
261
|
+
expect(document.activeElement).toBe(trigger);
|
|
262
|
+
|
|
263
|
+
fireEvent.click(trigger);
|
|
264
|
+
const closeButton = screen.getByRole("button", { name: "Close" });
|
|
265
|
+
closeButton.focus();
|
|
266
|
+
expect(document.activeElement).toBe(closeButton);
|
|
267
|
+
|
|
268
|
+
fireEvent.click(closeButton);
|
|
269
|
+
|
|
270
|
+
expect(document.activeElement).not.toBe(document.body);
|
|
271
|
+
expect(document.activeElement).toBe(trigger);
|
|
272
|
+
});
|
|
273
|
+
});
|