@tangle-network/ui 11.12.0 → 11.13.1

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 (276) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/_virtual/_rolldown/runtime.js +13 -0
  3. package/dist/auth/auth.d.ts +53 -0
  4. package/dist/{auth-DOETfnd9.js → auth/auth.js} +7 -37
  5. package/dist/auth/login-layout.d.ts +20 -0
  6. package/dist/auth/login-layout.js +36 -0
  7. package/dist/auth.d.ts +2 -1
  8. package/dist/auth.js +2 -1
  9. package/dist/chat/agent-timeline.d.ts +83 -0
  10. package/dist/chat/agent-timeline.js +236 -0
  11. package/dist/chat/chat-container.d.ts +51 -0
  12. package/dist/chat/chat-container.js +309 -0
  13. package/dist/chat/chat-message.d.ts +22 -0
  14. package/dist/chat/chat-message.js +38 -0
  15. package/dist/chat/message-list.d.ts +32 -0
  16. package/dist/chat/message-list.js +37 -0
  17. package/dist/chat/thinking-indicator.d.ts +6 -0
  18. package/dist/chat/thinking-indicator.js +38 -0
  19. package/dist/chat/user-message.d.ts +21 -0
  20. package/dist/chat/user-message.js +42 -0
  21. package/dist/chat.d.ts +6 -1
  22. package/dist/chat.js +6 -1
  23. package/dist/editor/document-editor-pane.d.ts +37 -0
  24. package/dist/editor/document-editor-pane.js +199 -0
  25. package/dist/editor/editor-lazy.js +37 -0
  26. package/dist/editor/editor-loading.js +21 -0
  27. package/dist/editor/editor-peers.d.ts +6 -0
  28. package/dist/editor/editor-peers.js +155 -0
  29. package/dist/editor/editor-provider.d.ts +97 -0
  30. package/dist/editor/editor-provider.js +253 -0
  31. package/dist/editor/editor-toolbar.d.ts +11 -0
  32. package/dist/editor/editor-toolbar.js +106 -0
  33. package/dist/editor/markdown-conversion.js +23 -0
  34. package/dist/editor/markdown-document-editor.js +103 -0
  35. package/dist/editor/tiptap-editor.d.ts +45 -0
  36. package/dist/editor/tiptap-editor.js +271 -0
  37. package/dist/editor/use-editor.d.ts +67 -0
  38. package/dist/editor/use-editor.js +158 -0
  39. package/dist/editor.d.ts +6 -119
  40. package/dist/editor.js +5 -1
  41. package/dist/files/file-artifact-pane.d.ts +47 -0
  42. package/dist/files/file-artifact-pane.js +114 -0
  43. package/dist/files/file-format.d.ts +51 -0
  44. package/dist/{file-format-BgXbr-Ib.js → files/file-format.js} +1 -1
  45. package/dist/files/file-preview.d.ts +27 -0
  46. package/dist/files/file-preview.js +359 -0
  47. package/dist/files/file-tabs.d.ts +19 -0
  48. package/dist/files/file-tabs.js +48 -0
  49. package/dist/files/file-tree.d.ts +31 -0
  50. package/dist/files/file-tree.js +135 -0
  51. package/dist/files/rich-file-tree.d.ts +62 -0
  52. package/dist/files/rich-file-tree.js +122 -0
  53. package/dist/files.d.ts +6 -1
  54. package/dist/files.js +6 -2
  55. package/dist/hooks/use-auth.d.ts +40 -0
  56. package/dist/hooks/use-auth.js +91 -0
  57. package/dist/hooks/use-auto-scroll.d.ts +11 -0
  58. package/dist/hooks/use-auto-scroll.js +48 -0
  59. package/dist/hooks/use-dropdown-menu.d.ts +12 -0
  60. package/dist/hooks/use-dropdown-menu.js +31 -0
  61. package/dist/hooks/use-live-time.d.ts +7 -0
  62. package/dist/hooks/use-live-time.js +22 -0
  63. package/dist/hooks/use-realtime-session.d.ts +32 -0
  64. package/dist/hooks/use-realtime-session.js +235 -0
  65. package/dist/hooks/use-run-collapse-state.d.ts +13 -0
  66. package/dist/hooks/use-run-collapse-state.js +24 -0
  67. package/dist/hooks/use-run-groups.d.ts +11 -0
  68. package/dist/hooks/use-run-groups.js +85 -0
  69. package/dist/hooks/use-sdk-session.d.ts +59 -0
  70. package/dist/hooks/use-sdk-session.js +322 -0
  71. package/dist/hooks/use-sse-stream.d.ts +124 -0
  72. package/dist/hooks/use-sse-stream.js +229 -0
  73. package/dist/hooks/use-tool-call-stream.d.ts +23 -0
  74. package/dist/hooks/use-tool-call-stream.js +75 -0
  75. package/dist/hooks.d.ts +11 -49
  76. package/dist/hooks.js +10 -110
  77. package/dist/index.d.ts +99 -25
  78. package/dist/index.js +86 -36
  79. package/dist/lib/focus.d.ts +55 -0
  80. package/dist/{focus-BcE_SHqz.js → lib/focus.js} +1 -1
  81. package/dist/lib/utils.d.ts +4 -0
  82. package/dist/{utils-C_uf36nf.js → lib/utils.js} +1 -1
  83. package/dist/{code-block-B9miUPht.d.ts → markdown/code-block.d.ts} +5 -6
  84. package/dist/{code-block-B36KVUSN.js → markdown/code-block.js} +60 -55
  85. package/dist/{markdown-C0atSabr.d.ts → markdown/markdown.d.ts} +4 -5
  86. package/dist/{markdown-Db08KAYF.js → markdown/markdown.js} +3 -3
  87. package/dist/markdown.d.ts +2 -2
  88. package/dist/markdown.js +2 -2
  89. package/dist/{openui-artifact-renderer-fkBQaoLO.d.ts → openui/openui-artifact-renderer.d.ts} +20 -21
  90. package/dist/{openui-artifact-renderer-DG_i2lOR.js → openui/openui-artifact-renderer.js} +9 -7
  91. package/dist/{schema-BpW9rSSG.js → openui/schema-compiled.js} +2 -354
  92. package/dist/{schema-DAstk-J4.d.ts → openui/schema-data.d.ts} +3 -24
  93. package/dist/openui/schema-data.js +206 -0
  94. package/dist/openui-schema.d.ts +22 -3
  95. package/dist/openui-schema.js +138 -1
  96. package/dist/openui.d.ts +3 -2
  97. package/dist/openui.js +3 -2
  98. package/dist/{artifact-pane-BL_DFWNl.d.ts → primitives/artifact-pane.d.ts} +3 -4
  99. package/dist/{artifact-pane-DMXT9a8U.js → primitives/artifact-pane.js} +2 -2
  100. package/dist/primitives/avatar.d.ts +8 -0
  101. package/dist/primitives/avatar.js +25 -0
  102. package/dist/primitives/badge.d.ts +22 -0
  103. package/dist/primitives/badge.js +47 -0
  104. package/dist/{button-yoIv77e2.d.ts → primitives/button.d.ts} +2 -2
  105. package/dist/primitives/button.js +113 -0
  106. package/dist/primitives/card.d.ts +18 -0
  107. package/dist/primitives/card.js +52 -0
  108. package/dist/primitives/code-block.d.ts +6 -0
  109. package/dist/primitives/code-block.js +13 -0
  110. package/dist/primitives/dialog.d.ts +23 -0
  111. package/dist/primitives/dialog.js +59 -0
  112. package/dist/primitives/drop-zone.d.ts +23 -0
  113. package/dist/primitives/drop-zone.js +104 -0
  114. package/dist/primitives/dropdown-menu.d.ts +29 -0
  115. package/dist/{dropdown-menu-BpcLa5vF.js → primitives/dropdown-menu.js} +2 -23
  116. package/dist/primitives/empty-state.d.ts +11 -0
  117. package/dist/primitives/empty-state.js +32 -0
  118. package/dist/primitives/heading.d.ts +23 -0
  119. package/dist/primitives/heading.js +38 -0
  120. package/dist/primitives/input.d.ts +23 -0
  121. package/dist/primitives/input.js +110 -0
  122. package/dist/primitives/label.d.ts +6 -0
  123. package/dist/primitives/label.js +16 -0
  124. package/dist/primitives/metric-strip.d.ts +48 -0
  125. package/dist/primitives/metric-strip.js +52 -0
  126. package/dist/primitives/page-header.d.ts +25 -0
  127. package/dist/primitives/page-header.js +47 -0
  128. package/dist/primitives/page-shell.d.ts +14 -0
  129. package/dist/primitives/page-shell.js +17 -0
  130. package/dist/primitives/progress.d.ts +9 -0
  131. package/dist/primitives/progress.js +29 -0
  132. package/dist/primitives/segmented-control.d.ts +40 -0
  133. package/dist/primitives/segmented-control.js +57 -0
  134. package/dist/primitives/select.d.ts +15 -0
  135. package/dist/primitives/select.js +74 -0
  136. package/dist/primitives/sidebar-drop-zone.d.ts +21 -0
  137. package/dist/primitives/sidebar-drop-zone.js +88 -0
  138. package/dist/primitives/skeleton.d.ts +10 -0
  139. package/dist/primitives/skeleton.js +50 -0
  140. package/dist/primitives/stat-card.d.ts +16 -0
  141. package/dist/primitives/stat-card.js +60 -0
  142. package/dist/primitives/status-pill.d.ts +44 -0
  143. package/dist/primitives/status-pill.js +119 -0
  144. package/dist/primitives/switch.d.ts +6 -0
  145. package/dist/primitives/switch.js +16 -0
  146. package/dist/primitives/table.d.ts +22 -0
  147. package/dist/primitives/table.js +63 -0
  148. package/dist/primitives/tabs.d.ts +13 -0
  149. package/dist/{tabs-BVHR6NkO.js → primitives/tabs.js} +3 -3
  150. package/dist/primitives/terminal-display.d.ts +25 -0
  151. package/dist/primitives/terminal-display.js +109 -0
  152. package/dist/primitives/text-shimmer.d.ts +16 -0
  153. package/dist/{text-shimmer-BUUo6I-g.js → primitives/text-shimmer.js} +2 -2
  154. package/dist/primitives/theme-toggle.d.ts +11 -0
  155. package/dist/primitives/theme-toggle.js +154 -0
  156. package/dist/primitives/toast.d.ts +29 -0
  157. package/dist/primitives/toast.js +142 -0
  158. package/dist/primitives/toolbar.d.ts +55 -0
  159. package/dist/primitives/toolbar.js +37 -0
  160. package/dist/primitives/upload-progress.d.ts +23 -0
  161. package/dist/primitives/upload-progress.js +72 -0
  162. package/dist/primitives.d.ts +35 -5
  163. package/dist/primitives.js +35 -9
  164. package/dist/{index-COgqsFAQ.d.ts → redaction/redacted-document.d.ts} +6 -7
  165. package/dist/{redaction-CGGB2ENG.js → redaction/redacted-document.js} +2 -2
  166. package/dist/redaction.d.ts +1 -1
  167. package/dist/redaction.js +1 -1
  168. package/dist/run/expandable-content.js +41 -0
  169. package/dist/run/expanded-tool-detail.d.ts +12 -0
  170. package/dist/run/expanded-tool-detail.js +163 -0
  171. package/dist/run/inline-thinking-item.d.ts +11 -0
  172. package/dist/run/inline-thinking-item.js +60 -0
  173. package/dist/run/inline-tool-item.d.ts +25 -0
  174. package/dist/run/inline-tool-item.js +63 -0
  175. package/dist/run/run-group.d.ts +26 -0
  176. package/dist/{run-group-BWGeesgs.js → run/run-group.js} +9 -60
  177. package/dist/run/run-item-primitives.d.ts +5 -0
  178. package/dist/{run-ZsIKPA6J.js → run/run-item-primitives.js} +2 -2
  179. package/dist/run/run-row-shell.d.ts +53 -0
  180. package/dist/run/run-row-shell.js +114 -0
  181. package/dist/{tool-call-feed-BeB1_4pX.d.ts → run/tool-call-feed.d.ts} +7 -12
  182. package/dist/{tool-call-feed-BpmMLpPX.js → run/tool-call-feed.js} +4 -4
  183. package/dist/run/tool-call-step.d.ts +5 -0
  184. package/dist/run/tool-call-step.js +82 -0
  185. package/dist/run.d.ts +8 -2
  186. package/dist/run.js +7 -4
  187. package/dist/sdk-hooks.d.ts +8 -1
  188. package/dist/sdk-hooks.js +8 -2
  189. package/dist/stores/active-sessions-store.d.ts +83 -0
  190. package/dist/{active-sessions-store-Gt1R0rFG.js → stores/active-sessions-store.js} +1 -1
  191. package/dist/stores/chat-store.d.ts +14 -0
  192. package/dist/stores/chat-store.js +33 -0
  193. package/dist/stores.d.ts +3 -16
  194. package/dist/stores.js +2 -33
  195. package/dist/tool-previews/command-preview.d.ts +11 -0
  196. package/dist/tool-previews/command-preview.js +91 -0
  197. package/dist/tool-previews/diff-preview.d.ts +7 -0
  198. package/dist/tool-previews/diff-preview.js +76 -0
  199. package/dist/tool-previews/glob-results-preview.d.ts +7 -0
  200. package/dist/tool-previews/glob-results-preview.js +53 -0
  201. package/dist/tool-previews/grep-results-preview.d.ts +7 -0
  202. package/dist/tool-previews/grep-results-preview.js +99 -0
  203. package/dist/tool-previews/preview-primitives.js +58 -0
  204. package/dist/tool-previews/question-preview.d.ts +7 -0
  205. package/dist/tool-previews/question-preview.js +63 -0
  206. package/dist/tool-previews/web-search-preview.d.ts +7 -0
  207. package/dist/tool-previews/web-search-preview.js +78 -0
  208. package/dist/tool-previews/write-file-preview.d.ts +11 -0
  209. package/dist/tool-previews/write-file-preview.js +52 -0
  210. package/dist/tool-previews.d.ts +7 -1
  211. package/dist/tool-previews.js +7 -1
  212. package/dist/{branding-26d5ARIr.d.ts → types/branding.d.ts} +2 -3
  213. package/dist/{message-vhUI0c5G.d.ts → types/message.d.ts} +2 -3
  214. package/dist/{parts-5mhy9yJ3.d.ts → types/parts.d.ts} +8 -9
  215. package/dist/{run-OyWmIDqu.d.ts → types/run.d.ts} +9 -10
  216. package/dist/{tool-display-DgmL5rIU.d.ts → types/tool-display.d.ts} +5 -6
  217. package/dist/types.d.ts +5 -5
  218. package/dist/utils/copy-text.d.ts +7 -0
  219. package/dist/{utils-Bl_CZ0Qp.js → utils/copy-text.js} +1 -11
  220. package/dist/utils/format.d.ts +22 -0
  221. package/dist/{format-DfcLWqMa.js → utils/format.js} +1 -1
  222. package/dist/utils/time-ago.d.ts +3 -0
  223. package/dist/utils/time-ago.js +11 -0
  224. package/dist/utils/tool-display.d.ts +16 -0
  225. package/dist/{tool-display-BAuAVa43.js → utils/tool-display.js} +1 -1
  226. package/dist/utils.d.ts +6 -1
  227. package/dist/utils.js +6 -5
  228. package/package.json +2 -2
  229. package/src/files/rich-file-tree.stories.tsx +1 -1
  230. package/src/files/rich-file-tree.tsx +12 -9
  231. package/src/markdown/code-block.test.tsx +80 -2
  232. package/src/markdown/code-block.tsx +76 -80
  233. package/src/primitives/button.stories.tsx +37 -1
  234. package/src/primitives/button.test.tsx +230 -0
  235. package/src/primitives/button.tsx +62 -14
  236. package/src/primitives/card-anatomy.stories.tsx +51 -0
  237. package/src/primitives/card.test.tsx +71 -0
  238. package/src/primitives/card.tsx +25 -13
  239. package/src/primitives/heading.stories.tsx +35 -0
  240. package/src/primitives/heading.test.tsx +56 -0
  241. package/src/primitives/heading.tsx +76 -0
  242. package/src/primitives/index.ts +8 -0
  243. package/src/primitives/input.stories.tsx +28 -0
  244. package/src/primitives/input.test.tsx +161 -0
  245. package/src/primitives/input.tsx +34 -28
  246. package/src/primitives/page-header.stories.tsx +51 -0
  247. package/src/primitives/page-header.test.tsx +48 -0
  248. package/src/primitives/page-header.tsx +35 -35
  249. package/src/primitives/page-shell.stories.tsx +89 -0
  250. package/src/primitives/page-shell.test.tsx +24 -0
  251. package/src/primitives/page-shell.tsx +26 -0
  252. package/src/primitives/table-wrapper.stories.tsx +54 -0
  253. package/src/primitives/table.test.tsx +72 -0
  254. package/src/primitives/table.tsx +33 -12
  255. package/src/primitives/theme-toggle.test.tsx +167 -0
  256. package/src/primitives/theme-toggle.tsx +111 -39
  257. package/dist/active-sessions-store-nl70ZnMC.d.ts +0 -84
  258. package/dist/button-C87nEe2R.js +0 -78
  259. package/dist/chat-DDvF2oGQ.js +0 -672
  260. package/dist/diff-preview-BcK3nRXb.js +0 -533
  261. package/dist/document-editor-pane-BilJfiCo.d.ts +0 -136
  262. package/dist/document-editor-pane-POsq5Q0o.js +0 -1279
  263. package/dist/files-hywocVZz.js +0 -753
  264. package/dist/index-2UntyjBb.d.ts +0 -52
  265. package/dist/index-BgUHilZm.d.ts +0 -108
  266. package/dist/index-CJ8wRfiC.d.ts +0 -236
  267. package/dist/index-CPDET_s1.d.ts +0 -549
  268. package/dist/index-Cg6gYAqh.d.ts +0 -73
  269. package/dist/index-Cs7j8V7l.d.ts +0 -203
  270. package/dist/index-DjKxO357.d.ts +0 -128
  271. package/dist/primitives-Bx59fT-U.js +0 -1239
  272. package/dist/table-Bss73DhB.js +0 -146
  273. package/dist/tool-call-step-DNebqhmX.js +0 -434
  274. package/dist/use-auto-scroll-C5Pjeqon.js +0 -153
  275. package/dist/use-sse-stream-DjqRuDft.js +0 -884
  276. package/dist/use-sse-stream-Dm6R5YWz.d.ts +0 -282
