@tangle-network/ui 11.13.0 → 11.14.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 +13 -0
- package/dist/markdown/code-block.js +41 -11
- package/dist/primitives/dialog.d.ts +6 -0
- package/dist/primitives/dialog.js +2 -2
- package/package.json +1 -1
- package/src/markdown/code-block.test.tsx +38 -4
- package/src/markdown/code-block.tsx +56 -23
- package/src/primitives/dialog-dismissal.stories.tsx +48 -0
- package/src/primitives/dialog.test.tsx +75 -0
- package/src/primitives/dialog.tsx +18 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 999438e: Add DialogContent hideCloseButton with a false default. It hides only the built-in close button, preserving Radix dismissal handlers, explicit close actions, variants and refs. Callers that require an explicit choice must still guard Escape/outside events and provide an accessible cancel or completion action.
|
|
8
|
+
|
|
9
|
+
## 11.13.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 5cbda07: Load the full Highlight.js syntax renderer only when a code block mounts.
|
|
14
|
+
Keep code readable until highlighting arrives, and leave prose-only Markdown free of syntax-engine requests.
|
|
15
|
+
|
|
3
16
|
## 11.13.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
|
@@ -3,7 +3,6 @@ import { cn } from "../lib/utils.js";
|
|
|
3
3
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
import { Check, Copy } from "lucide-react";
|
|
6
|
-
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
7
6
|
//#region src/markdown/code-block.tsx
|
|
8
7
|
const syntaxTheme = (() => {
|
|
9
8
|
const comment = "var(--syntax-comment, currentColor)";
|
|
@@ -65,9 +64,35 @@ const syntaxTheme = (() => {
|
|
|
65
64
|
}
|
|
66
65
|
};
|
|
67
66
|
})();
|
|
67
|
+
let highlighterPromise;
|
|
68
|
+
function loadHighlighter() {
|
|
69
|
+
return highlighterPromise ??= import("react-syntax-highlighter/dist/esm/default-highlight").then((module) => module.default).catch((error) => {
|
|
70
|
+
highlighterPromise = void 0;
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
68
74
|
const CodeBlock = memo(({ code, language, label, showLineNumbers = false, light, className, children, ...props }) => {
|
|
75
|
+
const [Highlighter, setHighlighter] = useState(null);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
let active = true;
|
|
78
|
+
loadHighlighter().then((component) => {
|
|
79
|
+
if (active) setHighlighter(() => component);
|
|
80
|
+
}).catch(() => {});
|
|
81
|
+
return () => {
|
|
82
|
+
active = false;
|
|
83
|
+
};
|
|
84
|
+
}, []);
|
|
69
85
|
const headerLabel = label ?? language;
|
|
70
86
|
const mode = light === void 0 ? {} : { "data-theme": light ? "light" : "dark" };
|
|
87
|
+
const customStyle = {
|
|
88
|
+
margin: 0,
|
|
89
|
+
padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
|
|
90
|
+
background: "transparent",
|
|
91
|
+
fontSize: "var(--code-font-size, 0.8125rem)",
|
|
92
|
+
lineHeight: "var(--code-line-height, 1.5)",
|
|
93
|
+
overflowX: "auto"
|
|
94
|
+
};
|
|
95
|
+
const codeTagProps = { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } };
|
|
71
96
|
return /* @__PURE__ */ jsxs("div", {
|
|
72
97
|
className: cn("group relative overflow-hidden rounded-lg border font-mono", "bg-card border-border", className),
|
|
73
98
|
...props,
|
|
@@ -84,7 +109,7 @@ const CodeBlock = memo(({ code, language, label, showLineNumbers = false, light,
|
|
|
84
109
|
className: "absolute right-2 top-2 z-10 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100",
|
|
85
110
|
children
|
|
86
111
|
}),
|
|
87
|
-
/* @__PURE__ */ jsx(
|
|
112
|
+
Highlighter ? /* @__PURE__ */ jsx(Highlighter, {
|
|
88
113
|
language: language ?? "text",
|
|
89
114
|
style: syntaxTheme,
|
|
90
115
|
showLineNumbers,
|
|
@@ -93,17 +118,22 @@ const CodeBlock = memo(({ code, language, label, showLineNumbers = false, light,
|
|
|
93
118
|
minWidth: "2.5em",
|
|
94
119
|
paddingRight: "1em"
|
|
95
120
|
},
|
|
96
|
-
customStyle
|
|
97
|
-
|
|
98
|
-
padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
|
|
99
|
-
background: "transparent",
|
|
100
|
-
fontSize: "var(--code-font-size, 0.8125rem)",
|
|
101
|
-
lineHeight: "var(--code-line-height, 1.5)",
|
|
102
|
-
overflowX: "auto"
|
|
103
|
-
},
|
|
104
|
-
codeTagProps: { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } },
|
|
121
|
+
customStyle,
|
|
122
|
+
codeTagProps,
|
|
105
123
|
wrapLines: false,
|
|
106
124
|
children: code
|
|
125
|
+
}) : /* @__PURE__ */ jsx("pre", {
|
|
126
|
+
style: {
|
|
127
|
+
...syntaxTheme.hljs,
|
|
128
|
+
...customStyle
|
|
129
|
+
},
|
|
130
|
+
children: /* @__PURE__ */ jsx("code", {
|
|
131
|
+
style: {
|
|
132
|
+
...codeTagProps.style,
|
|
133
|
+
whiteSpace: "pre"
|
|
134
|
+
},
|
|
135
|
+
children: code
|
|
136
|
+
})
|
|
107
137
|
})
|
|
108
138
|
]
|
|
109
139
|
});
|
|
@@ -8,6 +8,12 @@ declare const DialogClose: React.ForwardRefExoticComponent<DialogPrimitive.Dialo
|
|
|
8
8
|
declare const DialogOverlay: React.ForwardRefExoticComponent<Omit<DialogPrimitive.DialogOverlayProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
|
|
9
9
|
declare const DialogContent: React.ForwardRefExoticComponent<Omit<DialogPrimitive.DialogContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & {
|
|
10
10
|
variant?: "default" | "sandbox";
|
|
11
|
+
/**
|
|
12
|
+
* Hide only the built-in close button. Escape/outside dismissal still follows
|
|
13
|
+
* Radix's handlers; prevent those events explicitly when a choice is required
|
|
14
|
+
* and provide an accessible way to finish or cancel the operation.
|
|
15
|
+
*/
|
|
16
|
+
hideCloseButton?: boolean;
|
|
11
17
|
} & React.RefAttributes<HTMLDivElement>>;
|
|
12
18
|
declare function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element;
|
|
13
19
|
declare namespace DialogHeader {
|
|
@@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => /* @__P
|
|
|
15
15
|
...props
|
|
16
16
|
}));
|
|
17
17
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
18
|
-
const DialogContent = React.forwardRef(({ className, children, variant = "default", ...props }, ref) => {
|
|
18
|
+
const DialogContent = React.forwardRef(({ className, children, variant = "default", hideCloseButton = false, ...props }, ref) => {
|
|
19
19
|
return /* @__PURE__ */ jsxs(DialogPortal, { children: [/* @__PURE__ */ jsx(DialogOverlay, {}), /* @__PURE__ */ jsxs(DialogPrimitive.Content, {
|
|
20
20
|
ref,
|
|
21
21
|
className: cn("fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]", "gap-4 rounded-lg border bg-card p-6 shadow-lg duration-200", "data-[state=closed]:animate-out data-[state=open]:animate-in", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]", "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]", {
|
|
@@ -23,7 +23,7 @@ const DialogContent = React.forwardRef(({ className, children, variant = "defaul
|
|
|
23
23
|
sandbox: "border-[var(--border-accent)] shadow-[var(--shadow-accent)]"
|
|
24
24
|
}[variant], className),
|
|
25
25
|
...props,
|
|
26
|
-
children: [children, /* @__PURE__ */ jsxs(DialogPrimitive.Close, {
|
|
26
|
+
children: [children, !hideCloseButton && /* @__PURE__ */ jsxs(DialogPrimitive.Close, {
|
|
27
27
|
className: cn("absolute top-4 right-4 rounded-sm opacity-70 transition-[opacity,box-shadow] hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground", focusRing),
|
|
28
28
|
children: [/* @__PURE__ */ jsx(X, { className: "h-4 w-4" }), /* @__PURE__ */ jsx("span", {
|
|
29
29
|
className: "sr-only",
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { cleanup, render, screen } from "@testing-library/react";
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import { renderToString } from "react-dom/server";
|
|
3
|
+
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
3
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
5
|
import { CodeBlock, CopyButton } from "./code-block";
|
|
5
6
|
|
|
6
7
|
afterEach(cleanup);
|
|
7
8
|
|
|
8
9
|
describe("CodeBlock scoped semantic colors", () => {
|
|
9
|
-
it("
|
|
10
|
+
it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
|
|
10
11
|
const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
|
|
11
|
-
expect(container.
|
|
12
|
+
expect(container.querySelector("pre code")?.textContent).toBe('const message = "hello";');
|
|
13
|
+
await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"));
|
|
12
14
|
expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
|
|
13
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";');
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
it("inherits a named scope by default without inserting its own mode", () => {
|
|
@@ -35,10 +39,12 @@ describe("CodeBlock scoped semantic colors", () => {
|
|
|
35
39
|
expect(screen.getByRole("button", { name: "Copy example" })).toBeTruthy();
|
|
36
40
|
});
|
|
37
41
|
|
|
38
|
-
it("server-renders the same semantic references independent of document theme", () => {
|
|
42
|
+
it("server-renders readable code with the same semantic references independent of document theme", () => {
|
|
39
43
|
const source = <CodeBlock code="const x = 1" language="javascript" />;
|
|
40
44
|
document.documentElement.className = "dark";
|
|
41
45
|
const dark = renderToString(source);
|
|
46
|
+
expect(dark).toContain("const x = 1");
|
|
47
|
+
expect(dark).toContain("var(--syntax-foreground, currentColor)");
|
|
42
48
|
document.documentElement.className = "light";
|
|
43
49
|
expect(renderToString(source)).toBe(dark);
|
|
44
50
|
document.documentElement.removeAttribute("class");
|
|
@@ -52,6 +58,34 @@ describe("CodeBlock", () => {
|
|
|
52
58
|
expect(container.querySelector("code")).not.toBeNull();
|
|
53
59
|
});
|
|
54
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
|
+
|
|
55
89
|
it("uses `label` as the header text, overriding `language`", () => {
|
|
56
90
|
const { getByText, queryByText } = render(
|
|
57
91
|
<CodeBlock code={"x"} language="typescript" label="config.ts" />,
|
|
@@ -7,7 +7,7 @@ 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";
|
|
@@ -65,6 +65,20 @@ const syntaxTheme: { [key: string]: React.CSSProperties } = (() => {
|
|
|
65
65
|
};
|
|
66
66
|
})();
|
|
67
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
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
|
|
69
83
|
code: string;
|
|
70
84
|
language?: string;
|
|
@@ -82,8 +96,28 @@ export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
82
96
|
|
|
83
97
|
export const CodeBlock = memo(
|
|
84
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
|
+
|
|
85
110
|
const headerLabel = label ?? language;
|
|
86
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)" } };
|
|
87
121
|
|
|
88
122
|
return (
|
|
89
123
|
<div
|
|
@@ -104,28 +138,27 @@ export const CodeBlock = memo(
|
|
|
104
138
|
{children}
|
|
105
139
|
</div>
|
|
106
140
|
)}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
</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
|
+
)}
|
|
129
162
|
</div>
|
|
130
163
|
);
|
|
131
164
|
},
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./dialog";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Primitives/Dialog/Dismissal",
|
|
8
|
+
component: DialogContent,
|
|
9
|
+
parameters: { layout: "centered" },
|
|
10
|
+
} satisfies Meta<typeof DialogContent>;
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
function ExplicitChoiceExample() {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [choice, setChoice] = useState("No choice recorded.");
|
|
17
|
+
const finish = (value: string) => {
|
|
18
|
+
setChoice(value);
|
|
19
|
+
setOpen(false);
|
|
20
|
+
};
|
|
21
|
+
return (
|
|
22
|
+
<div className="space-y-4">
|
|
23
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
24
|
+
<DialogTrigger asChild><Button>Review operation</Button></DialogTrigger>
|
|
25
|
+
<DialogContent
|
|
26
|
+
hideCloseButton
|
|
27
|
+
onEscapeKeyDown={(event) => event.preventDefault()}
|
|
28
|
+
onInteractOutside={(event) => event.preventDefault()}
|
|
29
|
+
>
|
|
30
|
+
<DialogHeader>
|
|
31
|
+
<DialogTitle>Continue this operation?</DialogTitle>
|
|
32
|
+
<DialogDescription>
|
|
33
|
+
This example requires an explicit choice. Hiding the close button
|
|
34
|
+
alone does not block Escape or outside dismissal; these handlers do.
|
|
35
|
+
</DialogDescription>
|
|
36
|
+
</DialogHeader>
|
|
37
|
+
<DialogFooter>
|
|
38
|
+
<Button variant="outline" onClick={() => finish("Cancelled in this example.")}>Cancel</Button>
|
|
39
|
+
<Button onClick={() => finish("Confirmed in this example.")}>Confirm</Button>
|
|
40
|
+
</DialogFooter>
|
|
41
|
+
</DialogContent>
|
|
42
|
+
</Dialog>
|
|
43
|
+
<output className="block text-sm text-muted-foreground" aria-live="polite">{choice}</output>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const ExplicitChoice: Story = { render: () => <ExplicitChoiceExample /> };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createRef, type ComponentProps } from "react";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "./dialog";
|
|
6
|
+
|
|
7
|
+
function Example(props: ComponentProps<typeof DialogContent>) {
|
|
8
|
+
return (
|
|
9
|
+
<Dialog defaultOpen>
|
|
10
|
+
<DialogContent {...props}>
|
|
11
|
+
<DialogTitle>Confirm operation</DialogTitle>
|
|
12
|
+
<DialogDescription>Choose whether to continue.</DialogDescription>
|
|
13
|
+
<DialogClose asChild><button type="button">Cancel operation</button></DialogClose>
|
|
14
|
+
</DialogContent>
|
|
15
|
+
</Dialog>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("DialogContent close-button visibility", () => {
|
|
20
|
+
it("retains the default accessible close button and its dismissal behavior", async () => {
|
|
21
|
+
const user = userEvent.setup();
|
|
22
|
+
render(<Example />);
|
|
23
|
+
expect(screen.getByRole("dialog", { name: "Confirm operation" })).toBeInTheDocument();
|
|
24
|
+
await user.click(screen.getByRole("button", { name: "Close", exact: true }));
|
|
25
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each(["default", "sandbox"] as const)("hides only the built-in button for the %s variant", async (variant) => {
|
|
29
|
+
const user = userEvent.setup();
|
|
30
|
+
render(<Example hideCloseButton variant={variant} />);
|
|
31
|
+
expect(screen.queryByRole("button", { name: "Close", exact: true })).toBeNull();
|
|
32
|
+
expect(screen.getByRole("dialog")).not.toHaveAttribute("hideCloseButton");
|
|
33
|
+
expect(screen.getByRole("dialog")).toHaveAccessibleDescription("Choose whether to continue.");
|
|
34
|
+
await user.click(screen.getByRole("button", { name: "Cancel operation" }));
|
|
35
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("restores the same default button when visibility changes", () => {
|
|
39
|
+
const { rerender } = render(<Example hideCloseButton />);
|
|
40
|
+
expect(screen.queryByRole("button", { name: "Close", exact: true })).toBeNull();
|
|
41
|
+
rerender(<Example hideCloseButton={false} />);
|
|
42
|
+
expect(screen.getByRole("button", { name: "Close", exact: true })).toBeInTheDocument();
|
|
43
|
+
rerender(<Example hideCloseButton />);
|
|
44
|
+
expect(screen.queryByRole("button", { name: "Close", exact: true })).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not silently disable Escape when the close button is hidden", async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
render(<Example hideCloseButton />);
|
|
50
|
+
await user.keyboard("{Escape}");
|
|
51
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("preserves the caller's Escape guard and an explicit keyboard-operable exit", async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
const guard = vi.fn((event: KeyboardEvent) => event.preventDefault());
|
|
57
|
+
render(<Example hideCloseButton onEscapeKeyDown={guard} />);
|
|
58
|
+
await user.keyboard("{Escape}");
|
|
59
|
+
expect(guard).toHaveBeenCalledOnce();
|
|
60
|
+
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
61
|
+
screen.getByRole("button", { name: "Cancel operation" }).focus();
|
|
62
|
+
await user.keyboard("{Enter}");
|
|
63
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("forwards content refs, native attributes and class overrides", () => {
|
|
67
|
+
const ref = createRef<HTMLDivElement>();
|
|
68
|
+
render(<Example ref={ref} hideCloseButton id="operation-dialog" data-example="retained" className="max-w-xl" />);
|
|
69
|
+
const dialog = screen.getByRole("dialog");
|
|
70
|
+
expect(ref.current).toBe(dialog);
|
|
71
|
+
expect(dialog).toHaveAttribute("id", "operation-dialog");
|
|
72
|
+
expect(dialog).toHaveAttribute("data-example", "retained");
|
|
73
|
+
expect(dialog).toHaveClass("max-w-xl");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -33,8 +33,14 @@ const DialogContent = React.forwardRef<
|
|
|
33
33
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
34
34
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
|
35
35
|
variant?: "default" | "sandbox";
|
|
36
|
+
/**
|
|
37
|
+
* Hide only the built-in close button. Escape/outside dismissal still follows
|
|
38
|
+
* Radix's handlers; prevent those events explicitly when a choice is required
|
|
39
|
+
* and provide an accessible way to finish or cancel the operation.
|
|
40
|
+
*/
|
|
41
|
+
hideCloseButton?: boolean;
|
|
36
42
|
}
|
|
37
|
-
>(({ className, children, variant = "default", ...props }, ref) => {
|
|
43
|
+
>(({ className, children, variant = "default", hideCloseButton = false, ...props }, ref) => {
|
|
38
44
|
const variants = {
|
|
39
45
|
default: "border-border",
|
|
40
46
|
sandbox: "border-[var(--border-accent)] shadow-[var(--shadow-accent)]",
|
|
@@ -59,15 +65,17 @@ const DialogContent = React.forwardRef<
|
|
|
59
65
|
{...props}
|
|
60
66
|
>
|
|
61
67
|
{children}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
{!hideCloseButton && (
|
|
69
|
+
<DialogPrimitive.Close
|
|
70
|
+
className={cn(
|
|
71
|
+
"absolute top-4 right-4 rounded-sm opacity-70 transition-[opacity,box-shadow] hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
|
72
|
+
focusRing,
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
<X className="h-4 w-4" />
|
|
76
|
+
<span className="sr-only">Close</span>
|
|
77
|
+
</DialogPrimitive.Close>
|
|
78
|
+
)}
|
|
71
79
|
</DialogPrimitive.Content>
|
|
72
80
|
</DialogPortal>
|
|
73
81
|
);
|