@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
@@ -103,3 +103,166 @@ export const ToolbarSticky: Story = {
103
103
  await expect(Math.abs(headerTopAfterScroll - headerTopStuck)).toBeLessThanOrEqual(1);
104
104
  },
105
105
  };
106
+
107
+ /**
108
+ * `scroll="content"` makes `PageShell` itself the scroll port, for embedding
109
+ * inside a `SidebarInset` whose height is already bounded — the app chrome
110
+ * outside it (the top bar here) never moves.
111
+ */
112
+ export const ScrollContent: Story = {
113
+ render: function ScrollContentStory() {
114
+ return (
115
+ <div className="flex h-[420px] flex-col border border-border">
116
+ <div
117
+ data-testid="app-topbar"
118
+ className="shrink-0 border-b border-border bg-sidebar px-4 py-2 text-body"
119
+ >
120
+ App chrome outside PageShell
121
+ </div>
122
+ <PageShell scroll="content" data-testid="scroll-content-shell">
123
+ <div className="flex flex-col gap-3">
124
+ {Array.from({ length: 40 }, (_, i) => (
125
+ <div
126
+ key={i}
127
+ data-testid={i === 20 ? "marker-row" : undefined}
128
+ className="rounded-lg border bg-card p-4 text-body"
129
+ >
130
+ Row {i + 1}
131
+ </div>
132
+ ))}
133
+ </div>
134
+ </PageShell>
135
+ </div>
136
+ );
137
+ },
138
+ play: async ({ canvasElement }) => {
139
+ const shell = canvasElement.querySelector<HTMLElement>('[data-testid="scroll-content-shell"]');
140
+ const marker = canvasElement.querySelector<HTMLElement>('[data-testid="marker-row"]');
141
+ const topbar = canvasElement.querySelector<HTMLElement>('[data-testid="app-topbar"]');
142
+
143
+ await expect(shell).not.toBeNull();
144
+ await expect(marker).not.toBeNull();
145
+ await expect(topbar).not.toBeNull();
146
+
147
+ await expect(getComputedStyle(shell!).overflowY).toBe("auto");
148
+
149
+ // scroll="content" makes this element the scroll port, so it defaults to
150
+ // keyboard-operable (WCAG 2.1.1, axe `scrollable-region-focusable`). Read
151
+ // the ATTRIBUTE, not the `tabIndex` IDL getter — the getter reports a
152
+ // default for an element that carries no attribute at all.
153
+ await expect(shell!.getAttribute("tabindex")).toBe("0");
154
+ // And the rung is the inset one: `scroll="content"` is documented for use
155
+ // inside a `SidebarInset`, whose `overflow-hidden` clips both layers of the
156
+ // plain rung. `classList` is an exact token match, so this cannot be
157
+ // satisfied by `focus-ring-inset` being a superstring of `focus-ring`.
158
+ await expect(shell!.classList.contains("focus-ring-inset")).toBe(true);
159
+ await expect(shell!.classList.contains("focus-ring")).toBe(false);
160
+
161
+ const topbarTopBefore = topbar!.getBoundingClientRect().top;
162
+ const markerTopBefore = marker!.getBoundingClientRect().top;
163
+
164
+ shell!.scrollTop = 300;
165
+
166
+ await waitFor(() => {
167
+ expect(marker!.getBoundingClientRect().top).toBeLessThan(markerTopBefore);
168
+ });
169
+
170
+ // The chrome outside PageShell never moved — PageShell owns its own scroll.
171
+ await expect(topbar!.getBoundingClientRect().top).toBe(topbarTopBefore);
172
+ },
173
+ };
174
+
175
+ /**
176
+ * `scroll="fill"` fills its parent and scrolls nothing itself — a child (here
177
+ * an inner list) owns the overflow instead.
178
+ */
179
+ export const ScrollFill: Story = {
180
+ render: function ScrollFillStory() {
181
+ return (
182
+ <div className="h-[320px] border border-border">
183
+ <PageShell
184
+ scroll="fill"
185
+ data-testid="scroll-fill-shell"
186
+ contentClassName="flex h-full min-h-0 flex-col"
187
+ >
188
+ <div
189
+ data-testid="inner-scroll"
190
+ // The inner list is the scroll port here (`scroll="fill"` means
191
+ // PageShell itself owns none of it), so THIS element needs the
192
+ // keyboard-operable fix (WCAG 2.1.1, axe
193
+ // `scrollable-region-focusable`) — same precedent as `DialogBody`.
194
+ tabIndex={0}
195
+ className="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border bg-card p-2 focus-ring"
196
+ >
197
+ {Array.from({ length: 40 }, (_, i) => (
198
+ <div
199
+ key={i}
200
+ data-testid={i === 20 ? "marker-row" : undefined}
201
+ className="rounded-md bg-surface-muted p-2 text-body"
202
+ >
203
+ Row {i + 1}
204
+ </div>
205
+ ))}
206
+ </div>
207
+ </PageShell>
208
+ </div>
209
+ );
210
+ },
211
+ play: async ({ canvasElement }) => {
212
+ const shell = canvasElement.querySelector<HTMLElement>('[data-testid="scroll-fill-shell"]');
213
+ const inner = canvasElement.querySelector<HTMLElement>('[data-testid="inner-scroll"]');
214
+ const marker = canvasElement.querySelector<HTMLElement>('[data-testid="marker-row"]');
215
+
216
+ await expect(shell).not.toBeNull();
217
+ await expect(inner).not.toBeNull();
218
+ await expect(marker).not.toBeNull();
219
+
220
+ // PageShell itself does not scroll — the inner list owns the overflow.
221
+ await expect(getComputedStyle(shell!).overflow).toBe("hidden");
222
+ await expect(getComputedStyle(inner!).overflowY).toBe("auto");
223
+
224
+ const markerTopBefore = marker!.getBoundingClientRect().top;
225
+ inner!.scrollTop = 300;
226
+ await waitFor(() => {
227
+ expect(marker!.getBoundingClientRect().top).toBeLessThan(markerTopBefore);
228
+ });
229
+ },
230
+ };
231
+
232
+ /**
233
+ * `headerGutter` reserves a fixed-height header row so a page title lands at
234
+ * the same vertical coordinate on every route that turns it on.
235
+ */
236
+ export const WithHeaderGutter: Story = {
237
+ render: () => (
238
+ <PageShell headerGutter header={<h1 className="text-title font-medium">Pipeline runs</h1>}>
239
+ <div className="rounded-xl border bg-card p-6 text-body text-muted-foreground">Content</div>
240
+ </PageShell>
241
+ ),
242
+ play: async ({ canvasElement }) => {
243
+ const gutter = canvasElement.querySelector<HTMLElement>('[data-slot="page-shell-header"]');
244
+ await expect(gutter).not.toBeNull();
245
+ await expect(getComputedStyle(gutter!).minHeight).toBe("48px");
246
+ },
247
+ };
248
+
249
+ /**
250
+ * The reserved row renders even on a route with no header at all — an empty
251
+ * band, not a missing one — which is what keeps titles aligned across routes
252
+ * that do and don't have one (ADR 0035).
253
+ */
254
+ export const WithHeaderGutterNoHeader: Story = {
255
+ render: () => (
256
+ <PageShell headerGutter>
257
+ <div className="rounded-xl border bg-card p-6 text-body text-muted-foreground">
258
+ This route has no header, but the reserved row above still holds its space.
259
+ </div>
260
+ </PageShell>
261
+ ),
262
+ play: async ({ canvasElement }) => {
263
+ const gutter = canvasElement.querySelector<HTMLElement>('[data-slot="page-shell-header"]');
264
+ await expect(gutter).not.toBeNull();
265
+ await expect(gutter!.textContent).toBe("");
266
+ await expect(getComputedStyle(gutter!).minHeight).toBe("48px");
267
+ },
268
+ };
@@ -1,7 +1,18 @@
1
+ import { createRef } from "react";
1
2
  import { describe, expect, it } from "vitest";
