@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.
- package/CHANGELOG.md +17 -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} +22 -47
- 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 +46 -2
- package/src/markdown/code-block.tsx +23 -60
- 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,7 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
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,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
|
|
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
|
|
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=
|
|
133
|
-
<span className=
|
|
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={
|
|
109
|
+
style={syntaxTheme}
|
|
147
110
|
showLineNumbers={showLineNumbers}
|
|
148
111
|
lineNumberStyle={{
|
|
149
|
-
color:
|
|
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: '
|
|
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
|
|
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
|
-
<
|
|
102
|
+
<Slot
|
|
71
103
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
72
104
|
ref={ref}
|
|
73
105
|
{...props}
|
|
106
|
+
{...(isDisabled ? disabledHandlers(props) : {})}
|
|
74
107
|
>
|
|
75
|
-
{
|
|
76
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 /> };
|