@tangle-network/ui 11.21.0 → 11.22.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 +12 -0
- package/dist/markdown/code-block.js +30 -8
- package/dist/primitives/empty-state.d.ts +3 -0
- package/dist/primitives/empty-state.js +2 -2
- package/dist/primitives/metric-strip.d.ts +3 -0
- package/dist/primitives/metric-strip.js +16 -8
- package/dist/primitives/switch.js +2 -2
- package/package.json +2 -2
- package/src/markdown/code-block.test.tsx +42 -3
- package/src/markdown/code-block.tsx +36 -17
- package/src/primitives/empty-state.test.tsx +18 -0
- package/src/primitives/empty-state.tsx +5 -2
- package/src/primitives/metric-strip.test.tsx +15 -0
- package/src/primitives/metric-strip.tsx +12 -1
- package/src/primitives/switch.test.tsx +35 -0
- package/src/primitives/switch.tsx +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.22.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- eb11ddd: Switch: draw the off state as an outlined track with a filled thumb in `muted-foreground`, which clears 3:1 against the card and the canvas. The old filled `bg-input` track and `bg-background` thumb looked like one flat shape in both themes, so an unchecked switch could not be seen.
|
|
8
|
+
|
|
9
|
+
## 11.22.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 17162d7: Metric accepts an optional decorative `icon` before its label. EmptyState accepts `titleAs` (h2, h3 or h4) so the title follows the page outline. CopyButton is a `type="button"` with an `aria-label` and a polite status announcement when the copy succeeds, and its check icon uses the success text token. The CodeBlock header label renders at `text-xs` (12px) instead of one pixel below it.
|
|
14
|
+
|
|
3
15
|
## 11.21.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { focusRing, focusRingInset } from "../lib/focus.js";
|
|
2
2
|
import { cn } from "../lib/utils.js";
|
|
3
3
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
import { Check, Copy } from "lucide-react";
|
|
6
6
|
//#region src/markdown/code-block.tsx
|
|
7
7
|
const syntaxTheme = (() => {
|
|
@@ -107,7 +107,7 @@ const CodeBlock = memo(({ code, language, label, showLineNumbers = false, light,
|
|
|
107
107
|
headerLabel && /* @__PURE__ */ jsxs("div", {
|
|
108
108
|
className: "flex items-center justify-between border-b border-border px-3 py-1 bg-[var(--code-header-bg,hsl(var(--background)))]",
|
|
109
109
|
children: [/* @__PURE__ */ jsx("span", {
|
|
110
|
-
className: "text-
|
|
110
|
+
className: "text-xs font-mono font-medium uppercase tracking-widest text-muted-foreground",
|
|
111
111
|
children: headerLabel
|
|
112
112
|
}), children]
|
|
113
113
|
}),
|
|
@@ -150,6 +150,7 @@ CodeBlock.displayName = "CodeBlock";
|
|
|
150
150
|
/** Copy-to-clipboard button for use inside CodeBlock. */
|
|
151
151
|
const CopyButton = memo(({ text }) => {
|
|
152
152
|
const [copied, setCopied] = useState(false);
|
|
153
|
+
const [status, setStatus] = useState("");
|
|
153
154
|
const timerRef = useRef(null);
|
|
154
155
|
useEffect(() => {
|
|
155
156
|
return () => {
|
|
@@ -157,21 +158,42 @@ const CopyButton = memo(({ text }) => {
|
|
|
157
158
|
};
|
|
158
159
|
}, []);
|
|
159
160
|
const handleCopy = useCallback(async () => {
|
|
161
|
+
setStatus("");
|
|
162
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
160
163
|
try {
|
|
161
164
|
await navigator.clipboard.writeText(text);
|
|
162
165
|
setCopied(true);
|
|
163
|
-
|
|
164
|
-
timerRef.current = setTimeout(() => setCopied(false), 2e3);
|
|
166
|
+
setStatus("Copied to clipboard");
|
|
165
167
|
} catch (err) {
|
|
166
168
|
console.warn("Clipboard write failed:", err);
|
|
169
|
+
setCopied(false);
|
|
170
|
+
setStatus("Copy failed");
|
|
167
171
|
}
|
|
172
|
+
timerRef.current = setTimeout(() => {
|
|
173
|
+
setCopied(false);
|
|
174
|
+
setStatus("");
|
|
175
|
+
}, 2e3);
|
|
168
176
|
}, [text]);
|
|
169
|
-
|
|
177
|
+
const label = copied ? "Copied" : "Copy to clipboard";
|
|
178
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
179
|
+
type: "button",
|
|
170
180
|
onClick: handleCopy,
|
|
171
181
|
className: cn("flex items-center justify-center w-6 h-6 rounded-md bg-muted border border-border hover:border-[var(--border-strong)] transition-colors", focusRing),
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
"aria-label": label,
|
|
183
|
+
title: label,
|
|
184
|
+
children: copied ? /* @__PURE__ */ jsx(Check, {
|
|
185
|
+
"aria-hidden": "true",
|
|
186
|
+
className: "w-3.5 h-3.5 text-[var(--surface-success-text)]"
|
|
187
|
+
}) : /* @__PURE__ */ jsx(Copy, {
|
|
188
|
+
"aria-hidden": "true",
|
|
189
|
+
className: "w-3.5 h-3.5 text-muted-foreground"
|
|
190
|
+
})
|
|
191
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
192
|
+
className: "sr-only",
|
|
193
|
+
role: "status",
|
|
194
|
+
"aria-live": "polite",
|
|
195
|
+
children: status
|
|
196
|
+
})] });
|
|
175
197
|
});
|
|
176
198
|
CopyButton.displayName = "CopyButton";
|
|
177
199
|
//#endregion
|
|
@@ -5,6 +5,9 @@ export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
5
5
|
title: string;
|
|
6
6
|
description?: string;
|
|
7
7
|
action?: React.ReactNode;
|
|
8
|
+
/** Heading element for the title. Pick the level that follows the page's
|
|
9
|
+
* outline, e.g. h2 directly under a page h1. Defaults to h3. */
|
|
10
|
+
titleAs?: "h2" | "h3" | "h4";
|
|
8
11
|
}
|
|
9
12
|
declare const EmptyState: React.ForwardRefExoticComponent<EmptyStateProps & React.RefAttributes<HTMLDivElement>>;
|
|
10
13
|
//#endregion
|
|
@@ -2,7 +2,7 @@ import { cn } from "../lib/utils.js";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
4
|
//#region src/primitives/empty-state.tsx
|
|
5
|
-
const EmptyState = React.forwardRef(({ className, icon, title, description, action, ...props }, ref) => {
|
|
5
|
+
const EmptyState = React.forwardRef(({ className, icon, title, description, action, titleAs: Title = "h3", ...props }, ref) => {
|
|
6
6
|
return /* @__PURE__ */ jsxs("div", {
|
|
7
7
|
ref,
|
|
8
8
|
className: cn("flex flex-col items-center justify-center px-4 py-16 text-center", className),
|
|
@@ -12,7 +12,7 @@ const EmptyState = React.forwardRef(({ className, icon, title, description, acti
|
|
|
12
12
|
className: "mb-4 rounded-full bg-muted p-4 text-muted-foreground",
|
|
13
13
|
children: icon
|
|
14
14
|
}),
|
|
15
|
-
/* @__PURE__ */ jsx(
|
|
15
|
+
/* @__PURE__ */ jsx(Title, {
|
|
16
16
|
className: "font-semibold text-lg",
|
|
17
17
|
children: title
|
|
18
18
|
}),
|
|
@@ -19,6 +19,9 @@ export interface MetricProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
19
19
|
tone: StatusTone;
|
|
20
20
|
label: string;
|
|
21
21
|
};
|
|
22
|
+
/** Decorative glyph before the label; the label carries the meaning. A
|
|
23
|
+
* direct svg child is sized to 16px; wrap other content to size it yourself. */
|
|
24
|
+
icon?: React.ReactNode;
|
|
22
25
|
}
|
|
23
26
|
/** A described term; the strip, not this item, decides where separators belong. */
|
|
24
27
|
declare const Metric: React.ForwardRefExoticComponent<MetricProps & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -17,20 +17,28 @@ const MetricStrip = React.forwardRef(({ className, children, columns = 4, ...pro
|
|
|
17
17
|
}));
|
|
18
18
|
MetricStrip.displayName = "MetricStrip";
|
|
19
19
|
/** A described term; the strip, not this item, decides where separators belong. */
|
|
20
|
-
const Metric = React.forwardRef(({ className, label, value, hint, attention, ...props }, ref) => /* @__PURE__ */ jsxs("div", {
|
|
20
|
+
const Metric = React.forwardRef(({ className, label, value, hint, attention, icon, ...props }, ref) => /* @__PURE__ */ jsxs("div", {
|
|
21
21
|
ref,
|
|
22
22
|
className: cn("min-w-0 border-border p-4 sm:p-5", className),
|
|
23
23
|
...props,
|
|
24
24
|
children: [
|
|
25
25
|
/* @__PURE__ */ jsxs("dt", {
|
|
26
26
|
className: "flex min-w-0 flex-wrap items-center gap-2 text-muted-foreground text-sm",
|
|
27
|
-
children: [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
children: [
|
|
28
|
+
icon != null && icon !== false && /* @__PURE__ */ jsx("span", {
|
|
29
|
+
"aria-hidden": "true",
|
|
30
|
+
className: "inline-flex shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4",
|
|
31
|
+
children: icon
|
|
32
|
+
}),
|
|
33
|
+
/* @__PURE__ */ jsx("span", {
|
|
34
|
+
className: "min-w-0 whitespace-normal [overflow-wrap:anywhere]",
|
|
35
|
+
children: label
|
|
36
|
+
}),
|
|
37
|
+
attention && /* @__PURE__ */ jsx(StatusPill, {
|
|
38
|
+
tone: attention.tone,
|
|
39
|
+
children: attention.label
|
|
40
|
+
})
|
|
41
|
+
]
|
|
34
42
|
}),
|
|
35
43
|
/* @__PURE__ */ jsx("dd", {
|
|
36
44
|
className: cn("mt-1 whitespace-normal [overflow-wrap:anywhere] font-semibold text-xl tabular-nums tracking-tight lg:text-2xl", attention?.tone === "danger" && "text-[var(--surface-danger-text)]"),
|
|
@@ -6,10 +6,10 @@ import { jsx } from "react/jsx-runtime";
|
|
|
6
6
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
|
7
7
|
//#region src/primitives/switch.tsx
|
|
8
8
|
const Switch = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(SwitchPrimitives.Root, {
|
|
9
|
-
className: cn("peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2
|
|
9
|
+
className: cn("peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-[background-color,border-color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary data-[state=unchecked]:border-muted-foreground data-[state=unchecked]:bg-transparent", focusRing, className),
|
|
10
10
|
...props,
|
|
11
11
|
ref,
|
|
12
|
-
children: /* @__PURE__ */ jsx(SwitchPrimitives.Thumb, { className: cn("pointer-events-none block h-4 w-4 rounded-full
|
|
12
|
+
children: /* @__PURE__ */ jsx(SwitchPrimitives.Thumb, { className: cn("pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-[translate,background-color] data-[state=checked]:translate-x-4 data-[state=checked]:bg-background data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-muted-foreground") })
|
|
13
13
|
}));
|
|
14
14
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
|
15
15
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.22.1",
|
|
4
4
|
"description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"peerDependencies": {
|
|
150
150
|
"@hocuspocus/provider": ">=2.15.0 <5.0.0",
|
|
151
151
|
"@nanostores/react": ">=0.8.4 <3.0.0",
|
|
152
|
-
"@tangle-network/brand": "^1.15.
|
|
152
|
+
"@tangle-network/brand": "^1.15.4",
|
|
153
153
|
"@tiptap/core": ">=3.20.4 <4.0.0",
|
|
154
154
|
"@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
|
|
155
155
|
"@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import { renderToString } from "react-dom/server";
|
|
3
3
|
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { CodeBlock, CopyButton } from "./code-block";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
cleanup();
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard);
|
|
13
|
+
else delete (navigator as { clipboard?: unknown }).clipboard;
|
|
14
|
+
});
|
|
8
15
|
|
|
9
16
|
describe("CodeBlock scoped semantic colors", () => {
|
|
10
17
|
it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
|
|
@@ -137,4 +144,36 @@ describe("CodeBlock", () => {
|
|
|
137
144
|
expect(copyButton?.closest(".absolute")).not.toBeNull();
|
|
138
145
|
expect(copyButton?.closest(".border-b")).toBeNull();
|
|
139
146
|
});
|
|
147
|
+
|
|
148
|
+
it("names the copy button, keeps it out of forms, and announces the copy", async () => {
|
|
149
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
150
|
+
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
151
|
+
render(<CopyButton text="secret" />);
|
|
152
|
+
const button = screen.getByRole("button", { name: "Copy to clipboard" });
|
|
153
|
+
expect(button.getAttribute("type")).toBe("button");
|
|
154
|
+
const status = screen.getByRole("status");
|
|
155
|
+
expect(status.getAttribute("aria-live")).toBe("polite");
|
|
156
|
+
expect(status.textContent).toBe("");
|
|
157
|
+
fireEvent.click(button);
|
|
158
|
+
await waitFor(() => expect(status.textContent).toBe("Copied to clipboard"));
|
|
159
|
+
expect(writeText).toHaveBeenCalledWith("secret");
|
|
160
|
+
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("renders the header label at the 12px text-xs step", () => {
|
|
164
|
+
const { getByText } = render(<CodeBlock code={"x"} label="YAML" />);
|
|
165
|
+
expect(getByText("YAML").className).toContain("text-xs");
|
|
166
|
+
expect(getByText("YAML").className).not.toContain("calc(");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("announces a failed copy", async () => {
|
|
170
|
+
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
|
|
171
|
+
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
172
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
173
|
+
render(<CopyButton text="secret" />);
|
|
174
|
+
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
|
|
175
|
+
await waitFor(() => expect(screen.getByRole("status").textContent).toBe("Copy failed"));
|
|
176
|
+
expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeTruthy();
|
|
177
|
+
warn.mockRestore();
|
|
178
|
+
});
|
|
140
179
|
});
|
|
@@ -138,7 +138,7 @@ export const CodeBlock = memo(
|
|
|
138
138
|
>
|
|
139
139
|
{headerLabel && (
|
|
140
140
|
<div className="flex items-center justify-between border-b border-border px-3 py-1 bg-[var(--code-header-bg,hsl(var(--background)))]">
|
|
141
|
-
<span className="text-
|
|
141
|
+
<span className="text-xs font-mono font-medium uppercase tracking-widest text-muted-foreground">
|
|
142
142
|
{headerLabel}
|
|
143
143
|
</span>
|
|
144
144
|
{children}
|
|
@@ -180,6 +180,9 @@ CodeBlock.displayName = "CodeBlock";
|
|
|
180
180
|
/** Copy-to-clipboard button for use inside CodeBlock. */
|
|
181
181
|
export const CopyButton = memo(({ text }: { text: string }) => {
|
|
182
182
|
const [copied, setCopied] = useState(false);
|
|
183
|
+
// Status text for the live region. Cleared before each attempt so a repeat
|
|
184
|
+
// copy or a failure is announced again.
|
|
185
|
+
const [status, setStatus] = useState("");
|
|
183
186
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
184
187
|
|
|
185
188
|
useEffect(() => {
|
|
@@ -187,31 +190,47 @@ export const CopyButton = memo(({ text }: { text: string }) => {
|
|
|
187
190
|
}, []);
|
|
188
191
|
|
|
189
192
|
const handleCopy = useCallback(async () => {
|
|
193
|
+
setStatus("");
|
|
194
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
190
195
|
try {
|
|
191
196
|
await navigator.clipboard.writeText(text);
|
|
192
197
|
setCopied(true);
|
|
193
|
-
|
|
194
|
-
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
|
198
|
+
setStatus("Copied to clipboard");
|
|
195
199
|
} catch (err) {
|
|
196
200
|
console.warn("Clipboard write failed:", err);
|
|
201
|
+
setCopied(false);
|
|
202
|
+
setStatus("Copy failed");
|
|
197
203
|
}
|
|
204
|
+
timerRef.current = setTimeout(() => {
|
|
205
|
+
setCopied(false);
|
|
206
|
+
setStatus("");
|
|
207
|
+
}, 2000);
|
|
198
208
|
}, [text]);
|
|
199
209
|
|
|
210
|
+
const label = copied ? "Copied" : "Copy to clipboard";
|
|
200
211
|
return (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
<>
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
onClick={handleCopy}
|
|
216
|
+
className={cn(
|
|
217
|
+
"flex items-center justify-center w-6 h-6 rounded-md bg-muted border border-border hover:border-[var(--border-strong)] transition-colors",
|
|
218
|
+
focusRing,
|
|
219
|
+
)}
|
|
220
|
+
aria-label={label}
|
|
221
|
+
title={label}
|
|
222
|
+
>
|
|
223
|
+
{copied ? (
|
|
224
|
+
<Check aria-hidden="true" className="w-3.5 h-3.5 text-[var(--surface-success-text)]" />
|
|
225
|
+
) : (
|
|
226
|
+
<Copy aria-hidden="true" className="w-3.5 h-3.5 text-muted-foreground" />
|
|
227
|
+
)}
|
|
228
|
+
</button>
|
|
229
|
+
{/* Announce the result; the icon swap alone is silent to screen readers. */}
|
|
230
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
231
|
+
{status}
|
|
232
|
+
</span>
|
|
233
|
+
</>
|
|
215
234
|
);
|
|
216
235
|
});
|
|
217
236
|
CopyButton.displayName = "CopyButton";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { EmptyState } from "./empty-state";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
describe("EmptyState", () => {
|
|
8
|
+
it("renders the title as h3 by default", () => {
|
|
9
|
+
render(<EmptyState title="No runs yet" />);
|
|
10
|
+
expect(screen.getByRole("heading", { level: 3, name: "No runs yet" })).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses the requested heading level", () => {
|
|
14
|
+
render(<EmptyState title="No suites yet" titleAs="h2" description="Add one." />);
|
|
15
|
+
expect(screen.getByRole("heading", { level: 2, name: "No suites yet" })).toBeTruthy();
|
|
16
|
+
expect(screen.queryByRole("heading", { level: 3 })).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -6,10 +6,13 @@ export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
6
6
|
title: string;
|
|
7
7
|
description?: string;
|
|
8
8
|
action?: React.ReactNode;
|
|
9
|
+
/** Heading element for the title. Pick the level that follows the page's
|
|
10
|
+
* outline, e.g. h2 directly under a page h1. Defaults to h3. */
|
|
11
|
+
titleAs?: "h2" | "h3" | "h4";
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
12
|
-
({ className, icon, title, description, action, ...props }, ref) => {
|
|
15
|
+
({ className, icon, title, description, action, titleAs: Title = "h3", ...props }, ref) => {
|
|
13
16
|
return (
|
|
14
17
|
<div
|
|
15
18
|
ref={ref}
|
|
@@ -24,7 +27,7 @@ const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
24
27
|
{icon}
|
|
25
28
|
</div>
|
|
26
29
|
)}
|
|
27
|
-
<
|
|
30
|
+
<Title className="font-semibold text-lg">{title}</Title>
|
|
28
31
|
{description && (
|
|
29
32
|
<p className="mt-2 max-w-sm text-muted-foreground text-sm">
|
|
30
33
|
{description}
|
|
@@ -116,4 +116,19 @@ describe("MetricStrip", () => {
|
|
|
116
116
|
expect(strip.current!.className).not.toContain("sm:grid-cols-4");
|
|
117
117
|
expect(Array.from(strip.current!.querySelectorAll("dt")).map((node) => node.textContent)).toEqual(["Spend", "Calls", "Average"]);
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
it("renders an optional icon before the label without naming the term", () => {
|
|
121
|
+
render(
|
|
122
|
+
<MetricStrip>
|
|
123
|
+
<Metric label="Steps" value="4/5" icon={<svg data-testid="steps-icon" />} />
|
|
124
|
+
<Metric label="Cost" value="$0.12" />
|
|
125
|
+
</MetricStrip>,
|
|
126
|
+
);
|
|
127
|
+
const icon = screen.getByTestId("steps-icon");
|
|
128
|
+
const dt = screen.getByText("Steps").closest("dt")!;
|
|
129
|
+
expect(dt.contains(icon)).toBe(true);
|
|
130
|
+
expect(icon.parentElement!.getAttribute("aria-hidden")).toBe("true");
|
|
131
|
+
expect(dt.firstElementChild).toBe(icon.parentElement);
|
|
132
|
+
expect(screen.getByText("Cost").closest("dt")!.querySelector('[aria-hidden="true"]')).toBeNull();
|
|
133
|
+
});
|
|
119
134
|
});
|
|
@@ -45,17 +45,28 @@ export interface MetricProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
45
45
|
hint?: React.ReactNode;
|
|
46
46
|
/** Attention is a caller-owned action requirement, never inferred from zero. */
|
|
47
47
|
attention?: { tone: StatusTone; label: string };
|
|
48
|
+
/** Decorative glyph before the label; the label carries the meaning. A
|
|
49
|
+
* direct svg child is sized to 16px; wrap other content to size it yourself. */
|
|
50
|
+
icon?: React.ReactNode;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/** A described term; the strip, not this item, decides where separators belong. */
|
|
51
54
|
const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
|
|
52
|
-
({ className, label, value, hint, attention, ...props }, ref) => (
|
|
55
|
+
({ className, label, value, hint, attention, icon, ...props }, ref) => (
|
|
53
56
|
<div
|
|
54
57
|
ref={ref}
|
|
55
58
|
className={cn("min-w-0 border-border p-4 sm:p-5", className)}
|
|
56
59
|
{...props}
|
|
57
60
|
>
|
|
58
61
|
<dt className="flex min-w-0 flex-wrap items-center gap-2 text-muted-foreground text-sm">
|
|
62
|
+
{icon != null && icon !== false && (
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
className="inline-flex shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4"
|
|
66
|
+
>
|
|
67
|
+
{icon}
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
59
70
|
<span className="min-w-0 whitespace-normal [overflow-wrap:anywhere]">{label}</span>
|
|
60
71
|
{attention && (
|
|
61
72
|
<StatusPill tone={attention.tone}>{attention.label}</StatusPill>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { Switch } from "./switch";
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
/** The classes that apply in a state: unprefixed ones, plus that state's variant. */
|
|
8
|
+
function stateClasses(el: Element, state: "checked" | "unchecked"): string[] {
|
|
9
|
+
return (el.getAttribute("class") ?? "")
|
|
10
|
+
.split(/\s+/)
|
|
11
|
+
.filter((c) => !c.startsWith("data-[") || c.startsWith(`data-[state=${state}]:`))
|
|
12
|
+
.map((c) => c.replace(`data-[state=${state}]:`, ""));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("Switch", () => {
|
|
16
|
+
it("draws its off state in muted-foreground, so an unchecked switch is visible on a card or the canvas", () => {
|
|
17
|
+
render(<Switch aria-label="Reminders" />);
|
|
18
|
+
const track = screen.getByRole("switch", { name: "Reminders" });
|
|
19
|
+
const thumb = track.firstElementChild as Element;
|
|
20
|
+
expect(track).toHaveAttribute("data-state", "unchecked");
|
|
21
|
+
expect(stateClasses(track, "unchecked")).toContain("border-muted-foreground");
|
|
22
|
+
expect(stateClasses(track, "unchecked")).not.toContain("bg-input");
|
|
23
|
+
expect(stateClasses(thumb, "unchecked")).toContain("bg-muted-foreground");
|
|
24
|
+
expect(stateClasses(thumb, "unchecked")).not.toContain("bg-background");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("fills the track with primary once it is on", () => {
|
|
28
|
+
render(<Switch aria-label="Reminders" />);
|
|
29
|
+
const track = screen.getByRole("switch", { name: "Reminders" });
|
|
30
|
+
fireEvent.click(track);
|
|
31
|
+
expect(track).toHaveAttribute("data-state", "checked");
|
|
32
|
+
expect(stateClasses(track, "checked")).toContain("bg-primary");
|
|
33
|
+
expect(stateClasses(track.firstElementChild as Element, "checked")).toContain("bg-background");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -11,7 +11,12 @@ const Switch = React.forwardRef<
|
|
|
11
11
|
>(({ className, ...props }, ref) => (
|
|
12
12
|
<SwitchPrimitives.Root
|
|
13
13
|
className={cn(
|
|
14
|
-
|
|
14
|
+
// Off is an outlined track with a filled thumb, both in muted-foreground,
|
|
15
|
+
// which clears 3:1 against the card and the canvas in both themes (a
|
|
16
|
+
// switch's boundary is non-text content). The old bg-input track under a
|
|
17
|
+
// bg-background thumb read as one flat shape, so an off switch was not
|
|
18
|
+
// visible at all. --border-strong was tried and measured 2.2:1 on a white card.
|
|
19
|
+
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-[background-color,border-color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary data-[state=unchecked]:border-muted-foreground data-[state=unchecked]:bg-transparent",
|
|
15
20
|
focusRing,
|
|
16
21
|
className,
|
|
17
22
|
)}
|
|
@@ -20,7 +25,7 @@ const Switch = React.forwardRef<
|
|
|
20
25
|
>
|
|
21
26
|
<SwitchPrimitives.Thumb
|
|
22
27
|
className={cn(
|
|
23
|
-
"pointer-events-none block h-4 w-4 rounded-full
|
|
28
|
+
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-[translate,background-color] data-[state=checked]:translate-x-4 data-[state=checked]:bg-background data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-muted-foreground",
|
|
24
29
|
)}
|
|
25
30
|
/>
|
|
26
31
|
</SwitchPrimitives.Root>
|