2
3
  import { render, screen } from "@testing-library/react";
3
4
  import { PageShell } from "./page-shell";
4
5
 
6
+ /** True if any element in the tree carries a `min-h-*` utility class. */
7
+ function hasAnyMinHeightClass(root: HTMLElement): boolean {
8
+ const all = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
9
+ return all.some((el) => /(^|\s)min-h-\S/.test(el.className));
10
+ }
11
+
12
+ const HEADER_VARIANTS = [undefined, "default", "toolbar"] as const;
13
+ const WIDTHS = [undefined, "md", "lg", "xl", "full"] as const;
14
+ const HEADERS = [false, true] as const;
15
+
5
16
  describe("PageShell", () => {
6
17
  it("headerVariant omitted: renders the header inline with no sticky wrapper (AC2 — byte-identical default)", () => {
7
18
  render(<PageShell header={<div data-testid="hdr">H</div>}>body</PageShell>);
@@ -54,4 +65,222 @@ describe("PageShell", () => {
54
65
 
55
66
  expect(document.querySelector('[data-slot="page-shell-toolbar-header"]')).toBeNull();
56
67
  });
68
+
69
+ describe("component-API baseline (forwardRef, spread, data-slot)", () => {
70
+ it("forwards a ref to the root element", () => {
71
+ const ref = createRef<HTMLDivElement>();
72
+ const { container } = render(<PageShell ref={ref}>body</PageShell>);
73
+
74
+ expect(ref.current).not.toBeNull();
75
+ expect(ref.current).toBe(container.firstElementChild);
76
+ });
77
+
78
+ it("spreads extra DOM props (id, data-testid, aria-label) onto the root", () => {
79
+ render(
80
+ <PageShell id="my-page" data-testid="ps-root" aria-label="Runs">
81
+ body
82
+ </PageShell>,
83
+ );
84
+
85
+ const root = screen.getByTestId("ps-root");
86
+ expect(root.id).toBe("my-page");
87
+ expect(root.getAttribute("aria-label")).toBe("Runs");
88
+ });
89
+
90
+ it('carries data-slot="page-shell" on the root and "page-shell-content" on the inner column', () => {
91
+ const { container } = render(<PageShell>body</PageShell>);
92
+ const root = container.firstElementChild as HTMLElement;
93
+
94
+ expect(root.getAttribute("data-slot")).toBe("page-shell");
95
+ expect(root.querySelector('[data-slot="page-shell-content"]')).not.toBeNull();
96
+ });
97
+ });
98
+
99
+ describe('scroll="body" (default) — byte-identical to pre-R6, across every header/headerVariant/width combination', () => {
100
+ for (const headerVariant of HEADER_VARIANTS) {
101
+ for (const width of WIDTHS) {
102
+ for (const withHeader of HEADERS) {
103
+ const label = `header=${withHeader} headerVariant=${headerVariant ?? "(unset)"} width=${width ?? "(unset)"}`;
104
+
105
+ it(`root class is exactly the pre-R6 literal, no min-h-* anywhere, no gutter row — ${label}`, () => {
106
+ const { container } = render(
107
+ <PageShell
108
+ header={withHeader ? <div data-testid="hdr">H</div> : undefined}
109
+ headerVariant={headerVariant}
110
+ width={width}
111
+ >
112
+ body
113
+ </PageShell>,
114
+ );
115
+ const root = container.firstElementChild as HTMLElement;
116
+
117
+ // (1) The additive default: root's own class string is unchanged, whatever
118
+ // the header/headerVariant/width combination — those axes never touch it.
119
+ expect(root.className).toBe("w-full px-4 py-6 sm:px-6 lg:px-8");
120
+
121
+ // (2) No min-h-* class anywhere in the tree — the gutter mechanism is
122
+ // entirely absent, not merely invisible.
123
+ expect(hasAnyMinHeightClass(root)).toBe(false);
124
+
125
+ // (3) No gutter row — `headerGutter: false` must not produce the empty
126
+ // reserved row ADR 0035 calls "the whole mechanism".
127
+ expect(root.querySelector('[data-slot="page-shell-header"]')).toBeNull();
128
+
129
+ // (4) The header itself renders exactly where it always did.
130
+ if (withHeader) {
131
+ const hdr = screen.getByTestId("hdr");
132
+ if (headerVariant === "toolbar") {
133
+ const toolbarWrapper = root.querySelector(
134
+ '[data-slot="page-shell-toolbar-header"]',
135
+ );
136
+ expect(toolbarWrapper).not.toBeNull();
137
+ expect(toolbarWrapper).toContainElement(hdr);
138
+ } else {
139
+ expect(root.querySelector('[data-slot="page-shell-toolbar-header"]')).toBeNull();
140
+ }
141
+ }
142
+ });
143
+ }
144
+ }
145
+ }
146
+
147
+ it('passing scroll="body" explicitly is identical to omitting it', () => {
148
+ const { container: withProp } = render(<PageShell scroll="body">body</PageShell>);
149
+ const { container: omitted } = render(<PageShell>body</PageShell>);
150
+
151
+ expect((withProp.firstElementChild as HTMLElement).className).toBe(
152
+ (omitted.firstElementChild as HTMLElement).className,
153
+ );
154
+ });
155
+ });
156
+
157
+ describe("scroll modes", () => {
158
+ it('scroll="content" makes the inner column the scroll container', () => {
159
+ const { container } = render(<PageShell scroll="content">body</PageShell>);
160
+ const root = container.firstElementChild as HTMLElement;
161
+ expect(root.className).toContain("min-h-0");
162
+ expect(root.className).toContain("flex-1");
163
+ expect(root.className).toContain("overflow-y-auto");
164
+ // The base padding/width classes are still present — additive, not a replacement.
165
+ expect(root.className).toContain("w-full");
166
+ });
167
+
168
+ it('scroll="fill" fills its parent and does not scroll itself', () => {
169
+ const { container } = render(<PageShell scroll="fill">body</PageShell>);
170
+ const root = container.firstElementChild as HTMLElement;
171
+ expect(root.className).toContain("h-full");
172
+ expect(root.className).toContain("overflow-hidden");
173
+ });
174
+
175
+ it("a caller className can still override a scroll-mode utility (className merges last)", () => {
176
+ const { container } = render(
177
+ <PageShell scroll="fill" className="overflow-visible">
178
+ body
179
+ </PageShell>,
180
+ );
181
+ const root = container.firstElementChild as HTMLElement;
182
+ expect(root.className).toContain("overflow-visible");
183
+ expect(root.className).not.toContain("overflow-hidden");
184
+ });
185
+
186
+ it('scroll="content" defaults to keyboard-operable (WCAG 2.1.1 / axe scrollable-region-focusable)', () => {
187
+ const { container } = render(<PageShell scroll="content">body</PageShell>);
188
+ const root = container.firstElementChild as HTMLElement;
189
+ // `getAttribute`, never the `tabIndex` IDL getter: that getter answers a
190
+ // DEFAULT (-1 here, 0 for natively focusable elements) whether or not the
191
+ // attribute was ever written, so `expect(root.tabIndex).toBe(-1)` and the
192
+ // "gains no tab stop" cases below would pass on a component that emits
193
+ // nothing at all.
194
+ expect(root.getAttribute("tabindex")).toBe("0");
195
+ // `classList.contains` is an EXACT token match. `toContain("focus-ring")`
196
+ // is a substring test that `focus-ring-inset` satisfies, so it could not
197
+ // tell the two rungs apart — it passed before this fix and would pass
198
+ // after it, proving nothing either way.
199
+ expect(root.classList.contains("focus-ring-inset")).toBe(true);
200
+ // The plain rung must be ABSENT, not merely accompanied: both of its
201
+ // layers are drawn outside the element's box, and `scroll="content"`'s
202
+ // own doc sends callers to put this inside a `SidebarInset`, whose
203
+ // `overflow-hidden` clips exactly that.
204
+ expect(root.classList.contains("focus-ring")).toBe(false);
205
+ });
206
+
207
+ it('scroll="content" lets a caller opt out of the default tab stop', () => {
208
+ const { container } = render(
209
+ <PageShell scroll="content" tabIndex={-1}>
210
+ body
211
+ </PageShell>,
212
+ );
213
+ const root = container.firstElementChild as HTMLElement;
214
+ expect(root.getAttribute("tabindex")).toBe("-1");
215
+ });
216
+
217
+ it('scroll="fill" does not gain a default tab stop — the child scroll region owns it', () => {
218
+ const { container } = render(<PageShell scroll="fill">body</PageShell>);
219
+ const root = container.firstElementChild as HTMLElement;
220
+ expect(root.getAttribute("tabindex")).toBeNull();
221
+ expect(root.classList.contains("focus-ring")).toBe(false);
222
+ expect(root.classList.contains("focus-ring-inset")).toBe(false);
223
+ });
224
+
225
+ it('scroll="body" (default) gains no tabIndex attribute — byte-identical', () => {
226
+ const { container } = render(<PageShell>body</PageShell>);
227
+ const root = container.firstElementChild as HTMLElement;
228
+ expect(root.getAttribute("tabindex")).toBeNull();
229
+ });
230
+ });
231
+
232
+ describe("headerGutter", () => {
233
+ it("reserves a fixed header row so titles land at one coordinate, when a header is present", () => {
234
+ render(
235
+ <PageShell headerGutter header={<h1>Runs</h1>}>
236
+ body
237
+ </PageShell>,
238
+ );
239
+ const gutter = screen.getByText("Runs").closest('[data-slot="page-shell-header"]');
240
+ expect(gutter).not.toBeNull();
241
+ expect((gutter as HTMLElement).className).toContain("min-h-(--page-shell-header-gutter)");
242
+ });
243
+
244
+ it("renders the reserved row even when NO header is supplied — the empty row IS the mechanism (ADR 0035)", () => {
245
+ const { container } = render(<PageShell headerGutter>body</PageShell>);
246
+ const root = container.firstElementChild as HTMLElement;
247
+
248
+ const gutter = root.querySelector('[data-slot="page-shell-header"]');
249
+ expect(gutter).not.toBeNull();
250
+ expect((gutter as HTMLElement).className).toContain("min-h-(--page-shell-header-gutter)");
251
+ // It really is empty — no header content leaked in.
252
+ expect((gutter as HTMLElement).textContent).toBe("");
253
+ });
254
+
255
+ it("declares the --page-shell-header-gutter default (--spacing(12)) on the root when on", () => {
256
+ const { container } = render(<PageShell headerGutter>body</PageShell>);
257
+ const root = container.firstElementChild as HTMLElement;
258
+ expect(root.className).toContain("[--page-shell-header-gutter:--spacing(12)]");
259
+ });
260
+
261
+ it("is retunable per surface: a caller className overrides the default gutter height", () => {
262
+ const { container } = render(
263
+ <PageShell headerGutter className="[--page-shell-header-gutter:--spacing(16)]">
264
+ body
265
+ </PageShell>,
266
+ );
267
+ const root = container.firstElementChild as HTMLElement;
268
+ expect(root.className).toContain("[--page-shell-header-gutter:--spacing(16)]");
269
+ expect(root.className).not.toContain("--spacing(12)");
270
+ });
271
+
272
+ it('composes with headerVariant="toolbar": the sticky bar nests inside the reserved row', () => {
273
+ render(
274
+ <PageShell headerGutter headerVariant="toolbar" header={<div data-testid="hdr">H</div>}>
275
+ body
276
+ </PageShell>,
277
+ );
278
+ const gutter = document.querySelector('[data-slot="page-shell-header"]');
279
+ const toolbar = document.querySelector('[data-slot="page-shell-toolbar-header"]');
280
+ expect(gutter).not.toBeNull();
281
+ expect(toolbar).not.toBeNull();
282
+ expect(gutter).toContainElement(toolbar as HTMLElement);
283
+ expect(toolbar).toContainElement(screen.getByTestId("hdr"));
284
+ });
285
+ });
57
286
  });
@@ -1,7 +1,7 @@
1
- import { type ReactNode } from "react";
1
+ import { forwardRef, type ComponentProps, type ReactNode } from "react";
2
2
  import { cn } from "../../lib/cn";
3
3
 
4
- export interface PageShellProps {
4
+ export interface PageShellProps extends ComponentProps<"div"> {
5
5
  children: ReactNode;
6
6
  /** Optional header row (often a <SectionHeader />, or a <ViewToolbar> — see headerVariant). */
7
7
  header?: ReactNode;
@@ -20,6 +20,37 @@ export interface PageShellProps {
20
20
  headerVariant?: "default" | "toolbar";
21
21
  /** Constrain content width. Defaults to "xl". */
22
22
  width?: "md" | "lg" | "xl" | "full";
23
+ /**
24
+ * "body" (default) — the page scrolls with the document. Byte-identical to
25
+ * the pre-#R6 behaviour; existing callers are unaffected.
26
+ * "content" — this container is the scroll port. Use inside a `SidebarInset`
27
+ * whose height is already bounded, so the top bar stays put. Defaults its own
28
+ * `tabIndex` to `0` (a scroll container must be keyboard-operable — WCAG
29
+ * 2.1.1, axe `scrollable-region-focusable`), following the `DialogBody`
30
+ * precedent; pass `tabIndex={-1}` to opt out when the content already
31
+ * supplies its own focusable child.
32
+ * "fill" — fills its parent and scrolls nothing itself; a child (a table, a
33
+ * canvas) owns the scrolling — and owns that child's keyboard reachability
34
+ * too, the same way.
35
+ */
36
+ scroll?: "body" | "content" | "fill";
37
+ /**
38
+ * Reserve a fixed header row so a page title lands at identical coordinates
39
+ * on every route, whether or not that route has a header. Default `false`.
40
+ *
41
+ * The row's height is a component-local CSS variable —
42
+ * `--page-shell-header-gutter`, declared on the root, default `--spacing(12)`
43
+ * (3rem / 48px at a 16px root — the height a one-line title already occupies
44
+ * inside the `headerVariant="toolbar"` bar's own `py-3`). It is NOT a theme
45
+ * token (`pnpm theme-parity:check` does not see it); retune it per surface the
46
+ * same way `--focus-ring-color` is retargeted elsewhere in this package:
47
+ * `<PageShell headerGutter className="[--page-shell-header-gutter:--spacing(16)]" />`.
48
+ *
49
+ * When `true` the header row renders **even if `header` is omitted** — that
50
+ * empty row is the whole mechanism, and it is what `headerGutter={false}`
51
+ * must never produce.
52
+ */
53
+ headerGutter?: boolean;
23
54
  className?: string;
24
55
  contentClassName?: string;
25
56
  }
@@ -31,32 +62,84 @@ const widthMap = {
31
62
  full: "max-w-none",
32
63
  } as const;
33
64
 
65
+ const scrollMap = {
66
+ body: "",
67
+ content: "min-h-0 flex-1 overflow-y-auto",
68
+ fill: "h-full min-h-0 overflow-hidden",
69
+ } as const;
70
+
34
71
  /** Page-level content container with consistent padding and max width. */
35
- export function PageShell({
36
- children,
37
- header,
38
- headerVariant = "default",
39
- width = "xl",
40
- className,
41
- contentClassName,
42
- }: PageShellProps) {
72
+ export const PageShell = forwardRef<HTMLDivElement, PageShellProps>(function PageShell(
73
+ {
74
+ children,
75
+ header,
76
+ headerVariant = "default",
77
+ width = "xl",
78
+ scroll = "body",
79
+ headerGutter = false,
80
+ className,
81
+ contentClassName,
82
+ tabIndex,
83
+ ...props
84
+ },
85
+ ref,
86
+ ) {
87
+ // scroll="content" makes this element the scroll port, so it needs the same
88
+ // keyboard-operable fix `DialogBody`/`ExpandDialogPanes` ship: default the
89
+ // tab stop to 0, let a caller opt out with an explicit `tabIndex` (typically
90
+ // `-1`). "body"/"fill" never set a default, so their DOM gains no attribute.
91
+ const resolvedTabIndex = tabIndex ?? (scroll === "content" ? 0 : undefined);
92
+
93
+ const headerNode = header ? (
94
+ headerVariant === "toolbar" ? (
95
+ <div
96
+ data-slot="page-shell-toolbar-header"
97
+ className="sticky top-0 z-10 border-b border-border bg-background/95 py-3 backdrop-blur"
98
+ >
99
+ {header}
100
+ </div>
101
+ ) : (
102
+ header
103
+ )
104
+ ) : null;
105
+
43
106
  return (
44
- <div className={cn("w-full px-4 py-6 sm:px-6 lg:px-8", className)}>
45
- <div className={cn("mx-auto w-full space-y-6", widthMap[width], contentClassName)}>
46
- {header ? (
47
- headerVariant === "toolbar" ? (
48
- <div
49
- data-slot="page-shell-toolbar-header"
50
- className="sticky top-0 z-10 border-b border-border bg-background/95 py-3 backdrop-blur"
51
- >
52
- {header}
53
- </div>
54
- ) : (
55
- header
56
- )
57
- ) : null}
107
+ <div
108
+ ref={ref}
109
+ data-slot="page-shell"
110
+ tabIndex={resolvedTabIndex}
111
+ className={cn(
112
+ "w-full px-4 py-6 sm:px-6 lg:px-8",
113
+ scrollMap[scroll],
114
+ // `focus-ring-inset`, NOT `focus-ring`: `scroll="content"`'s own doc
115
+ // above tells the caller to use it inside a `SidebarInset`, whose
116
+ // `overflow-hidden` clips both layers of the plain rung — they are
117
+ // drawn OUTSIDE the element's box. A consumer following that
118
+ // instruction would get a focusable scroll port with no visible focus
119
+ // indicator at all. Same reasoning, verbatim, as the app-shell blocks'
120
+ // own scroll ports.
121
+ scroll === "content" && "focus-ring-inset",
122
+ headerGutter && "[--page-shell-header-gutter:--spacing(12)]",
123
+ className,
124
+ )}
125
+ {...props}
126
+ >
127
+ <div
128
+ data-slot="page-shell-content"
129
+ className={cn("mx-auto w-full space-y-6", widthMap[width], contentClassName)}
130
+ >
131
+ {headerGutter ? (
132
+ <div
133
+ data-slot="page-shell-header"
134
+ className="flex min-h-(--page-shell-header-gutter) items-center"
135
+ >
136
+ {headerNode}
137
+ </div>
138
+ ) : (
139
+ headerNode
140
+ )}
58
141
  {children}
59
142
  </div>
60
143
  </div>
61
144
  );
62
- }
145
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { LocaleProvider } from "../locale-provider";
4
+ import { PaginationEllipsis, PaginationNext, PaginationPrevious } from "./pagination";
5
+
6
+ /**
7
+ * Pagination had no test file before #18 — its three microcopy sites
8
+ * (Previous/Next reuse the generic keys, the ellipsis's sr-only "More pages"
9
+ * is a new namespaced key) are locked here.
10
+ */
11
+ describe("Pagination — microcopy (#18)", () => {
12
+ it("PaginationPrevious/PaginationNext keep their shipped English labels", () => {
13
+ render(
14
+ <>
15
+ <PaginationPrevious />
16
+ <PaginationNext />
17
+ </>,
18
+ );
19
+ expect(screen.getByText("Previous")).toBeInTheDocument();
20
+ expect(screen.getByText("Next")).toBeInTheDocument();
21
+ });
22
+
23
+ it("PaginationEllipsis keeps its shipped sr-only 'More pages' text", () => {
24
+ render(<PaginationEllipsis />);
25
+ expect(screen.getByText("More pages")).toHaveClass("sr-only");
26
+ });
27
+
28
+ it("translates all three via a LocaleProvider (generic + namespaced keys)", () => {
29
+ render(
30
+ <LocaleProvider
31
+ locale="de-DE"
32
+ messages={{
33
+ previous: "Zurück",
34
+ next: "Weiter",
35
+ "ui.pagination.morePages": "Weitere Seiten",
36
+ }}
37
+ >
38
+ <PaginationPrevious />
39
+ <PaginationNext />
40
+ <PaginationEllipsis />
41
+ </LocaleProvider>,
42
+ );
43
+ expect(screen.getByText("Zurück")).toBeInTheDocument();
44
+ expect(screen.getByText("Weiter")).toBeInTheDocument();
45
+ expect(screen.getByText("Weitere Seiten")).toHaveClass("sr-only");
46
+ });
47
+
48
+ // #12/#53 review (P2): the `aria-label` overrides visible text content as
49
+ // the ACCESSIBLE NAME, so a non-English `LocaleProvider` that only
50
+ // translated the visible `previous`/`next` span left screen-reader users
51
+ // hearing hardcoded English regardless of locale.
52
+ describe("accessible name (aria-label) is localized, not just the visible text", () => {
53
+ it("defaults to the shipped English accessible names", () => {
54
+ render(
55
+ <>
56
+ <PaginationPrevious />
57
+ <PaginationNext />
58
+ </>,
59
+ );
60
+ expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument();
61
+ expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
62
+ });
63
+
64
+ it("routes the aria-label through t() under a non-English LocaleProvider", () => {
65
+ render(
66
+ <LocaleProvider
67
+ locale="de-DE"
68
+ messages={{
69
+ previous: "Zurück",
70
+ next: "Weiter",
71
+ "ui.pagination.previous": "Zur vorherigen Seite",
72
+ "ui.pagination.next": "Zur nächsten Seite",
73
+ }}
74
+ >
75
+ <PaginationPrevious />
76
+ <PaginationNext />
77
+ </LocaleProvider>,
78
+ );
79
+ const prev = screen.getByLabelText("Zur vorherigen Seite");
80
+ const next = screen.getByLabelText("Zur nächsten Seite");
81
+ // The visible text is translated too — the accessible name isn't just
82
+ // masking untranslated visible content.
83
+ expect(prev).toHaveTextContent("Zurück");
84
+ expect(next).toHaveTextContent("Weiter");
85
+ // Neither link's accessible name is the stale hardcoded English string.
86
+ expect(screen.queryByLabelText("Go to previous page")).not.toBeInTheDocument();
87
+ expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
88
+ });
89
+ });
90
+ });