@@ -0,0 +1,161 @@
1
+ import * as React from "react";
2
+ import { act, cleanup, render, screen, within } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { hydrateRoot, type Root } from "react-dom/client";
5
+ import { renderToString } from "react-dom/server";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { Input, Textarea } from "./input";
8
+ import { Label } from "./label";
9
+
10
+ afterEach(cleanup);
11
+
12
+ describe.each([
13
+ ["Input", Input],
14
+ ["Textarea", Textarea],
15
+ ] as const)("%s relationships", (_name, Field) => {
16
+ it("gives repeated labels distinct, stable IDs and focuses the correct field", async () => {
17
+ const user = userEvent.setup();
18
+ const { rerender } = render(<><Field label="Name" /><Field label="Name" /></>);
19
+ const fields = screen.getAllByRole("textbox");
20
+ const ids = fields.map((field) => field.id);
21
+ expect(ids.every(Boolean)).toBe(true);
22
+ expect(new Set(ids).size).toBe(2);
23
+ expect(screen.getAllByLabelText("Name")).toEqual(fields);
24
+ for (const [index, label] of screen.getAllByText("Name", { selector: "label" }).entries()) {
25
+ expect(label).toHaveAttribute("for", ids[index]);
26
+ await user.click(label);
27
+ expect(fields[index]).toHaveFocus();
28
+ }
29
+ rerender(<><Field label="Renamed" /><Field label="Renamed" /></>);
30
+ expect(screen.getAllByRole("textbox").map((field) => field.id)).toEqual(ids);
31
+ });
32
+
33
+ it("preserves an explicit ID and merges caller, hint and error descriptions without duplicates", () => {
34
+ render(<>
35
+ <p id="external">External instructions.</p>
36
+ <Field
37
+ id="profile"
38
+ label="Name"
39
+ hint="Use your full name."
40
+ error="Name is required."
41
+ aria-describedby={" external external\tprofile-hint "}
42
+ aria-invalid={false}
43
+ />
44
+ </>);
45
+ const field = screen.getByRole("textbox", { name: "Name" });
46
+ expect(field).toHaveAttribute("id", "profile");
47
+ expect(field).toHaveAttribute("aria-describedby", "external profile-hint profile-error");
48
+ expect(screen.getByText("Use your full name.")).toHaveAttribute("id", "profile-hint");
49
+ expect(screen.getByText("Name is required.")).toHaveAttribute("id", "profile-error");
50
+ expect(field).toHaveAccessibleDescription("External instructions. Use your full name. Name is required.");
51
+ expect(field).toHaveAttribute("aria-invalid", "true");
52
+ });
53
+
54
+ it("associates descriptions without a convenience label and removes stale relationships", () => {
55
+ const { rerender } = render(<Field aria-label="Name" hint="A hint" error="An error" />);
56
+ const field = screen.getByRole("textbox", { name: "Name" });
57
+ const id = field.id;
58
+ expect(id).not.toBe("");
59
+ expect(field).toHaveAccessibleDescription("A hint An error");
60
+ rerender(<Field aria-label="Name" hint="A hint" />);
61
+ expect(field.id).toBe(id);
62
+ expect(field).toHaveAttribute("aria-describedby", `${id}-hint`);
63
+ expect(field).not.toHaveAttribute("aria-invalid");
64
+ expect(document.getElementById(`${id}-error`)).toBeNull();
65
+ rerender(<Field aria-label="Name" />);
66
+ const bareField = screen.getByRole("textbox", { name: "Name" });
67
+ expect(bareField.id).toBe(id);
68
+ expect(bareField).not.toHaveAttribute("aria-describedby");
69
+ expect(document.getElementById(`${id}-hint`)).toBeNull();
70
+ });
71
+
72
+ it("preserves the bare-element API and caller invalid states", () => {
73
+ const { container, rerender } = render(
74
+ <Field aria-label="Name" aria-describedby="external" aria-invalid="spelling" className="caller-class" />,
75
+ );
76
+ const field = screen.getByRole("textbox");
77
+ expect(container.firstElementChild).toBe(field);
78
+ expect(container.childElementCount).toBe(1);
79
+ expect(field).toHaveAttribute("aria-describedby", "external");
80
+ expect(field).toHaveAttribute("aria-invalid", "spelling");
81
+ expect(field).toHaveClass("caller-class");
82
+ rerender(<Field aria-label="Name" aria-invalid={false} />);
83
+ expect(field).toHaveAttribute("aria-invalid", "false");
84
+ });
85
+
86
+ it("uses the existing Label treatment", () => {
87
+ render(<><Label className="block">Reference</Label><Field label="Name" /></>);
88
+ expect(screen.getByText("Name", { selector: "label" }).className)
89
+ .toBe(screen.getByText("Reference").className);
90
+ });
91
+ });
92
+
93
+ it("keeps Input and Textarea IDs distinct even with the same label", () => {
94
+ render(<><Input label="Notes" /><Textarea label="Notes" /></>);
95
+ expect(new Set(screen.getAllByRole("textbox").map((field) => field.id)).size).toBe(2);
96
+ });
97
+
98
+ it("exposes Input's error variant without requiring error text", () => {
99
+ render(<Input aria-label="Name" variant="error" />);
100
+ expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
101
+ });
102
+
103
+ it("forwards native field refs, values and change handlers", async () => {
104
+ const user = userEvent.setup();
105
+ const inputRef = React.createRef<HTMLInputElement>();
106
+ const textareaRef = React.createRef<HTMLTextAreaElement>();
107
+ const inputChange = vi.fn();
108
+ const textareaChange = vi.fn();
109
+ const { unmount } = render(<>
110
+ <Input ref={inputRef} label="Name" defaultValue="A" onChange={inputChange} />
111
+ <Textarea ref={textareaRef} label="Notes" defaultValue="B" onChange={textareaChange} />
112
+ </>);
113
+ expect(inputRef.current).toBe(screen.getByRole("textbox", { name: "Name" }));
114
+ expect(textareaRef.current).toBe(screen.getByRole("textbox", { name: "Notes" }));
115
+ await user.type(inputRef.current!, "lice");
116
+ await user.type(textareaRef.current!, "ody");
117
+ expect(inputRef.current).toHaveValue("Alice");
118
+ expect(textareaRef.current).toHaveValue("Body");
119
+ expect(inputChange).toHaveBeenCalled();
120
+ expect(textareaChange).toHaveBeenCalled();
121
+ unmount();
122
+ expect(inputRef.current).toBeNull();
123
+ expect(textareaRef.current).toBeNull();
124
+ });
125
+
126
+ it("hydrates SSR IDs and all description relationships without mismatch", async () => {
127
+ const ui = <React.StrictMode>
128
+ <p id="external">External instructions.</p>
129
+ <Input label="Name" hint="First hint" />
130
+ <Input label="Name" error="Required" />
131
+ <Textarea label="Name" hint="Notes hint" error="Too short" aria-describedby="external" />
132
+ <Input id="explicit" label="Explicit" hint="Explicit hint" />
133
+ </React.StrictMode>;
134
+ const container = document.createElement("div");
135
+ container.innerHTML = renderToString(ui, { identifierPrefix: "fields-" });
136
+ document.body.appendChild(container);
137
+ const serverHTML = container.innerHTML;
138
+ const serverIds = within(container).getAllByRole("textbox").map((field) => field.id);
139
+ const recoverableError = vi.fn();
140
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
141
+ let root: Root | undefined;
142
+ try {
143
+ await act(async () => {
144
+ root = hydrateRoot(container, ui, { identifierPrefix: "fields-", onRecoverableError: recoverableError });
145
+ });
146
+ expect(new Set(serverIds).size).toBe(4);
147
+ expect(within(container).getAllByRole("textbox").map((field) => field.id)).toEqual(serverIds);
148
+ expect(container.innerHTML).toBe(serverHTML);
149
+ for (const field of within(container).getAllByRole("textbox")) {
150
+ for (const id of field.getAttribute("aria-describedby")!.split(" ")) {
151
+ expect(document.getElementById(id)).not.toBeNull();
152
+ }
153
+ }
154
+ expect(recoverableError).not.toHaveBeenCalled();
155
+ expect(consoleError).not.toHaveBeenCalled();
156
+ } finally {
157
+ await act(async () => { root?.unmount(); });
158
+ consoleError.mockRestore();
159
+ container.remove();
160
+ }
161
+ });
@@ -1,6 +1,9 @@
1
+ "use client";
2
+
1
3
  import * as React from "react";
