@tangle-network/ui 11.21.0 → 11.22.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
3
9
  ## 11.21.0
4
10
 
5
11
  ### 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-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest text-muted-foreground",
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
- if (timerRef.current) clearTimeout(timerRef.current);
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
- return /* @__PURE__ */ jsx("button", {
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
- title: "Copy to clipboard",
173
- children: copied ? /* @__PURE__ */ jsx(Check, { className: "w-3.5 h-3.5 text-emerald-500" }) : /* @__PURE__ */ jsx(Copy, { className: "w-3.5 h-3.5 text-muted-foreground" })
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("h3", {
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: [/* @__PURE__ */ jsx("span", {
28
- className: "min-w-0 whitespace-normal [overflow-wrap:anywhere]",
29
- children: label
30
- }), attention && /* @__PURE__ */ jsx(StatusPill, {
31
- tone: attention.tone,
32
- children: attention.label
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)]"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.21.0",
3
+ "version": "11.22.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- afterEach(cleanup);
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-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest text-muted-foreground">
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
- if (timerRef.current) clearTimeout(timerRef.current);
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
- <button
202
- onClick={handleCopy}
203
- className={cn(
204
- "flex items-center justify-center w-6 h-6 rounded-md bg-muted border border-border hover:border-[var(--border-strong)] transition-colors",
205
- focusRing,
206
- )}
207
- title="Copy to clipboard"
208
- >
209
- {copied ? (
210
- <Check className="w-3.5 h-3.5 text-emerald-500" />
211
- ) : (
212
- <Copy className="w-3.5 h-3.5 text-muted-foreground" />
213
- )}
214
- </button>
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
- <h3 className="font-semibold text-lg">{title}</h3>
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>