@tangle-network/ui 11.12.0 → 11.13.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 (276) hide show
  1. package/CHANGELOG.md +17 -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} +22 -47
  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 +46 -2
  232. package/src/markdown/code-block.tsx +23 -60
  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
@@ -1,7 +1,51 @@
1
- import { describe, it, expect } from "vitest";
2
- import { render } from "@testing-library/react";
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { afterEach, describe, expect, it } from "vitest";
3
4
  import { CodeBlock, CopyButton } from "./code-block";
4
5
 
6
+ afterEach(cleanup);
7
+
8
+ describe("CodeBlock scoped semantic colors", () => {
9
+ it("emits live CSS variables through the real highlighter, not sampled root colors", () => {
10
+ const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
11
+ expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)");
12
+ expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
13
+ expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
14
+ });
15
+
16
+ it("inherits a named scope by default without inserting its own mode", () => {
17
+ const { container } = render(<section data-theme="arena-light"><CodeBlock data-testid="code" code="const x = 1" language="javascript" /></section>);
18
+ const code = screen.getByTestId("code");
19
+ expect(code.hasAttribute("data-theme")).toBe(false);
20
+ const before = code.innerHTML;
21
+ container.querySelector("section")!.setAttribute("data-theme", "arena");
22
+ expect(code.innerHTML).toBe(before); // References stay live; the browser fixture checks the computed colors.
23
+ });
24
+
25
+ it.each([true, false])("preserves explicit light=%s as an opposite nested mode", (light) => {
26
+ render(<section data-theme={light ? "dark" : "light"}><CodeBlock data-testid="code" code="x" light={light} /></section>);
27
+ expect(screen.getByTestId("code").getAttribute("data-theme")).toBe(light ? "light" : "dark");
28
+ });
29
+
30
+ it("preserves caller attributes, labels, children and custom scopes", () => {
31
+ render(<CodeBlock data-testid="code" data-theme="aubergine" className="caller" code="echo hello" language="bash" label="BASHRC"><button>Copy example</button></CodeBlock>);
32
+ expect(screen.getByTestId("code").getAttribute("data-theme")).toBe("aubergine");
33
+ expect(screen.getByTestId("code").classList.contains("caller")).toBe(true);
34
+ expect(screen.getByText("BASHRC")).toBeTruthy();
35
+ expect(screen.getByRole("button", { name: "Copy example" })).toBeTruthy();
36
+ });
37
+
38
+ it("server-renders the same semantic references independent of document theme", () => {
39
+ const source = <CodeBlock code="const x = 1" language="javascript" />;
40
+ document.documentElement.className = "dark";
41
+ const dark = renderToString(source);
42
+ document.documentElement.className = "light";
43
+ expect(renderToString(source)).toBe(dark);
44
+ document.documentElement.removeAttribute("class");
45
+ });
46
+ });
47
+
48
+ // Retain the existing header/label/copy-slot contracts alongside theme coverage.
5
49
  describe("CodeBlock", () => {
6
50
  it("renders the code through the syntax highlighter", () => {
7
51
  const { container } = render(<CodeBlock code={"const x = 1;"} language="typescript" />);
@@ -12,23 +12,19 @@ import { Check, Copy } from "lucide-react";
12
12
  import { focusRing } from "../lib/focus";
13
13
  import { cn } from "../lib/utils";
14
14
 
15
- // Theme-aware syntax highlighting — reads CSS custom properties at render time.
16
- // Override --syntax-* tokens in tokens.css per theme.
17
- function getSyntaxTheme(): { [key: string]: React.CSSProperties } {
18
- const el = typeof document !== "undefined" ? document.documentElement : null;
19
- const v = (name: string, fallback: string) =>
20
- el ? getComputedStyle(el).getPropertyValue(name).trim() || fallback : fallback;
21
-
22
- const comment = v("--syntax-comment", "#6B7094");
23
- const keyword = v("--syntax-keyword", "#A78FFF");
24
- const string = v("--syntax-string", "#10b981");
25
- const fn = v("--syntax-function", "#6D9FFF");
26
- const number = v("--syntax-number", "#FFB347");
27
- const meta = v("--syntax-meta", "#8263FF");
28
- const error = v("--syntax-error", "#FF4D6D");
29
- const variable = v("--syntax-variable", "#C4C0D8");
30
- const fg = v("--syntax-foreground", "#E8E6F6");
31
-
15
+ // Leave variables unresolved: the browser resolves them on each token span,
16
+ // including nested themes, inline overrides and CSS/media changes. No document
17
+ // sampling, observers, layout reads or React re-render are needed to recolor code.
18
+ const syntaxTheme: { [key: string]: React.CSSProperties } = (() => {
19
+ const comment = "var(--syntax-comment, currentColor)";
20
+ const keyword = "var(--syntax-keyword, currentColor)";
21
+ const string = "var(--syntax-string, currentColor)";
22
+ const fn = "var(--syntax-function, currentColor)";
23
+ const number = "var(--syntax-number, currentColor)";
24
+ const meta = "var(--syntax-meta, currentColor)";
25
+ const error = "var(--syntax-error, currentColor)";
26
+ const variable = "var(--syntax-variable, currentColor)";
27
+ const fg = "var(--syntax-foreground, currentColor)";
32
28
  return {
33
29
  "hljs-comment": { color: comment, fontStyle: "italic" },
34
30
  "hljs-quote": { color: comment, fontStyle: "italic" },
@@ -67,10 +63,7 @@ function getSyntaxTheme(): { [key: string]: React.CSSProperties } {
67
63
  "hljs-emphasis": { fontStyle: "italic" },
68
64
  "hljs": { color: fg, background: "transparent" },
69
65
  };
70
- }
71
-
72
- // tangleLight removed — getSyntaxTheme() reads --syntax-* CSS vars which are overridden
73
- // per theme in tokens.css (vault, dawn themes set light values).
66
+ })();
74
67
 
75
68
  export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
76
69
  code: string;
@@ -82,55 +75,25 @@ export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
82
75
  */
83
76
  label?: string;
84
77
  showLineNumbers?: boolean;
85
- /** Force light theme; defaults to dark */
78
+ /** Force light (true) or dark (false); omitted inherits the nearest theme. */
86
79
  light?: boolean;
87
80
  children?: ReactNode;
88
81
  }
89
82
 
90
- const LIGHT_THEMES = new Set(["vault", "dawn"]);
91
-
92
- function detectLightTheme() {
93
- return (
94
- typeof document !== "undefined" &&
95
- LIGHT_THEMES.has(document.documentElement.getAttribute("data-sandbox-theme") ?? "")
96
- );
97
- }
98
-
99
- function useIsLightTheme(): boolean {
100
- const [isLight, setIsLight] = useState(detectLightTheme);
101
-
102
- useEffect(() => {
103
- if (typeof document === "undefined") return;
104
- setIsLight(detectLightTheme());
105
- const observer = new MutationObserver(() => setIsLight(detectLightTheme()));
106
- observer.observe(document.documentElement, {
107
- attributes: true,
108
- attributeFilter: ["data-sandbox-theme"],
109
- });
110
- return () => observer.disconnect();
111
- }, []);
112
-
113
- return isLight;
114
- }
115
-
116
83
  export const CodeBlock = memo(
117
- ({ code, language, label, showLineNumbers = false, light: lightProp, className, children, ...props }: CodeBlockProps) => {
118
- const isLight = useIsLightTheme();
119
- const light = lightProp ?? isLight;
120
- const theme = getSyntaxTheme();
121
- const bg = "bg-card border-border";
122
- const headerBg = light ? "bg-muted/50 border-border" : "bg-background border-border";
123
- const langColor = "text-muted-foreground";
84
+ ({ code, language, label, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
124
85
  const headerLabel = label ?? language;
86
+ const mode = light === undefined ? {} : { "data-theme": light ? "light" : "dark" };
125
87
 
126
88
  return (
127
89
  <div
128
- className={cn("group relative overflow-hidden rounded-lg border font-mono", bg, className)}
90
+ className={cn("group relative overflow-hidden rounded-lg border font-mono", "bg-card border-border", className)}
129
91
  {...props}
92
+ {...mode}
130
93
  >
131
94
  {headerLabel && (
132
- <div className={cn("flex items-center justify-between border-b px-3 py-1", headerBg)}>
133
- <span className={cn("text-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest", langColor)}>
95
+ <div className="flex items-center justify-between border-b border-border px-3 py-1 bg-[var(--code-header-bg,hsl(var(--background)))]">
96
+ <span className="text-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest text-muted-foreground">
134
97
  {headerLabel}
135
98
  </span>
136
99
  {children}
@@ -143,10 +106,10 @@ export const CodeBlock = memo(
143
106
  )}
144
107
  <SyntaxHighlighter
145
108
  language={language ?? "text"}
146
- style={theme}
109
+ style={syntaxTheme}
147
110
  showLineNumbers={showLineNumbers}
148
111
  lineNumberStyle={{
149
- color: light ? "#8B92B8" : "#4A4D6A",
112
+ color: "var(--syntax-comment, currentColor)",
150
113
  minWidth: "2.5em",
151
114
  paddingRight: "1em",
152
115
  }}
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useId, useState } from 'react'
2
3
  import { Button } from './button'
3
4
 
4
5
  const meta: Meta<typeof Button> = {
@@ -17,7 +18,7 @@ export const Secondary: Story = { args: { variant: 'secondary' } }
17
18
  export const Outline: Story = { args: { variant: 'outline' } }
18
19
  export const Ghost: Story = { args: { variant: 'ghost' } }
19
20
  export const Destructive: Story = { args: { variant: 'destructive', children: 'Delete' } }
20
- export const Loading: Story = { args: { loading: true, children: 'Loading...' } }
21
+ export const Loading: Story = { args: { loading: true, children: 'Save changes' } }
21
22
 
22
23
  export const AllSizes: Story = {
23
24
  name: 'All Sizes',
@@ -46,3 +47,38 @@ export const AllVariants: Story = {
46
47
  </div>
47
48
  ),
48
49
  }
50
+
51
+ export const ActionContracts: Story = {
52
+ parameters: {
53
+ docs: {
54
+ description: {
55
+ story: 'Toggle disabled/loading, then try pointer, Enter and Space activation. Native buttons retain their submit default; links keep their action name while unavailable. Emulate prefers-reduced-motion to check the spinner and press effect.',
56
+ },
57
+ },
58
+ },
59
+ render: function ActionContracts() {
60
+ const resultId = useId()
61
+ const [disabled, setDisabled] = useState(false)
62
+ const [loading, setLoading] = useState(false)
63
+ const [submits, setSubmits] = useState(0)
64
+ const [opens, setOpens] = useState(0)
65
+ return (
66
+ <div className="flex max-w-lg flex-col gap-4">
67
+ <div className="flex gap-4">
68
+ <label><input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} /> Disabled</label>
69
+ <label><input type="checkbox" checked={loading} onChange={event => setLoading(event.target.checked)} /> Loading</label>
70
+ </div>
71
+ <form className="flex flex-wrap gap-3" onSubmit={event => { event.preventDefault(); setSubmits(value => value + 1) }}>
72
+ <Button disabled={disabled} loading={loading}>Save changes</Button>
73
+ <Button asChild disabled={disabled} loading={loading}>
74
+ <button disabled={false} aria-busy={false}>Submit slotted button</button>
75
+ </Button>
76
+ <Button asChild disabled={disabled} loading={loading}>
77
+ <a href={`#${resultId}`} onClick={() => setOpens(value => value + 1)}>Open result</a>
78
+ </Button>
79
+ </form>
80
+ <p id={resultId} role="status">Submissions: {submits}. Link activations: {opens}.</p>
81
+ </div>
82
+ )
83
+ },
84
+ }
@@ -0,0 +1,230 @@
1
+ import * as React from "react";
2
+ import { act, cleanup, fireEvent, 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 { Button } from "./button";
8
+
9
+ afterEach(cleanup);
10
+
11
+ describe.each(["disabled", "loading"] as const)("%s controls", (state) => {
12
+ it.each([false, true])("natively disables button elements (asChild=%s)", async (asChild) => {
13
+ const user = userEvent.setup();
14
+ const click = vi.fn();
15
+ const submit = vi.fn((event: React.FormEvent) => event.preventDefault());
16
+ render(<form onSubmit={submit}>
17
+ <Button {...{ [state]: true }} asChild={asChild} onClick={click}>
18
+ {asChild ? <button disabled={false} aria-busy={false}>Save</button> : "Save"}
19
+ </Button>
20
+ <input aria-label="Next field" />
21
+ </form>);
22
+ const button = screen.getByRole("button", { name: "Save" });
23
+ expect(button).toBeDisabled();
24
+ expect(button).toHaveAccessibleName("Save");
25
+ if (state === "loading") expect(button).toHaveAttribute("aria-busy", "true");
26
+ await user.click(button);
27
+ button.click();
28
+ button.focus();
29
+ expect(button).not.toHaveFocus();
30
+ await user.keyboard("{Enter} ");
31
+ expect(click).not.toHaveBeenCalled();
32
+ expect(submit).not.toHaveBeenCalled();
33
+ await user.tab();
34
+ expect(screen.getByRole("textbox")).toHaveFocus();
35
+ });
36
+
37
+ it("blocks slotted anchor activation before child AND slot capture/bubble handlers", async () => {
38
+ const user = userEvent.setup();
39
+ const activation = vi.fn();
40
+ const handlers = {
41
+ onClick: activation, onClickCapture: activation,
42
+ onAuxClick: activation, onAuxClickCapture: activation,
43
+ onDoubleClick: activation, onDoubleClickCapture: activation,
44
+ onPointerDown: activation, onPointerDownCapture: activation,
45
+ onPointerUp: activation, onPointerUpCapture: activation,
46
+ onMouseDown: activation, onMouseDownCapture: activation,
47
+ onMouseUp: activation, onMouseUpCapture: activation,
48
+ onKeyDown: activation, onKeyDownCapture: activation,
49
+ onKeyUp: activation, onKeyUpCapture: activation,
50
+ };
51
+ render(<div onClick={activation}>
52
+ <Button asChild {...{ [state]: true }} aria-busy={false} {...handlers}>
53
+ <a href="#destination" tabIndex={0} aria-disabled={false} aria-busy={false} {...handlers}>
54
+ <span>Open account</span>
55
+ </a>
56
+ </Button>
57
+ <button>Next action</button>
58
+ </div>);
59
+ const link = screen.getByRole("link", { name: "Open account" });
60
+ expect(link).toHaveAttribute("aria-disabled", "true");
61
+ expect(link).toHaveAttribute("tabindex", "-1");
62
+ expect(link).not.toHaveAttribute("disabled");
63
+ expect(link).not.toHaveAttribute("href");
64
+ if (state === "loading") expect(link).toHaveAttribute("aria-busy", "true");
65
+ const target = screen.getByText("Open account");
66
+ expect(fireEvent.click(target)).toBe(false);
67
+ expect(fireEvent.doubleClick(target)).toBe(false);
68
+ expect(fireEvent(target, new MouseEvent("auxclick", { bubbles: true, cancelable: true, button: 1 }))).toBe(false);
69
+ expect(fireEvent.pointerDown(target)).toBe(false);
70
+ expect(fireEvent.pointerUp(target)).toBe(false);
71
+ expect(fireEvent.mouseDown(target)).toBe(false);
72
+ expect(fireEvent.mouseUp(target)).toBe(false);
73
+ for (const key of ["Enter", " "]) {
74
+ expect(fireEvent.keyDown(link, { key })).toBe(false);
75
+ expect(fireEvent.keyUp(link, { key })).toBe(false);
76
+ }
77
+ await user.click(target);
78
+ link.click();
79
+ // Even programmatically focused, a disabled link cannot activate by keyboard.
80
+ link.focus();
81
+ await user.keyboard("{Enter} ");
82
+ expect(activation).not.toHaveBeenCalled();
83
+ link.blur();
84
+ await user.tab();
85
+ expect(screen.getByRole("button", { name: "Next action" })).toHaveFocus();
86
+ });
87
+ });
88
+
89
+ it("retains the action name and hides the reduced-motion spinner from accessibility", () => {
90
+ const { rerender } = render(<Button loading aria-busy={false}>Save changes</Button>);
91
+ const button = screen.getByRole("button", { name: "Save changes" });
92
+ expect(button).toHaveAttribute("aria-busy", "true");
93
+ const spinner = button.querySelector("svg");
94
+ expect(spinner).toHaveAttribute("aria-hidden", "true");
95
+ expect(spinner).toHaveAttribute("focusable", "false");
96
+ expect(spinner).toHaveClass("motion-reduce:animate-none");
97
+ expect(button).toHaveClass("motion-reduce:transition-none", "motion-reduce:active:scale-100");
98
+ expect(button.querySelector("title")).toBeNull();
99
+ rerender(<Button loading size="icon" aria-label="Refresh reports"><span aria-hidden="true">↻</span></Button>);
100
+ expect(screen.getByRole("button")).toHaveAccessibleName("Refresh reports");
101
+ });
102
+
103
+ it.each(["disabled", "loading"] as const)("restores link attributes and original Slot handler order when %s ends", async (state) => {
104
+ const user = userEvent.setup();
105
+ const calls: string[] = [];
106
+ const ui = (unavailable: boolean) => <Button asChild {...{ [state]: unavailable }} onClick={() => calls.push("slot")}>
107
+ <a href="#destination" tabIndex={2} onClick={(event) => { event.preventDefault(); calls.push("child"); }}>Open</a>
108
+ </Button>;
109
+ const { rerender } = render(ui(true));
110
+ const link = screen.getByRole("link");
111
+ rerender(ui(false));
112
+ expect(link).toHaveAttribute("href", "#destination");
113
+ expect(link).toHaveAttribute("tabindex", "2");
114
+ expect(link).not.toHaveAttribute("aria-disabled");
115
+ expect(link).not.toHaveAttribute("aria-busy");
116
+ expect(link).not.toHaveAttribute("role");
117
+ await user.click(link);
118
+ expect(calls).toEqual(["child", "slot"]);
119
+ calls.length = 0;
120
+ link.focus();
121
+ await user.keyboard("{Enter}");
122
+ expect(calls).toEqual(["child", "slot"]);
123
+ });
124
+
125
+ it("preserves non-activation keyboard handlers on disabled links", () => {
126
+ const calls: string[] = [];
127
+ render(<Button asChild disabled onKeyDownCapture={() => calls.push("slot capture")} onKeyDown={() => calls.push("slot bubble")}>
128
+ <a href="#destination" onKeyDownCapture={() => calls.push("child capture")} onKeyDown={() => calls.push("child bubble")}>Open</a>
129
+ </Button>);
130
+ expect(fireEvent.keyDown(screen.getByRole("link"), { key: "ArrowRight" })).toBe(true);
131
+ expect(calls).toEqual(["child capture", "slot capture", "child bubble", "slot bubble"]);
132
+ });
133
+
134
+ it("respects a native child's own disabled state", () => {
135
+ render(<Button asChild disabled={false}><button disabled>Save</button></Button>);
136
+ expect(screen.getByRole("button")).toBeDisabled();
137
+ });
138
+
139
+ it("composes refs on a slotted native button", () => {
140
+ const outerRef = React.createRef<HTMLButtonElement>();
141
+ const childRef = React.createRef<HTMLButtonElement>();
142
+ const { unmount } = render(<Button asChild ref={outerRef}><button ref={childRef}>Save</button></Button>);
143
+ expect(outerRef.current).toBe(screen.getByRole("button"));
144
+ expect(childRef.current).toBe(outerRef.current);
145
+ unmount();
146
+ expect(outerRef.current).toBeNull();
147
+ expect(childRef.current).toBeNull();
148
+ });
149
+
150
+ it("preserves caller busy state when Button is not loading", () => {
151
+ render(<><Button aria-busy>Save</Button><Button asChild aria-busy><a href="#destination">Open</a></Button></>);
152
+ expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
153
+ expect(screen.getByRole("link")).toHaveAttribute("aria-busy", "true");
154
+ });
155
+
156
+ it("preserves implicit submission, explicit types, and enabled keyboard activation", async () => {
157
+ const user = userEvent.setup();
158
+ const submit = vi.fn((event: React.FormEvent) => event.preventDefault());
159
+ render(<form onSubmit={submit}>
160
+ <Button>Implicit submit</Button>
161
+ <Button asChild><button>Slotted submit</button></Button>
162
+ <Button type="button">Explicit button</Button>
163
+ <Button type="submit">Explicit submit</Button>
164
+ <Button asChild type="submit"><button type="button">Child button</button></Button>
165
+ </form>);
166
+ const implicit = screen.getByRole("button", { name: "Implicit submit" });
167
+ expect(implicit).not.toHaveAttribute("type");
168
+ await user.click(implicit);
169
+ await user.click(screen.getByRole("button", { name: "Slotted submit" }));
170
+ await user.click(screen.getByRole("button", { name: "Explicit submit" }));
171
+ expect(submit).toHaveBeenCalledTimes(3);
172
+ await user.click(screen.getByRole("button", { name: "Explicit button" }));
173
+ await user.click(screen.getByRole("button", { name: "Child button" }));
174
+ expect(submit).toHaveBeenCalledTimes(3);
175
+ implicit.focus();
176
+ await user.keyboard("{Enter} ");
177
+ expect(submit).toHaveBeenCalledTimes(5);
178
+ });
179
+
180
+ it("forwards native refs and composes both refs on a slotted anchor", () => {
181
+ const nativeRef = React.createRef<HTMLButtonElement>();
182
+ const anchorRef = React.createRef<HTMLAnchorElement>();
183
+ const slotRef = vi.fn();
184
+ const { rerender, unmount } = render(<>
185
+ <Button ref={nativeRef}>Save</Button>
186
+ <Button asChild loading ref={slotRef}><a ref={anchorRef} href="#destination">Open</a></Button>
187
+ </>);
188
+ const link = screen.getByRole("link");
189
+ expect(nativeRef.current).toBe(screen.getByRole("button"));
190
+ expect(anchorRef.current).toBe(link);
191
+ expect(slotRef).toHaveBeenLastCalledWith(link);
192
+ rerender(<>
193
+ <Button ref={nativeRef}>Save</Button>
194
+ <Button asChild ref={slotRef}><a ref={anchorRef} href="#destination">Open</a></Button>
195
+ </>);
196
+ expect(anchorRef.current).toBe(link);
197
+ expect(slotRef).toHaveBeenLastCalledWith(link);
198
+ unmount();
199
+ expect(nativeRef.current).toBeNull();
200
+ expect(anchorRef.current).toBeNull();
201
+ expect(slotRef).toHaveBeenLastCalledWith(null);
202
+ });
203
+
204
+ it("hydrates disabled/loading native and slotted controls without changing their names", async () => {
205
+ const ui = <>
206
+ <Button loading>Save</Button>
207
+ <Button asChild loading><button>Submit</button></Button>
208
+ <Button asChild disabled><a href="#destination">Open</a></Button>
209
+ </>;
210
+ const container = document.createElement("div");
211
+ container.innerHTML = renderToString(ui);
212
+ document.body.appendChild(container);
213
+ const html = container.innerHTML;
214
+ const recoverableError = vi.fn();
215
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
216
+ let root: Root | undefined;
217
+ try {
218
+ await act(async () => { root = hydrateRoot(container, ui, { onRecoverableError: recoverableError }); });
219
+ expect(container.innerHTML).toBe(html);
220
+ expect(within(container).getByRole("button", { name: "Save" })).toBeDisabled();
221
+ expect(within(container).getByRole("button", { name: "Submit" })).toBeDisabled();
222
+ expect(within(container).getByRole("link", { name: "Open" })).not.toHaveAttribute("href");
223
+ expect(recoverableError).not.toHaveBeenCalled();
224
+ expect(consoleError).not.toHaveBeenCalled();
225
+ } finally {
226
+ await act(async () => { root?.unmount(); });
227
+ consoleError.mockRestore();
228
+ container.remove();
229
+ }
230
+ });
@@ -1,11 +1,11 @@
1
1
  import { Slot } from "@radix-ui/react-slot";
2
- import { cva, type VariantProps } from "class-variance-authority";
2
+ import { cva } from "class-variance-authority";
3
3
  import * as React from "react";
4
4
  import { focusRing } from "../lib/focus";
5
5
  import { cn } from "../lib/utils";
6
6
 
7
7
  const buttonVariants = cva(
8
- `inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
8
+ `inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
9
9
  {
10
10
  variants: {
11
11
  variant: {
@@ -47,6 +47,36 @@ export interface ButtonProps
47
47
  children?: React.ReactNode;
48
48
  }
49
49
 
50
+ type ButtonChildProps = React.HTMLAttributes<HTMLElement> & {
51
+ disabled?: boolean;
52
+ href?: string;
53
+ };
54
+
55
+ function preventActivation(event: React.SyntheticEvent) {
56
+ event.preventDefault();
57
+ event.stopPropagation();
58
+ }
59
+
60
+ function disabledHandlers(props: React.HTMLAttributes<HTMLElement>) {
61
+ const guardKey = (handler?: React.KeyboardEventHandler<HTMLElement>) =>
62
+ (event: React.KeyboardEvent<HTMLElement>) => {
63
+ if (event.key === "Enter" || event.key === " ") preventActivation(event);
64
+ else handler?.(event);
65
+ };
66
+
67
+ return {
68
+ onClickCapture: preventActivation,
69
+ onAuxClickCapture: preventActivation,
70
+ onDoubleClickCapture: preventActivation,
71
+ onPointerDownCapture: preventActivation,
72
+ onPointerUpCapture: preventActivation,
73
+ onMouseDownCapture: preventActivation,
74
+ onMouseUpCapture: preventActivation,
75
+ onKeyDownCapture: guardKey(props.onKeyDownCapture),
76
+ onKeyUpCapture: guardKey(props.onKeyUpCapture),
77
+ };
78
+ }
79
+
50
80
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
51
81
  (
52
82
  {
@@ -61,36 +91,54 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
61
91
  },
62
92
  ref,
63
93
  ) => {
64
- const Comp = asChild ? Slot : "button";
65
-
66
- // When using asChild, we can't add the loading spinner as it would create multiple children
67
- // which breaks Slot's single-child requirement
68
94
  if (asChild) {
95
+ const child = React.isValidElement<ButtonChildProps>(children) ? children : null;
96
+ const isDisabled = disabled || loading || child?.props.disabled;
97
+
98
+ // Slot composes child handlers first. Guard BOTH capture paths so neither
99
+ // child nor Button activation handlers run, without changing enabled order.
100
+ // Keep slotted content intact: its action name must survive loading too.
69
101
  return (
70
- <Comp
102
+ <Slot
71
103
  className={cn(buttonVariants({ variant, size, className }))}
72
104
  ref={ref}
73
105
  {...props}
106
+ {...(isDisabled ? disabledHandlers(props) : {})}
74
107
  >
75
- {children}
76
- </Comp>
108
+ {child ? React.cloneElement(child, {
109
+ ...(loading ? { "aria-busy": true } : {}),
110
+ ...(isDisabled ? {
111
+ "aria-disabled": true,
112
+ ...(child.type === "button" ? { disabled: true } : { tabIndex: -1 }),
113
+ // Removing href also prevents native context-menu / middle-click
114
+ // navigation. Keep link semantics while it is unavailable.
115
+ ...(child.type === "a" ? {
116
+ href: undefined,
117
+ role: child.props.role ?? props.role ?? "link",
118
+ } : {}),
119
+ ...disabledHandlers(child.props),
120
+ } : {}),
121
+ }) : children}
122
+ </Slot>
77
123
  );
78
124
  }
79
125
 
80
126
  return (
81
- <Comp
127
+ <button
82
128
  className={cn(buttonVariants({ variant, size, className }))}
83
129
  ref={ref}
84
- disabled={disabled || loading}
85
130
  {...props}
131
+ disabled={disabled || loading}
132
+ aria-busy={loading || props["aria-busy"]}
86
133
  >
87
134
  {loading && (
88
135
  <svg
89
- className="mr-2 -ml-1 h-4 w-4 animate-spin"
136
+ className="mr-2 -ml-1 h-4 w-4 animate-spin motion-reduce:animate-none"
137
+ aria-hidden="true"
138
+ focusable="false"
90
139
  fill="none"
91
140
  viewBox="0 0 24 24"
92
141
  >
93
- <title>Loading spinner</title>
94
142
  <circle
95
143
  className="opacity-25"
96
144
  cx="12"
@@ -107,7 +155,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
107
155
  </svg>
108
156
  )}
109
157
  {children}
110
- </Comp>
158
+ </button>
111
159
  );
112
160
  },
113
161
  );
@@ -0,0 +1,51 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Button } from "./button";
4
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
5
+ import { Heading } from "./heading";
6
+
7
+ const meta = {
8
+ title: "Primitives/Card/Anatomy",
9
+ component: Card,
10
+ parameters: { layout: "padded" },
11
+ } satisfies Meta<typeof Card>;
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ export const AlignedParts: Story = {
16
+ render: () => <div className="max-w-lg space-y-6">
17
+ <Heading variant="page">Activity details</Heading>
18
+ <Card data-anatomy="full">
19
+ <CardHeader data-part="header"><CardTitle as="h2">Latest result</CardTitle><CardDescription>One heading level below the page.</CardDescription></CardHeader>
20
+ <CardContent data-part="content">The header, body and footer share one horizontal gutter.</CardContent>
21
+ <CardFooter data-part="footer"><Button type="button" variant="outline">View result</Button></CardFooter>
22
+ </Card>
23
+ <Card data-anatomy="standalone"><CardContent data-part="content">Content-only cards retain their top padding.</CardContent></Card>
24
+ </div>,
25
+ play: ({ canvasElement }) => {
26
+ const parts = Array.from(canvasElement.querySelectorAll<HTMLElement>('[data-anatomy="full"] > [data-part]'));
27
+ const styles = parts.map((part) => getComputedStyle(part));
28
+ const gutter = styles[0]?.paddingLeft;
29
+ if (!gutter || parseFloat(gutter) <= 0 || styles.some((style) => style.paddingLeft !== gutter || style.paddingRight !== gutter)) {
30
+ throw new Error("Card parts do not share a nonzero horizontal gutter");
31
+ }
32
+ if (parseFloat(styles[1]!.paddingTop) !== 0 || parseFloat(styles[2]!.paddingTop) !== 0) {
33
+ throw new Error("Stacked card parts duplicate top padding");
34
+ }
35
+ const standalone = canvasElement.querySelector<HTMLElement>('[data-anatomy="standalone"] > [data-part]')!;
36
+ const style = getComputedStyle(standalone);
37
+ if (parseFloat(style.paddingTop) <= 0 || style.paddingTop !== style.paddingBottom) {
38
+ throw new Error("Content-only card lost its top padding");
39
+ }
40
+ },
41
+ };
42
+
43
+ function ActionCard() {
44
+ const [count, setCount] = useState(0);
45
+ return <Card hover className="max-w-sm">
46
+ <CardHeader><CardTitle as="h2">Decorative hover, native action</CardTitle><CardDescription>Tab to the button; Enter and Space both work.</CardDescription></CardHeader>
47
+ <CardContent><p role="status">Opened {count} times</p></CardContent>
48
+ <CardFooter><Button type="button" onClick={() => setCount((value) => value + 1)}>Open details</Button></CardFooter>
49
+ </Card>;
50
+ }
51
+ export const KeyboardAction: Story = { render: () => <ActionCard /> };