2
4
  import { focusField, focusFieldInvalid } from "../lib/focus";
3
5
  import { cn } from "../lib/utils";
6
+ import { Label } from "./label";
4
7
 
5
8
  import { cva, type VariantProps } from "class-variance-authority";
6
9
 
@@ -30,6 +33,11 @@ const inputVariants = cva(
30
33
  }
31
34
  );
32
35
 
36
+ // Only rendered descriptions are added; caller IDs and their order are retained.
37
+ function descriptionIds(...ids: Array<string | undefined>) {
38
+ return [...new Set(ids.join(" ").split(/\s+/).filter(Boolean))].join(" ") || undefined;
39
+ }
40
+
33
41
  export interface InputProps
34
42
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
35
43
  VariantProps<typeof inputVariants> {
@@ -40,10 +48,16 @@ export interface InputProps
40
48
 
41
49
  const Input = React.forwardRef<HTMLInputElement, InputProps>(
42
50
  (
43
- { className, type, variant, size, label, error, hint, id, ...props },
51
+ {
52
+ className, type, variant, size, label, error, hint, id,
53
+ "aria-describedby": describedBy, "aria-invalid": invalid, ...props
54
+ },
44
55
  ref,
45
56
  ) => {
46
- const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
57
+ const generatedId = React.useId();
58
+ const inputId = id ?? generatedId;
59
+ const hintId = hint ? `${inputId}-hint` : undefined;
60
+ const errorId = error ? `${inputId}-error` : undefined;
47
61
 
48
62
  const input = (
49
63
  <input
@@ -52,6 +66,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
52
66
  className={cn(inputVariants({ variant: error ? "error" : variant, size, className }))}
53
67
  ref={ref}
54
68
  {...props}
69
+ aria-describedby={descriptionIds(describedBy, hintId, errorId)}
70
+ aria-invalid={error || variant === "error" ? true : invalid}
55
71
  />
56
72
  );
57
73
 
@@ -59,19 +75,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
59
75
 
60
76
  return (
61
77
  <div className="w-full space-y-1.5">
62
- {label && (
63
- <label
64
- htmlFor={inputId}
65
- className="block font-medium text-foreground text-sm"
66
- >
67
- {label}
68
- </label>
69
- )}
78
+ {label && <Label htmlFor={inputId} className="block">{label}</Label>}
70
79
  {input}
71
- {error && <p className="text-[var(--surface-danger-text)] text-sm font-medium">{error}</p>}
72
- {hint && !error && (
73
- <p className="text-[var(--text-dim)] text-sm">{hint}</p>
74
- )}
80
+ {error && <p id={errorId} className="text-[var(--surface-danger-text)] text-sm font-medium">{error}</p>}
81
+ {hint && <p id={hintId} className="text-[var(--text-dim)] text-sm">{hint}</p>}
75
82
  </div>
76
83
  );
77
84
  },
@@ -90,10 +97,16 @@ export interface TextareaProps
90
97
  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
91
98
  (
92
99
  // `variant` is taken out so it never reaches the DOM element.
93
- { className, variant: _variant, label, error, hint, id, ...props },
100
+ {
101
+ className, variant: _variant, label, error, hint, id,
102
+ "aria-describedby": describedBy, "aria-invalid": invalid, ...props
103
+ },
94
104
  ref,
95
105
  ) => {
96
- const textareaId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
106
+ const generatedId = React.useId();
107
+ const textareaId = id ?? generatedId;
108
+ const hintId = hint ? `${textareaId}-hint` : undefined;
109
+ const errorId = error ? `${textareaId}-error` : undefined;
97
110
 
98
111
  const textarea = (
99
112
  <textarea
@@ -108,6 +121,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
108
121
  )}
109
122
  ref={ref}
110
123
  {...props}
124
+ aria-describedby={descriptionIds(describedBy, hintId, errorId)}
125
+ aria-invalid={error ? true : invalid}
111
126
  />
112
127
  );
113
128
 
@@ -115,19 +130,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
115
130
 
116
131
  return (
117
132
  <div className="w-full space-y-1.5">
118
- {label && (
119
- <label
120
- htmlFor={textareaId}
121
- className="block font-medium text-muted-foreground text-sm"
122
- >
123
- {label}
124
- </label>
125
- )}
133
+ {label && <Label htmlFor={textareaId} className="block">{label}</Label>}
126
134
  {textarea}
127
- {error && <p className="text-[var(--surface-danger-text)] text-sm">{error}</p>}
128
- {hint && !error && (
129
- <p className="text-[var(--text-dim)] text-sm">{hint}</p>
130
- )}
135
+ {error && <p id={errorId} className="text-[var(--surface-danger-text)] text-sm">{error}</p>}
136
+ {hint && <p id={hintId} className="text-[var(--text-dim)] text-sm">{hint}</p>}
131
137
  </div>
132
138
  );
133
139
  },
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "./button";
3
+ import { PageHeader } from "./page-header";
4
+ import { StatusPill } from "./status-pill";
5
+
6
+ const meta = {
7
+ title: "Primitives/PageHeader",
8
+ component: PageHeader,
9
+ parameters: { layout: "padded" },
10
+ args: {
11
+ title: "Activity",
12
+ description: "Inspect recent work and the scope of the results below.",
13
+ actions: <Button type="button">Export</Button>,
14
+ meta: <><span>12 results · Last 24 hours</span><StatusPill tone="success">Up to date</StatusPill></>,
15
+ },
16
+ } satisfies Meta<typeof PageHeader>;
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ export const Default: Story = {};
21
+ export const Nested: Story = { args: { level: 2 } };
22
+ export const SandboxInputs: Story = {
23
+ args: { actions: undefined, action: <Button type="button">Create item</Button>, eyebrow: "Workspace", titleAs: "h2" },
24
+ };
25
+ export const NarrowLongContent: Story = {
26
+ render: () => <div data-narrow-header style={{ width: 320, maxWidth: "100%" }}>
27
+ <PageHeader
28
+ title="Production activity for workspace-with-a-very-long-unbroken-identifier-012345678901234567890123456789"
29
+ description="Inspect the selected activity without clipping the title or hiding action names."
30
+ eyebrow="Production workspace"
31
+ titleId="long-page-title"
32
+ actions={<>
33
+ <Button type="button" className="h-auto whitespace-normal">Export all selected activity for this workspace</Button>
34
+ <Button type="button" variant="outline" className="h-auto whitespace-normal">Create another activity item</Button>
35
+ </>}
36
+ meta={<><span>0 matching results</span><StatusPill tone="neutral">No filter matches</StatusPill></>}
37
+ />
38
+ </div>,
39
+ // Executes when this story is opened in a real browser. Building Storybook
40
+ // alone does not run this layout assertion.
41
+ play: async ({ canvasElement }) => {
42
+ await canvasElement.ownerDocument.fonts.ready;
43
+ const root = canvasElement.querySelector<HTMLElement>("[data-narrow-header]")!;
44
+ if (root.scrollWidth > root.clientWidth + 1) throw new Error("PageHeader overflows its narrow container");
45
+ for (const button of root.querySelectorAll("button")) {
46
+ if (button.scrollWidth > button.clientWidth + 1 || button.scrollHeight > button.clientHeight + 1) {
47
+ throw new Error("A long action label is clipped");
48
+ }
49
+ }
50
+ },
51
+ };
@@ -1,3 +1,4 @@
1
+ import { createRef } from "react";
1
2
  import { render, screen } from "@testing-library/react";
