@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.
- package/CHANGELOG.md +24 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/auth/auth.d.ts +53 -0
- package/dist/{auth-DOETfnd9.js → auth/auth.js} +7 -37
- package/dist/auth/login-layout.d.ts +20 -0
- package/dist/auth/login-layout.js +36 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +2 -1
- package/dist/chat/agent-timeline.d.ts +83 -0
- package/dist/chat/agent-timeline.js +236 -0
- package/dist/chat/chat-container.d.ts +51 -0
- package/dist/chat/chat-container.js +309 -0
- package/dist/chat/chat-message.d.ts +22 -0
- package/dist/chat/chat-message.js +38 -0
- package/dist/chat/message-list.d.ts +32 -0
- package/dist/chat/message-list.js +37 -0
- package/dist/chat/thinking-indicator.d.ts +6 -0
- package/dist/chat/thinking-indicator.js +38 -0
- package/dist/chat/user-message.d.ts +21 -0
- package/dist/chat/user-message.js +42 -0
- package/dist/chat.d.ts +6 -1
- package/dist/chat.js +6 -1
- package/dist/editor/document-editor-pane.d.ts +37 -0
- package/dist/editor/document-editor-pane.js +199 -0
- package/dist/editor/editor-lazy.js +37 -0
- package/dist/editor/editor-loading.js +21 -0
- package/dist/editor/editor-peers.d.ts +6 -0
- package/dist/editor/editor-peers.js +155 -0
- package/dist/editor/editor-provider.d.ts +97 -0
- package/dist/editor/editor-provider.js +253 -0
- package/dist/editor/editor-toolbar.d.ts +11 -0
- package/dist/editor/editor-toolbar.js +106 -0
- package/dist/editor/markdown-conversion.js +23 -0
- package/dist/editor/markdown-document-editor.js +103 -0
- package/dist/editor/tiptap-editor.d.ts +45 -0
- package/dist/editor/tiptap-editor.js +271 -0
- package/dist/editor/use-editor.d.ts +67 -0
- package/dist/editor/use-editor.js +158 -0
- package/dist/editor.d.ts +6 -119
- package/dist/editor.js +5 -1
- package/dist/files/file-artifact-pane.d.ts +47 -0
- package/dist/files/file-artifact-pane.js +114 -0
- package/dist/files/file-format.d.ts +51 -0
- package/dist/{file-format-BgXbr-Ib.js → files/file-format.js} +1 -1
- package/dist/files/file-preview.d.ts +27 -0
- package/dist/files/file-preview.js +359 -0
- package/dist/files/file-tabs.d.ts +19 -0
- package/dist/files/file-tabs.js +48 -0
- package/dist/files/file-tree.d.ts +31 -0
- package/dist/files/file-tree.js +135 -0
- package/dist/files/rich-file-tree.d.ts +62 -0
- package/dist/files/rich-file-tree.js +122 -0
- package/dist/files.d.ts +6 -1
- package/dist/files.js +6 -2
- package/dist/hooks/use-auth.d.ts +40 -0
- package/dist/hooks/use-auth.js +91 -0
- package/dist/hooks/use-auto-scroll.d.ts +11 -0
- package/dist/hooks/use-auto-scroll.js +48 -0
- package/dist/hooks/use-dropdown-menu.d.ts +12 -0
- package/dist/hooks/use-dropdown-menu.js +31 -0
- package/dist/hooks/use-live-time.d.ts +7 -0
- package/dist/hooks/use-live-time.js +22 -0
- package/dist/hooks/use-realtime-session.d.ts +32 -0
- package/dist/hooks/use-realtime-session.js +235 -0
- package/dist/hooks/use-run-collapse-state.d.ts +13 -0
- package/dist/hooks/use-run-collapse-state.js +24 -0
- package/dist/hooks/use-run-groups.d.ts +11 -0
- package/dist/hooks/use-run-groups.js +85 -0
- package/dist/hooks/use-sdk-session.d.ts +59 -0
- package/dist/hooks/use-sdk-session.js +322 -0
- package/dist/hooks/use-sse-stream.d.ts +124 -0
- package/dist/hooks/use-sse-stream.js +229 -0
- package/dist/hooks/use-tool-call-stream.d.ts +23 -0
- package/dist/hooks/use-tool-call-stream.js +75 -0
- package/dist/hooks.d.ts +11 -49
- package/dist/hooks.js +10 -110
- package/dist/index.d.ts +99 -25
- package/dist/index.js +86 -36
- package/dist/lib/focus.d.ts +55 -0
- package/dist/{focus-BcE_SHqz.js → lib/focus.js} +1 -1
- package/dist/lib/utils.d.ts +4 -0
- package/dist/{utils-C_uf36nf.js → lib/utils.js} +1 -1
- package/dist/{code-block-B9miUPht.d.ts → markdown/code-block.d.ts} +5 -6
- package/dist/{code-block-B36KVUSN.js → markdown/code-block.js} +60 -55
- package/dist/{markdown-C0atSabr.d.ts → markdown/markdown.d.ts} +4 -5
- package/dist/{markdown-Db08KAYF.js → markdown/markdown.js} +3 -3
- package/dist/markdown.d.ts +2 -2
- package/dist/markdown.js +2 -2
- package/dist/{openui-artifact-renderer-fkBQaoLO.d.ts → openui/openui-artifact-renderer.d.ts} +20 -21
- package/dist/{openui-artifact-renderer-DG_i2lOR.js → openui/openui-artifact-renderer.js} +9 -7
- package/dist/{schema-BpW9rSSG.js → openui/schema-compiled.js} +2 -354
- package/dist/{schema-DAstk-J4.d.ts → openui/schema-data.d.ts} +3 -24
- package/dist/openui/schema-data.js +206 -0
- package/dist/openui-schema.d.ts +22 -3
- package/dist/openui-schema.js +138 -1
- package/dist/openui.d.ts +3 -2
- package/dist/openui.js +3 -2
- package/dist/{artifact-pane-BL_DFWNl.d.ts → primitives/artifact-pane.d.ts} +3 -4
- package/dist/{artifact-pane-DMXT9a8U.js → primitives/artifact-pane.js} +2 -2
- package/dist/primitives/avatar.d.ts +8 -0
- package/dist/primitives/avatar.js +25 -0
- package/dist/primitives/badge.d.ts +22 -0
- package/dist/primitives/badge.js +47 -0
- package/dist/{button-yoIv77e2.d.ts → primitives/button.d.ts} +2 -2
- package/dist/primitives/button.js +113 -0
- package/dist/primitives/card.d.ts +18 -0
- package/dist/primitives/card.js +52 -0
- package/dist/primitives/code-block.d.ts +6 -0
- package/dist/primitives/code-block.js +13 -0
- package/dist/primitives/dialog.d.ts +23 -0
- package/dist/primitives/dialog.js +59 -0
- package/dist/primitives/drop-zone.d.ts +23 -0
- package/dist/primitives/drop-zone.js +104 -0
- package/dist/primitives/dropdown-menu.d.ts +29 -0
- package/dist/{dropdown-menu-BpcLa5vF.js → primitives/dropdown-menu.js} +2 -23
- package/dist/primitives/empty-state.d.ts +11 -0
- package/dist/primitives/empty-state.js +32 -0
- package/dist/primitives/heading.d.ts +23 -0
- package/dist/primitives/heading.js +38 -0
- package/dist/primitives/input.d.ts +23 -0
- package/dist/primitives/input.js +110 -0
- package/dist/primitives/label.d.ts +6 -0
- package/dist/primitives/label.js +16 -0
- package/dist/primitives/metric-strip.d.ts +48 -0
- package/dist/primitives/metric-strip.js +52 -0
- package/dist/primitives/page-header.d.ts +25 -0
- package/dist/primitives/page-header.js +47 -0
- package/dist/primitives/page-shell.d.ts +14 -0
- package/dist/primitives/page-shell.js +17 -0
- package/dist/primitives/progress.d.ts +9 -0
- package/dist/primitives/progress.js +29 -0
- package/dist/primitives/segmented-control.d.ts +40 -0
- package/dist/primitives/segmented-control.js +57 -0
- package/dist/primitives/select.d.ts +15 -0
- package/dist/primitives/select.js +74 -0
- package/dist/primitives/sidebar-drop-zone.d.ts +21 -0
- package/dist/primitives/sidebar-drop-zone.js +88 -0
- package/dist/primitives/skeleton.d.ts +10 -0
- package/dist/primitives/skeleton.js +50 -0
- package/dist/primitives/stat-card.d.ts +16 -0
- package/dist/primitives/stat-card.js +60 -0
- package/dist/primitives/status-pill.d.ts +44 -0
- package/dist/primitives/status-pill.js +119 -0
- package/dist/primitives/switch.d.ts +6 -0
- package/dist/primitives/switch.js +16 -0
- package/dist/primitives/table.d.ts +22 -0
- package/dist/primitives/table.js +63 -0
- package/dist/primitives/tabs.d.ts +13 -0
- package/dist/{tabs-BVHR6NkO.js → primitives/tabs.js} +3 -3
- package/dist/primitives/terminal-display.d.ts +25 -0
- package/dist/primitives/terminal-display.js +109 -0
- package/dist/primitives/text-shimmer.d.ts +16 -0
- package/dist/{text-shimmer-BUUo6I-g.js → primitives/text-shimmer.js} +2 -2
- package/dist/primitives/theme-toggle.d.ts +11 -0
- package/dist/primitives/theme-toggle.js +154 -0
- package/dist/primitives/toast.d.ts +29 -0
- package/dist/primitives/toast.js +142 -0
- package/dist/primitives/toolbar.d.ts +55 -0
- package/dist/primitives/toolbar.js +37 -0
- package/dist/primitives/upload-progress.d.ts +23 -0
- package/dist/primitives/upload-progress.js +72 -0
- package/dist/primitives.d.ts +35 -5
- package/dist/primitives.js +35 -9
- package/dist/{index-COgqsFAQ.d.ts → redaction/redacted-document.d.ts} +6 -7
- package/dist/{redaction-CGGB2ENG.js → redaction/redacted-document.js} +2 -2
- package/dist/redaction.d.ts +1 -1
- package/dist/redaction.js +1 -1
- package/dist/run/expandable-content.js +41 -0
- package/dist/run/expanded-tool-detail.d.ts +12 -0
- package/dist/run/expanded-tool-detail.js +163 -0
- package/dist/run/inline-thinking-item.d.ts +11 -0
- package/dist/run/inline-thinking-item.js +60 -0
- package/dist/run/inline-tool-item.d.ts +25 -0
- package/dist/run/inline-tool-item.js +63 -0
- package/dist/run/run-group.d.ts +26 -0
- package/dist/{run-group-BWGeesgs.js → run/run-group.js} +9 -60
- package/dist/run/run-item-primitives.d.ts +5 -0
- package/dist/{run-ZsIKPA6J.js → run/run-item-primitives.js} +2 -2
- package/dist/run/run-row-shell.d.ts +53 -0
- package/dist/run/run-row-shell.js +114 -0
- package/dist/{tool-call-feed-BeB1_4pX.d.ts → run/tool-call-feed.d.ts} +7 -12
- package/dist/{tool-call-feed-BpmMLpPX.js → run/tool-call-feed.js} +4 -4
- package/dist/run/tool-call-step.d.ts +5 -0
- package/dist/run/tool-call-step.js +82 -0
- package/dist/run.d.ts +8 -2
- package/dist/run.js +7 -4
- package/dist/sdk-hooks.d.ts +8 -1
- package/dist/sdk-hooks.js +8 -2
- package/dist/stores/active-sessions-store.d.ts +83 -0
- package/dist/{active-sessions-store-Gt1R0rFG.js → stores/active-sessions-store.js} +1 -1
- package/dist/stores/chat-store.d.ts +14 -0
- package/dist/stores/chat-store.js +33 -0
- package/dist/stores.d.ts +3 -16
- package/dist/stores.js +2 -33
- package/dist/tool-previews/command-preview.d.ts +11 -0
- package/dist/tool-previews/command-preview.js +91 -0
- package/dist/tool-previews/diff-preview.d.ts +7 -0
- package/dist/tool-previews/diff-preview.js +76 -0
- package/dist/tool-previews/glob-results-preview.d.ts +7 -0
- package/dist/tool-previews/glob-results-preview.js +53 -0
- package/dist/tool-previews/grep-results-preview.d.ts +7 -0
- package/dist/tool-previews/grep-results-preview.js +99 -0
- package/dist/tool-previews/preview-primitives.js +58 -0
- package/dist/tool-previews/question-preview.d.ts +7 -0
- package/dist/tool-previews/question-preview.js +63 -0
- package/dist/tool-previews/web-search-preview.d.ts +7 -0
- package/dist/tool-previews/web-search-preview.js +78 -0
- package/dist/tool-previews/write-file-preview.d.ts +11 -0
- package/dist/tool-previews/write-file-preview.js +52 -0
- package/dist/tool-previews.d.ts +7 -1
- package/dist/tool-previews.js +7 -1
- package/dist/{branding-26d5ARIr.d.ts → types/branding.d.ts} +2 -3
- package/dist/{message-vhUI0c5G.d.ts → types/message.d.ts} +2 -3
- package/dist/{parts-5mhy9yJ3.d.ts → types/parts.d.ts} +8 -9
- package/dist/{run-OyWmIDqu.d.ts → types/run.d.ts} +9 -10
- package/dist/{tool-display-DgmL5rIU.d.ts → types/tool-display.d.ts} +5 -6
- package/dist/types.d.ts +5 -5
- package/dist/utils/copy-text.d.ts +7 -0
- package/dist/{utils-Bl_CZ0Qp.js → utils/copy-text.js} +1 -11
- package/dist/utils/format.d.ts +22 -0
- package/dist/{format-DfcLWqMa.js → utils/format.js} +1 -1
- package/dist/utils/time-ago.d.ts +3 -0
- package/dist/utils/time-ago.js +11 -0
- package/dist/utils/tool-display.d.ts +16 -0
- package/dist/{tool-display-BAuAVa43.js → utils/tool-display.js} +1 -1
- package/dist/utils.d.ts +6 -1
- package/dist/utils.js +6 -5
- package/package.json +2 -2
- package/src/files/rich-file-tree.stories.tsx +1 -1
- package/src/files/rich-file-tree.tsx +12 -9
- package/src/markdown/code-block.test.tsx +80 -2
- package/src/markdown/code-block.tsx +76 -80
- package/src/primitives/button.stories.tsx +37 -1
- package/src/primitives/button.test.tsx +230 -0
- package/src/primitives/button.tsx +62 -14
- package/src/primitives/card-anatomy.stories.tsx +51 -0
- package/src/primitives/card.test.tsx +71 -0
- package/src/primitives/card.tsx +25 -13
- package/src/primitives/heading.stories.tsx +35 -0
- package/src/primitives/heading.test.tsx +56 -0
- package/src/primitives/heading.tsx +76 -0
- package/src/primitives/index.ts +8 -0
- package/src/primitives/input.stories.tsx +28 -0
- package/src/primitives/input.test.tsx +161 -0
- package/src/primitives/input.tsx +34 -28
- package/src/primitives/page-header.stories.tsx +51 -0
- package/src/primitives/page-header.test.tsx +48 -0
- package/src/primitives/page-header.tsx +35 -35
- package/src/primitives/page-shell.stories.tsx +89 -0
- package/src/primitives/page-shell.test.tsx +24 -0
- package/src/primitives/page-shell.tsx +26 -0
- package/src/primitives/table-wrapper.stories.tsx +54 -0
- package/src/primitives/table.test.tsx +72 -0
- package/src/primitives/table.tsx +33 -12
- package/src/primitives/theme-toggle.test.tsx +167 -0
- package/src/primitives/theme-toggle.tsx +111 -39
- package/dist/active-sessions-store-nl70ZnMC.d.ts +0 -84
- package/dist/button-C87nEe2R.js +0 -78
- package/dist/chat-DDvF2oGQ.js +0 -672
- package/dist/diff-preview-BcK3nRXb.js +0 -533
- package/dist/document-editor-pane-BilJfiCo.d.ts +0 -136
- package/dist/document-editor-pane-POsq5Q0o.js +0 -1279
- package/dist/files-hywocVZz.js +0 -753
- package/dist/index-2UntyjBb.d.ts +0 -52
- package/dist/index-BgUHilZm.d.ts +0 -108
- package/dist/index-CJ8wRfiC.d.ts +0 -236
- package/dist/index-CPDET_s1.d.ts +0 -549
- package/dist/index-Cg6gYAqh.d.ts +0 -73
- package/dist/index-Cs7j8V7l.d.ts +0 -203
- package/dist/index-DjKxO357.d.ts +0 -128
- package/dist/primitives-Bx59fT-U.js +0 -1239
- package/dist/table-Bss73DhB.js +0 -146
- package/dist/tool-call-step-DNebqhmX.js +0 -434
- package/dist/use-auto-scroll-C5Pjeqon.js +0 -153
- package/dist/use-sse-stream-DjqRuDft.js +0 -884
- package/dist/use-sse-stream-Dm6R5YWz.d.ts +0 -282
|
@@ -1,13 +1,91 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
5
|
import { CodeBlock, CopyButton } from "./code-block";
|
|
4
6
|
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("CodeBlock scoped semantic colors", () => {
|
|
10
|
+
it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
|
|
11
|
+
const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
|
|
12
|
+
expect(container.querySelector("pre code")?.textContent).toBe('const message = "hello";');
|
|
13
|
+
await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"));
|
|
14
|
+
expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
|
|
15
|
+
expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
|
|
16
|
+
expect(container.querySelector(".react-syntax-highlighter-line-number")).not.toBeNull();
|
|
17
|
+
expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("inherits a named scope by default without inserting its own mode", () => {
|
|
21
|
+
const { container } = render(<section data-theme="arena-light"><CodeBlock data-testid="code" code="const x = 1" language="javascript" /></section>);
|
|
22
|
+
const code = screen.getByTestId("code");
|
|
23
|
+
expect(code.hasAttribute("data-theme")).toBe(false);
|
|
24
|
+
const before = code.innerHTML;
|
|
25
|
+
container.querySelector("section")!.setAttribute("data-theme", "arena");
|
|
26
|
+
expect(code.innerHTML).toBe(before); // References stay live; the browser fixture checks the computed colors.
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each([true, false])("preserves explicit light=%s as an opposite nested mode", (light) => {
|
|
30
|
+
render(<section data-theme={light ? "dark" : "light"}><CodeBlock data-testid="code" code="x" light={light} /></section>);
|
|
31
|
+
expect(screen.getByTestId("code").getAttribute("data-theme")).toBe(light ? "light" : "dark");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("preserves caller attributes, labels, children and custom scopes", () => {
|
|
35
|
+
render(<CodeBlock data-testid="code" data-theme="aubergine" className="caller" code="echo hello" language="bash" label="BASHRC"><button>Copy example</button></CodeBlock>);
|
|
36
|
+
expect(screen.getByTestId("code").getAttribute("data-theme")).toBe("aubergine");
|
|
37
|
+
expect(screen.getByTestId("code").classList.contains("caller")).toBe(true);
|
|
38
|
+
expect(screen.getByText("BASHRC")).toBeTruthy();
|
|
39
|
+
expect(screen.getByRole("button", { name: "Copy example" })).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("server-renders readable code with the same semantic references independent of document theme", () => {
|
|
43
|
+
const source = <CodeBlock code="const x = 1" language="javascript" />;
|
|
44
|
+
document.documentElement.className = "dark";
|
|
45
|
+
const dark = renderToString(source);
|
|
46
|
+
expect(dark).toContain("const x = 1");
|
|
47
|
+
expect(dark).toContain("var(--syntax-foreground, currentColor)");
|
|
48
|
+
document.documentElement.className = "light";
|
|
49
|
+
expect(renderToString(source)).toBe(dark);
|
|
50
|
+
document.documentElement.removeAttribute("class");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Retain the existing header/label/copy-slot contracts alongside theme coverage.
|
|
5
55
|
describe("CodeBlock", () => {
|
|
6
56
|
it("renders the code through the syntax highlighter", () => {
|
|
7
57
|
const { container } = render(<CodeBlock code={"const x = 1;"} language="typescript" />);
|
|
8
58
|
expect(container.querySelector("code")).not.toBeNull();
|
|
9
59
|
});
|
|
10
60
|
|
|
61
|
+
it.each([
|
|
62
|
+
["javascript", "const answer = 42;"],
|
|
63
|
+
["js", "const answer = 42;"],
|
|
64
|
+
["brainfuck", "++[>+++<-]"],
|
|
65
|
+
["not-a-language", "const answer = 42;"],
|
|
66
|
+
])("keeps the default highlighter's tokens for language %s", async (language, code) => {
|
|
67
|
+
const baseline = document.createElement("div");
|
|
68
|
+
baseline.innerHTML = renderToString(<SyntaxHighlighter language={language}>{code}</SyntaxHighlighter>);
|
|
69
|
+
const expected = Array.from(baseline.querySelectorAll("pre code span"), (span) => span.textContent);
|
|
70
|
+
expect(expected.length).toBeGreaterThan(0);
|
|
71
|
+
|
|
72
|
+
const { container } = render(<CodeBlock code={code} language={language} />);
|
|
73
|
+
await waitFor(() => expect(container.querySelectorAll("pre code span").length).toBe(expected.length));
|
|
74
|
+
expect(Array.from(container.querySelectorAll("pre code span"), (span) => span.textContent)).toEqual(expected);
|
|
75
|
+
expect(container.querySelector("pre code")?.textContent).toBe(code);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("preserves whitespace and line numbering after the renderer loads", async () => {
|
|
79
|
+
const code = "\tfirst \n second\n";
|
|
80
|
+
const baseline = document.createElement("div");
|
|
81
|
+
baseline.innerHTML = renderToString(<SyntaxHighlighter language="text" showLineNumbers>{code}</SyntaxHighlighter>);
|
|
82
|
+
const { container } = render(<CodeBlock code={code} language="text" showLineNumbers />);
|
|
83
|
+
expect(container.querySelector("pre code")?.textContent).toBe(code);
|
|
84
|
+
await waitFor(() => expect(container.querySelectorAll(".react-syntax-highlighter-line-number").length)
|
|
85
|
+
.toBe(baseline.querySelectorAll(".react-syntax-highlighter-line-number").length));
|
|
86
|
+
expect(container.querySelector("pre code")?.textContent).toBe(baseline.querySelector("pre code")?.textContent);
|
|
87
|
+
});
|
|
88
|
+
|
|
11
89
|
it("uses `label` as the header text, overriding `language`", () => {
|
|
12
90
|
const { getByText, queryByText } = render(
|
|
13
91
|
<CodeBlock code={"x"} language="typescript" label="config.ts" />,
|
|
@@ -7,28 +7,24 @@ import {
|
|
|
7
7
|
type HTMLAttributes,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
|
-
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
10
|
+
import type SyntaxHighlighter from "react-syntax-highlighter";
|
|
11
11
|
import { Check, Copy } from "lucide-react";
|
|
12
12
|
import { focusRing } from "../lib/focus";
|
|
13
13
|
import { cn } from "../lib/utils";
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
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,11 +63,22 @@ function getSyntaxTheme(): { [key: string]: React.CSSProperties } {
|
|
|
67
63
|
"hljs-emphasis": { fontStyle: "italic" },
|
|
68
64
|
"hljs": { color: fg, background: "transparent" },
|
|
69
65
|
};
|
|
66
|
+
})();
|
|
67
|
+
|
|
68
|
+
type Highlighter = typeof SyntaxHighlighter;
|
|
69
|
+
let highlighterPromise: Promise<Highlighter> | undefined;
|
|
70
|
+
|
|
71
|
+
function loadHighlighter(): Promise<Highlighter> {
|
|
72
|
+
// Preserve the default renderer's language behavior, including alias inputs
|
|
73
|
+
// and unknown-language auto-detection. Its light async variant differs.
|
|
74
|
+
return highlighterPromise ??= import("react-syntax-highlighter/dist/esm/default-highlight")
|
|
75
|
+
.then((module) => module.default)
|
|
76
|
+
.catch((error: unknown) => {
|
|
77
|
+
highlighterPromise = undefined;
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
// tangleLight removed — getSyntaxTheme() reads --syntax-* CSS vars which are overridden
|
|
73
|
-
// per theme in tokens.css (vault, dawn themes set light values).
|
|
74
|
-
|
|
75
82
|
export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
|
|
76
83
|
code: string;
|
|
77
84
|
language?: string;
|
|
@@ -82,55 +89,45 @@ export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
82
89
|
*/
|
|
83
90
|
label?: string;
|
|
84
91
|
showLineNumbers?: boolean;
|
|
85
|
-
/** Force light
|
|
92
|
+
/** Force light (true) or dark (false); omitted inherits the nearest theme. */
|
|
86
93
|
light?: boolean;
|
|
87
94
|
children?: ReactNode;
|
|
88
95
|
}
|
|
89
96
|
|
|
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
97
|
export const CodeBlock = memo(
|
|
117
|
-
({ code, language, label, showLineNumbers = false, light
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
98
|
+
({ code, language, label, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
|
|
99
|
+
const [Highlighter, setHighlighter] = useState<Highlighter | null>(null);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
let active = true;
|
|
102
|
+
void loadHighlighter().then((component) => {
|
|
103
|
+
if (active) setHighlighter(() => component);
|
|
104
|
+
}).catch(() => {
|
|
105
|
+
// A failed chunk leaves the code readable; a later mount can retry.
|
|
106
|
+
});
|
|
107
|
+
return () => { active = false; };
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
124
110
|
const headerLabel = label ?? language;
|
|
111
|
+
const mode = light === undefined ? {} : { "data-theme": light ? "light" : "dark" };
|
|
112
|
+
const customStyle = {
|
|
113
|
+
margin: 0,
|
|
114
|
+
padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
|
|
115
|
+
background: "transparent",
|
|
116
|
+
fontSize: "var(--code-font-size, 0.8125rem)",
|
|
117
|
+
lineHeight: "var(--code-line-height, 1.5)",
|
|
118
|
+
overflowX: "auto" as const,
|
|
119
|
+
};
|
|
120
|
+
const codeTagProps = { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } };
|
|
125
121
|
|
|
126
122
|
return (
|
|
127
123
|
<div
|
|
128
|
-
className={cn("group relative overflow-hidden rounded-lg border font-mono", bg, className)}
|
|
124
|
+
className={cn("group relative overflow-hidden rounded-lg border font-mono", "bg-card border-border", className)}
|
|
129
125
|
{...props}
|
|
126
|
+
{...mode}
|
|
130
127
|
>
|
|
131
128
|
{headerLabel && (
|
|
132
|
-
<div className=
|
|
133
|
-
<span className=
|
|
129
|
+
<div className="flex items-center justify-between border-b border-border px-3 py-1 bg-[var(--code-header-bg,hsl(var(--background)))]">
|
|
130
|
+
<span className="text-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest text-muted-foreground">
|
|
134
131
|
{headerLabel}
|
|
135
132
|
</span>
|
|
136
133
|
{children}
|
|
@@ -141,28 +138,27 @@ export const CodeBlock = memo(
|
|
|
141
138
|
{children}
|
|
142
139
|
</div>
|
|
143
140
|
)}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
</SyntaxHighlighter>
|
|
141
|
+
{Highlighter ? (
|
|
142
|
+
<Highlighter
|
|
143
|
+
language={language ?? "text"}
|
|
144
|
+
style={syntaxTheme}
|
|
145
|
+
showLineNumbers={showLineNumbers}
|
|
146
|
+
lineNumberStyle={{
|
|
147
|
+
color: "var(--syntax-comment, currentColor)",
|
|
148
|
+
minWidth: "2.5em",
|
|
149
|
+
paddingRight: "1em",
|
|
150
|
+
}}
|
|
151
|
+
customStyle={customStyle}
|
|
152
|
+
codeTagProps={codeTagProps}
|
|
153
|
+
wrapLines={false}
|
|
154
|
+
>
|
|
155
|
+
{code}
|
|
156
|
+
</Highlighter>
|
|
157
|
+
) : (
|
|
158
|
+
<pre style={{ ...syntaxTheme.hljs, ...customStyle }}>
|
|
159
|
+
<code style={{ ...codeTagProps.style, whiteSpace: "pre" }}>{code}</code>
|
|
160
|
+
</pre>
|
|
161
|
+
)}
|
|
166
162
|
</div>
|
|
167
163
|
);
|
|
168
164
|
},
|
|
@@ -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: '
|
|
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
|
+
});
|