@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,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
- <h2 className="text-title text-foreground">{title}</h2>
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:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
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
- "disabled:opacity-50 disabled:border-border [&>span]:line-clamp-1",
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:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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
+ });