2
3
  import { describe, expect, it } from "vitest";
3
4
  import { PageHeader } from "./page-header";
@@ -54,4 +55,51 @@ describe("PageHeader", () => {
54
55
  expect(container.querySelector("p")).toBeNull();
55
56
  expect(screen.queryByRole("button")).toBeNull();
56
57
  });
58
+
59
+ it("maps Sandbox inputs while keeping title, eyebrow and description separate", () => {
60
+ const { container } = render(<PageHeader title="Activity" titleAs="h3" eyebrow="Workspace" description="Recent events" action={<button type="button">Export</button>} />);
61
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("Activity");
62
+ expect(screen.getAllByRole("heading")).toHaveLength(1);
63
+ expect(screen.getByText("Workspace").tagName).toBe("P");
64
+ expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
65
+ for (const attribute of ["action", "actions", "titleAs", "eyebrow", "level"]) {
66
+ expect(container.querySelector("header")).not.toHaveAttribute(attribute);
67
+ }
68
+ });
69
+
70
+ it("gives actions precedence without duplicating controls, including explicit null", () => {
71
+ const fallback = <button type="button">Legacy</button>;
72
+ const { rerender } = render(<PageHeader title="Activity" action={fallback} actions={<button type="button">Canonical</button>} />);
73
+ expect(screen.getByRole("button", { name: "Canonical" })).toBeInTheDocument();
74
+ expect(screen.queryByRole("button", { name: "Legacy" })).toBeNull();
75
+ rerender(<PageHeader title="Activity" action={fallback} actions={null} />);
76
+ expect(screen.queryByRole("button")).toBeNull();
77
+ });
78
+
79
+ it("lets titleAs override level's element but not its visual treatment", () => {
80
+ const { rerender } = render(<PageHeader title="Nested" level={2} />);
81
+ const classes = screen.getByRole("heading", { level: 2 }).className;
82
+ rerender(<PageHeader title="Nested" level={2} titleAs="h4" />);
83
+ expect(screen.getByRole("heading", { level: 4 }).className).toBe(classes);
84
+ });
85
+
86
+ it("preserves the header ref, native attributes and zero-valued metadata", () => {
87
+ const ref = createRef<HTMLElement>();
88
+ render(<PageHeader ref={ref} title="Activity" titleId="activity-title" id="masthead" aria-labelledby="activity-title" className="custom" meta={0} />);
89
+ expect(ref.current?.tagName).toBe("HEADER");
90
+ expect(ref.current).toHaveAttribute("id", "masthead");
91
+ expect(ref.current).toHaveAttribute("aria-labelledby", "activity-title");
92
+ expect(ref.current).toHaveClass("custom");
93
+ expect(screen.getByText("0")).toBeInTheDocument();
94
+ });
95
+
96
+ it("does not truncate long titles or put action labels inside the heading", () => {
97
+ const title = "A".repeat(180);
98
+ render(<PageHeader title={title} actions={<button type="button">Export the selected activity</button>} />);
99
+ const heading = screen.getByRole("heading", { name: title });
100
+ expect(heading).toHaveClass("[overflow-wrap:anywhere]");
101
+ expect(heading).not.toHaveClass("truncate");
102
+ expect(heading).not.toHaveTextContent("Export the selected activity");
103
+ expect(screen.getByRole("button").parentElement).toHaveClass("flex-wrap", "min-w-0", "max-w-full");
104
+ });
57
105
  });
@@ -1,39 +1,32 @@
1
1
  import * as React from "react";
2
2
  import { cn } from "../lib/utils";
3
+ import { Heading } from "./heading";
3
4
 
4
5
  /**
5
- * The one masthead every console page opens with.
6
- *
7
- * A page identifies itself in three separate registers, and each gets its own
8
- * slot here so they cannot compete for the same one:
9
- *
10
- * - `title` — what the page IS. The `<h1>`, and the only `<h1>`.
11
- * - `description` — one line on what it is for. Prose belongs here, never in
12
- * the title slot: a paragraph rendered where a heading goes reads as body
13
- * copy to a sighted reader and as nothing at all to a screen reader, which
14
- * is how a page ends up with no accessible name.
15
- * - `actions` — what the reader can DO from here, right-aligned and wrapping
16
- * under the title on a narrow viewport rather than squeezing it.
17
- *
18
- * `meta` is the fourth register and the one most often missing: the small facts
19
- * that qualify everything below (a count, a scope, a window). They sit on their
20
- * own line under the title so a reader can tell the difference between "this
21
- * page has nothing in it" and "this filter matches nothing".
22
- *
23
- * `titleId` is exposed so a page can point `aria-labelledby` at the heading
24
- * from a region further down without minting a second copy of the string.
6
+ * A page masthead with separate title, prose, actions and qualifying metadata.
7
+ * Brand's existing props and header ref are preserved. Sandbox's existing
8
+ * action/eyebrow/titleAs inputs map into the same renderer, not a second header.
25
9
  */
26
10
  export interface PageHeaderProps
27
11
  extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
28
12
  title: React.ReactNode;
29
13
  description?: React.ReactNode;
14
+ /** Canonical action slot. If supplied (even null), takes precedence over action. */
30
15
  actions?: React.ReactNode;
31
16
  meta?: React.ReactNode;
32
17
  titleId?: string;
33
- /** Renders the title one step down for a nested/tab surface. */
18
+ /** Existing nested-surface treatment: h2 and section typography. */
34
19
  level?: 1 | 2;
20
+ /** Existing Sandbox action slot; used only when actions is undefined. */
21
+ action?: React.ReactNode;
22
+ eyebrow?: React.ReactNode;
23
+ /** Existing Sandbox semantic override; takes precedence over level's tag only. */
24
+ titleAs?: React.ElementType;
35
25
  }
36
26
 
27
+ // A numeric zero is useful metadata, not an absent slot.
28
+ const hasContent = (node: React.ReactNode) => Boolean(node) || node === 0;
29
+
37
30
  const PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(
38
31
  (
39
32
  {
@@ -41,39 +34,46 @@ const PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(
41
34
  title,
42
35
  description,
43
36
  actions,
37
+ action,
38
+ eyebrow,
44
39
  meta,
45
40
  titleId,
41
+ titleAs,
46
42
  level = 1,
47
43
  ...props
48
44
  },
49
45
  ref,
50
46
  ) => {
51
- const Heading = level === 1 ? "h1" : "h2";
47
+ const resolvedActions = actions === undefined ? action : actions;
52
48
  return (
53
- <header ref={ref} className={cn("mb-6", className)} {...props}>
54
- <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
55
- <div className="min-w-0">
49
+ <header ref={ref} className={cn("mb-6 min-w-0", className)} {...props}>
50
+ <div className="flex min-w-0 flex-wrap items-start justify-between gap-x-6 gap-y-3">
51
+ <div className="min-w-0 flex-[1_1_16rem]">
52
+ {hasContent(eyebrow) && (
53
+ <Heading variant="eyebrow" className="mb-1.5">{eyebrow}</Heading>
54
+ )}
56
55
  <Heading
57
56
  id={titleId}
58
- className={cn(
59
- "text-balance font-semibold tracking-tight",
60
- level === 1 ? "text-2xl" : "text-lg",
61
- )}
57
+ variant={level === 1 ? "page" : "section"}
58
+ as={titleAs ?? (level === 1 ? "h1" : "h2")}
59
+ className="text-balance"
62
60
  >
63
61
  {title}
64
62
  </Heading>
65
- {description && (
66
- <p className="mt-1 max-w-prose text-muted-foreground text-sm">
63
+ {hasContent(description) && (
64
+ <p className="mt-1 max-w-prose text-muted-foreground text-sm [overflow-wrap:anywhere]">
67
65
  {description}
68
66
  </p>
69
67
  )}
70
68
  </div>
71
- {actions && (
72
- <div className="flex flex-wrap items-center gap-2">{actions}</div>
69
+ {hasContent(resolvedActions) && (
70
+ <div className="flex min-w-0 max-w-full flex-wrap items-center gap-2 [overflow-wrap:anywhere] [&>*]:min-w-0 [&>*]:max-w-full">
71
+ {resolvedActions}
72
+ </div>
73
73
  )}
74
74
  </div>
75
- {meta && (
76
- <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-[var(--text-dim)] text-xs">
75
+ {hasContent(meta) && (
76
+ <div className="mt-3 flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 text-[var(--text-dim)] text-xs [overflow-wrap:anywhere]">
77
77
  {meta}
78
78
  </div>
79
79
  )}
@@ -0,0 +1,89 @@
1
+ import { useId, useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Button } from "./button";
4
+ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "./card";
5
+ import { EmptyState } from "./empty-state";
6
+ import { Input } from "./input";
7
+ import { Metric, MetricStrip } from "./metric-strip";
8
+ import { PageHeader } from "./page-header";
9
+ import { PageShell } from "./page-shell";
10
+ import { StatusPill } from "./status-pill";
11
+ import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "./table";
12
+ import { Toolbar } from "./toolbar";
13
+
14
+ type PageState = "empty" | "loading" | "error" | "populated";
15
+ const items = [{ name: "Documentation build", status: "Complete" }, { name: "Package validation", status: "Complete" }];
16
+
17
+ /** Local example data and real state transitions; no auth, routing or fetching in PageShell. */
18
+ function ActivityPage({ state, fluid = false }: { state: PageState; fluid?: boolean }) {
19
+ const titleId = useId();
20
+ const searchId = useId();
21
+ const [recovered, setRecovered] = useState(false);
22
+ const [query, setQuery] = useState("");
23
+ const current = recovered ? "populated" : state;
24
+ const available = current === "populated" ? items : [];
25
+ const matching = available.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()));
26
+ const known = current === "empty" || current === "populated";
27
+ const recover = () => { setQuery(""); setRecovered(true); };
28
+
29
+ return <main>
30
+ <PageShell className={fluid ? "mx-0 max-w-none" : undefined}>
31
+ <PageHeader
32
+ title="Workspace activity"
33
+ eyebrow="Example workspace"
34
+ description="Search the example activity or add example items. State is owned by this story, not the layout."
35
+ actions={<Button type="button" disabled={current === "loading"} onClick={recover}>Add example items</Button>}
36
+ meta={known ? <span>{matching.length} matching of {available.length} available items</span> : <span>Activity is not available yet</span>}
37
+ />
38
+ <MetricStrip>
39
+ <Metric label="Available" value={known ? available.length : "—"} />
40
+ <Metric label="Matching" value={known ? matching.length : "—"} />
41
+ <Metric label="Completed" value={known ? available.length : "—"} />
42
+ <Metric label="Failed" value={known ? 0 : "—"} />
43
+ </MetricStrip>
44
+ <section aria-labelledby={titleId}>
45
+ <Toolbar search={<Input id={searchId} label="Search activity" value={query} disabled={!known} onChange={(event) => setQuery(event.target.value)} placeholder="Search by name" />} />
46
+ <Card>
47
+ <CardHeader>
48
+ <CardTitle as="h2" id={titleId}>Activity items</CardTitle>
49
+ </CardHeader>
50
+ <CardContent aria-busy={current === "loading"}>
51
+ {current === "loading" ? <p role="status">Loading activity…</p> :
52
+ current === "error" ? <div className="space-y-3">
53
+ <p role="alert">Activity could not be loaded. The last request failed; retry to restore the example results.</p>
54
+ <Button type="button" onClick={recover}>Retry</Button>
55
+ </div> : matching.length === 0 ?
56
+ <EmptyState
57
+ title={query ? "No matching activity" : "No activity yet"}
58
+ description={query ? "Try a different search or clear the current filter." : "Add example items to see the populated table."}
59
+ action={<Button type="button" onClick={query ? () => setQuery("") : recover}>{query ? "Clear search" : "Add example items"}</Button>}
60
+ /> :
61
+ <Table wrapper={false}>
62
+ <TableCaption>Matching workspace activity</TableCaption>
63
+ <TableHeader><TableRow><TableHead scope="col">Item</TableHead><TableHead scope="col">State</TableHead></TableRow></TableHeader>
64
+ <TableBody>{matching.map((item) => <TableRow key={item.name}>
65
+ <TableHead scope="row" className="[overflow-wrap:anywhere]">{item.name}</TableHead>
66
+ <TableCell><StatusPill tone="success">{item.status}</StatusPill></TableCell>
67
+ </TableRow>)}</TableBody>
68
+ </Table>}
69
+ </CardContent>
70
+ <CardFooter className="text-sm text-muted-foreground">{known ? "Example data. Use search to exercise the filtered empty state." : "Missing data is not reported as zero activity."}</CardFooter>
71
+ </Card>
72
+ </section>
73
+ </PageShell>
74
+ </main>;
75
+ }
76
+
77
+ const meta = {
78
+ title: "Primitives/PageShell",
79
+ component: PageShell,
80
+ parameters: { layout: "fullscreen" },
81
+ } satisfies Meta<typeof PageShell>;
82
+ export default meta;
83
+ type Story = StoryObj<typeof PageShell>;
84
+
85
+ export const Populated: Story = { render: () => <ActivityPage state="populated" /> };
86
+ export const Empty: Story = { render: () => <ActivityPage state="empty" /> };
87
+ export const Loading: Story = { render: () => <ActivityPage state="loading" /> };
88
+ export const Error: Story = { render: () => <ActivityPage state="error" /> };
89
+ export const FluidWidth: Story = { render: () => <ActivityPage state="populated" fluid /> };
@@ -0,0 +1,24 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { PageShell } from "./page-shell";
4
+
5
+ describe("PageShell", () => {
6
+ it("ports Sandbox's width, gutters and rhythm without imposing a landmark or grid", () => {
7
+ const { container } = render(<PageShell><p>Content</p></PageShell>);
8
+ const shell = container.firstElementChild;
9
+ expect(shell?.tagName).toBe("DIV");
10
+ expect(shell).toHaveClass("mx-auto", "max-w-6xl", "space-y-8", "px-6", "py-8", "lg:px-8");
11
+ expect(shell).not.toHaveAttribute("role");
12
+ expect(shell).not.toHaveClass("grid");
13
+ expect(screen.queryByRole("main")).toBeNull();
14
+ expect(screen.getByText("Content")).toBeInTheDocument();
15
+ });
16
+
17
+ it("allows a caller-owned fluid layout without introducing shell behavior", () => {
18
+ const { container } = render(<main><PageShell className="mx-0 max-w-none px-0 lg:px-0"><p>Workbench</p></PageShell></main>);
19
+ const shell = container.querySelector("main > div");
20
+ expect(shell).toHaveClass("mx-0", "max-w-none", "px-0", "lg:px-0");
21
+ expect(shell).not.toHaveClass("mx-auto", "max-w-6xl", "px-6", "lg:px-8");
22
+ expect(screen.getAllByRole("main")).toHaveLength(1);
23
+ });
24
+ });
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ export interface PageShellProps {
5
+ children: ReactNode;
6
+ className?: string;
7
+ }
8
+
9
+ /**
10
+ * Ported from sandbox-ui/src/primitives/page-shell.tsx. An optional bounded
11
+ * width, gutter and vertical rhythm, not an application shell or a card grid.
12
+ * The caller owns landmarks, routing, auth, navigation and data state. Use
13
+ * className to override the bound/gutters, or omit it for a workbench layout.
14
+ */
15
+ export function PageShell({ children, className }: PageShellProps) {
16
+ return (
17
+ <div
18
+ className={cn(
19
+ "mx-auto min-w-0 w-full max-w-6xl space-y-8 px-6 py-8 lg:px-8",
20
+ className,
21
+ )}
22
+ >
23
+ {children}
24
+ </div>
25
+ );
26
+ }