@tangle-network/ui 11.13.1 → 11.15.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.
@@ -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
- <DialogPrimitive.Close
63
- className={cn(
64
- "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",
65
- focusRing,
66
- )}
67
- >
68
- <X className="h-4 w-4" />
69
- <span className="sr-only">Close</span>
70
- </DialogPrimitive.Close>
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
  );
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { focusField, focusFieldInvalid } from "../lib/focus";
4
+ import { controlSizes, fieldPresentation } from "../lib/control-presentation";
5
+ import { focusFieldInvalid } from "../lib/focus";
5
6
  import { cn } from "../lib/utils";
6
7
  import { Label } from "./label";
7
8
 
@@ -9,8 +10,8 @@ import { cva, type VariantProps } from "class-variance-authority";
9
10
 
10
11
  const inputVariants = cva(
11
12
  cn(
12
- "flex w-full rounded-lg border bg-card px-4 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium file:text-sm",
13
- focusField,
13
+ "flex w-full rounded-lg border px-4 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium file:text-sm",
14
+ fieldPresentation,
14
15
  ),
15
16
  {
16
17
  variants: {
@@ -24,6 +25,7 @@ const inputVariants = cva(
24
25
  default: "h-11",
25
26
  sm: "h-9 px-3",
26
27
  lg: "h-12 px-5",
28
+ ...controlSizes,
27
29
  }
28
30
  },
29
31
  defaultVariants: {
@@ -89,6 +91,8 @@ export interface TextareaProps
89
91
  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
90
92
  /** Both values render the same field. */
91
93
  variant?: "default" | "sandbox";
94
+ /** Opt in to density/typing presentation without changing the default 120px well. */
95
+ size?: "default" | "compact" | "touch";
92
96
  label?: string;
93
97
  error?: string;
94
98
  hint?: string;
@@ -98,7 +102,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
98
102
  (
99
103
  // `variant` is taken out so it never reaches the DOM element.
100
104
  {
101
- className, variant: _variant, label, error, hint, id,
105
+ className, variant: _variant, size = "default", label, error, hint, id,
102
106
  "aria-describedby": describedBy, "aria-invalid": invalid, ...props
103
107
  },
104
108
  ref,
@@ -112,11 +116,13 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
112
116
  <textarea
113
117
  id={textareaId}
114
118
  className={cn(
115
- "flex min-h-[120px] w-full resize-y rounded-lg border bg-card px-4 py-3 text-sm",
119
+ "flex min-h-[120px] w-full resize-y rounded-lg border px-4 py-3 text-sm",
116
120
  "placeholder:text-muted-foreground",
117
121
  "disabled:cursor-not-allowed disabled:opacity-50",
118
- focusField,
122
+ fieldPresentation,
119
123
  error && focusFieldInvalid,
124
+ size === "compact" && "min-h-24 px-3 py-2",
125
+ size === "touch" && "text-base",
120
126
  className,
121
127
  )}
122
128
  ref={ref}
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Metric, MetricStrip } from "./metric-strip";
3
+
4
+ const meta = {
5
+ title: "Primitives/MetricStrip",
6
+ component: MetricStrip,
7
+ parameters: { layout: "padded" },
8
+ } satisfies Meta<typeof MetricStrip>;
9
+ export default meta;
10
+ type Story = StoryObj<typeof meta>;
11
+
12
+ const readings = [
13
+ { label: "Available balance · project wallet", value: "$1,284,003.10", hint: "This account only; not the organization total" },
14
+ { label: "Calls", value: 0, hint: "Known zero" },
15
+ { label: "Pending amount", value: "—", hint: "Not yet available" },
16
+ { label: "Average cost / call", value: "$0.000123", hint: "Current window" },
17
+ { label: "LongUnbrokenAccountIdentity1234567890", value: "12345678901234567890", hint: 0 },
18
+ { label: "Errors requiring review", value: 0, hint: "No inferred warning" },
19
+ ];
20
+
21
+ export const Default: Story = {
22
+ args: { children: readings.slice(0, 4).map((reading) => <Metric key={reading.label} {...reading} />) },
23
+ };
24
+
25
+ export const ThreeToSix: Story = {
26
+ args: { children: null },
27
+ render: () => (
28
+ <div className="space-y-6">
29
+ {([3, 4, 5, 6] as const).map((columns) => (
30
+ <section key={columns} className="space-y-2">
31
+ <h2 className="text-lg font-semibold">{columns} metrics</h2>
32
+ <MetricStrip columns={columns} aria-label={`${columns} metrics`}>
33
+ {readings.slice(0, columns).map((reading) => <Metric key={reading.label} {...reading} />)}
34
+ </MetricStrip>
35
+ </section>
36
+ ))}
37
+ </div>
38
+ ),
39
+ };
@@ -1,181 +1,119 @@
1
- import { render, screen } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
1
+ import * as React from "react";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
3
4
  import { Metric, MetricStrip } from "./metric-strip";
4
5
 
5
- describe("MetricStrip", () => {
6
- it("pairs each label with its value as a described term", () => {
7
- render(
8
- <MetricStrip>
9
- <Metric label="Balance" value="$248.55" />
10
- </MetricStrip>,
11
- );
12
- // The label is wrapped for truncation, so assert the term it belongs to.
13
- expect(screen.getByText("Balance").closest("dt")).not.toBeNull();
14
- expect(screen.getByText("$248.55").tagName).toBe("DD");
15
- });
6
+ afterEach(cleanup);
16
7
 
17
- // axe's `definition-list` rule allows only dt, dd, script, template and div
18
- // as direct children of a dl. The hint is a second dd for this reason: a <p>
19
- // between the terms is a violation, and it was one.
20
- it("puts only permitted elements directly inside the dl", () => {
8
+ describe("MetricStrip", () => {
9
+ it("pairs labels, values and qualifiers as native described terms", () => {
21
10
  const { container } = render(
22
11
  <MetricStrip>
23
- <Metric label="Spend" value="$12.00" hint="This month" />
12
+ <Metric label="Balance" value="$248.55" hint="Personal wallet" />
24
13
  <Metric label="Runs" value="41" />
25
14
  </MetricStrip>,
26
15
  );
27
- const dl = container.querySelector("dl") as HTMLElement;
16
+ expect(screen.getByText("Balance").closest("dt")).not.toBeNull();
17
+ expect(screen.getByText("$248.55").tagName).toBe("DD");
18
+ expect(screen.getByText("Personal wallet").tagName).toBe("DD");
19
+ const dl = container.querySelector("dl")!;
28
20
  const permitted = new Set(["DT", "DD", "SCRIPT", "TEMPLATE", "DIV"]);
29
- for (const child of Array.from(dl.children)) {
30
- expect(permitted).toContain(child.tagName);
31
- }
21
+ for (const child of Array.from(dl.children)) expect(permitted).toContain(child.tagName);
22
+ expect(dl.querySelectorAll("dt")).toHaveLength(2);
23
+ expect(dl.querySelectorAll("dd")).toHaveLength(3);
32
24
  });
33
25
 
34
- it("renders the hint as a dd rather than a paragraph", () => {
35
- render(
36
- <MetricStrip>
37
- <Metric label="Spend" value="$12.00" hint="Personal wallet" />
38
- </MetricStrip>,
39
- );
40
- const hint = screen.getByText("Personal wallet");
41
- expect(hint.tagName).toBe("DD");
42
- });
43
-
44
- it("omits the hint entirely when not given", () => {
26
+ it("omits absent qualifiers but preserves a numeric zero qualifier inside dd", () => {
45
27
  const { container } = render(
46
28
  <MetricStrip>
47
- <Metric label="Runs" value="41" />
29
+ <Metric label="Unknown" value="—" />
30
+ <Metric label="Known zero" value={0} hint={0} />
31
+ <Metric label="Unavailable" value={null} hint={false} />
32
+ <Metric label="Empty hint" value="" hint="" />
48
33
  </MetricStrip>,
49
34
  );
50
- expect(container.querySelectorAll("dd")).toHaveLength(1);
35
+ expect(container.querySelectorAll("dd")).toHaveLength(5);
36
+ expect(screen.getByText("Unknown").closest("div")!.querySelector("dd")!.textContent).toBe("—");
37
+ const zero = screen.getByText("Known zero").closest("div")!;
38
+ expect(Array.from(zero.querySelectorAll("dd")).map((node) => node.textContent)).toEqual(["0", "0"]);
39
+ expect(Array.from(zero.childNodes).every((node) => node.nodeType === Node.ELEMENT_NODE)).toBe(true);
40
+ expect(screen.getByText("Unavailable").closest("div")!.querySelector("dd")!.textContent).toBe("");
51
41
  });
52
42
 
53
- // `attention` means a person has to do something, so it shows a pill next to
54
- // the label. A zero on its own is not attention.
55
- it("shows a pill beside the label only when attention is set", () => {
56
- const { rerender, container } = render(
57
- <MetricStrip>
58
- <Metric label="Balance" value="$0.00" />
59
- </MetricStrip>,
60
- );
43
+ it("does not infer attention from zero; explicit danger retains its pill and value tone", () => {
44
+ const { rerender, container } = render(<MetricStrip><Metric label="Balance" value="$0.00" /></MetricStrip>);
61
45
  expect(container.querySelector("svg")).toBeNull();
62
-
63
- rerender(
64
- <MetricStrip>
65
- <Metric
66
- attention={{ tone: "danger", label: "Empty" }}
67
- label="Balance"
68
- value="$0.00"
69
- />
70
- </MetricStrip>,
71
- );
46
+ rerender(<MetricStrip><Metric label="Balance" value="$0.00" attention={{ tone: "danger", label: "Empty" }} /></MetricStrip>);
72
47
  expect(screen.getByText("Empty")).toBeInTheDocument();
73
48
  expect(container.querySelector("svg")).not.toBeNull();
74
- });
75
-
76
- it("raises the value's tone only for a danger attention", () => {
77
- const { container } = render(
78
- <MetricStrip>
79
- <Metric
80
- attention={{ tone: "danger", label: "Empty" }}
81
- label="Balance"
82
- value="$0.00"
83
- />
84
- </MetricStrip>,
85
- );
86
- const value = screen.getByText("$0.00");
87
- expect(value.className).toContain("--surface-danger-text");
49
+ expect(screen.getByText("$0.00").className).toContain("--surface-danger-text");
88
50
  expect(container.querySelectorAll("dd")).toHaveLength(1);
51
+ rerender(<MetricStrip><Metric label="Balance" value="$0.00" attention={{ tone: "warning", label: "Review" }} /></MetricStrip>);
52
+ expect(screen.getByText("$0.00").className).not.toContain("--surface-danger-text");
89
53
  });
90
54
 
91
- // The divider is drawn only where it belongs, never drawn and then
92
- // suppressed: `border-l` alongside `border-l-0` variants puts both in one
93
- // conflict group whose winner is decided by emitted-rule order, and that
94
- // resolved the wrong way at every breakpoint — every item kept a left border,
95
- // including the first in each row.
96
- it("draws the divider without a suppressing counterpart", () => {
97
- const { container } = render(
98
- <MetricStrip>
99
- <Metric label="Balance" value="$0.00" />
100
- </MetricStrip>,
101
- );
102
- const item = (container.querySelector("dl") as HTMLElement)
103
- .children[0] as HTMLElement;
104
- expect(item.className).not.toContain("border-l-0");
55
+ it("retains the default two / sm:four grid", () => {
56
+ const { container } = render(<MetricStrip><Metric label="Runs" value={0} /></MetricStrip>);
57
+ const dl = container.querySelector("dl")!;
58
+ expect(dl.className.split(" ")).toEqual(expect.arrayContaining(["grid-cols-2", "sm:grid-cols-4"]));
59
+ expect(dl.className).not.toContain("lg:grid-cols");
105
60
  });
106
61
 
107
- // The row-start test has to name the same column count the grid does, at each
108
- // breakpoint. Nothing in the type system ties them together, so changing the
109
- // grid to three columns without moving the divider puts a rule mid-row. Read
110
- // both out of the rendered classes and require them to agree, so the coupling
111
- // is enforced rather than only described.
112
- it("keeps the divider's row-start test matching the grid's column count", () => {
62
+ it.each([3, 4, 5, 6] as const)("owns the grid and all nonoverlapping row-start guards for %i columns", (columns) => {
113
63
  const { container } = render(
114
- <MetricStrip>
115
- <Metric label="Balance" value="$0.00" />
64
+ <MetricStrip columns={columns}>
65
+ {Array.from({ length: columns }, (_, i) => <Metric key={i} label={`Metric ${i}`} value={i} />)}
116
66
  </MetricStrip>,
117
67
  );
118
- const dl = container.querySelector("dl") as HTMLElement;
119
- const item = dl.children[0] as HTMLElement;
120
-
121
- // `grid-cols-N` unprefixed is the base breakpoint; `<bp>:grid-cols-N` is that
122
- // breakpoint. The divider spells the same breakpoints as `max-<bp>:` / `<bp>:`.
123
- const columnsAt = (prefix: string) => {
124
- const re = prefix
125
- ? new RegExp(`(?:^| )${prefix}:grid-cols-(\\d+)(?: |$)`)
126
- : /(?:^| )grid-cols-(\d+)(?: |$)/;
127
- const m = dl.className.match(re);
128
- return m ? Number(m[1]) : null;
129
- };
130
- const modulusAt = (prefix: string) => {
131
- const m = item.className.match(
132
- new RegExp(`(?:^| )${prefix}:\\[&:not\\(:nth-child\\((\\d+)n\\+1\\)\\)\\]:border-l(?: |$)`),
133
- );
134
- return m ? Number(m[1]) : null;
135
- };
136
-
137
- // base grid (below sm) is guarded by the `max-sm:` divider rule
138
- expect(modulusAt("max-sm")).toBe(columnsAt(""));
139
- // the `sm:` grid is guarded by the `sm:` divider rule
140
- expect(modulusAt("sm")).toBe(columnsAt("sm"));
141
-
142
- // Every grid-cols breakpoint the strip declares must have a divider rule.
143
- const declared = [...dl.className.matchAll(/(?:^| )(?:([a-z]+):)?grid-cols-\d+(?= |$)/g)]
144
- .map((m) => m[1] ?? "");
145
- for (const bp of declared) {
146
- const guard = bp === "" ? "max-sm" : bp;
147
- expect(
148
- modulusAt(guard),
149
- `grid-cols at "${bp || "base"}" has no matching ${guard}: divider rule`,
150
- ).not.toBeNull();
68
+ const dl = container.querySelector("dl")!;
69
+ expect(dl).not.toHaveAttribute("columns");
70
+ expect(dl.className).toContain("max-sm:[&>div:not(:nth-child(2n+1))]:border-l");
71
+ if (columns <= 4) {
72
+ expect(dl.className).toContain(`sm:grid-cols-${columns}`);
73
+ expect(dl.className).toContain(`sm:[&>div:not(:nth-child(${columns}n+1))]:border-l`);
74
+ } else {
75
+ expect(dl.className).toContain("sm:grid-cols-3");
76
+ expect(dl.className).toContain("sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l");
77
+ expect(dl.className).toContain(`lg:grid-cols-${columns}`);
78
+ expect(dl.className).toContain(`lg:[&>div:not(:nth-child(${columns}n+1))]:border-l`);
151
79
  }
80
+ expect(dl.className).not.toContain("border-l-0");
81
+ for (const item of Array.from(dl.children)) expect(item.className).not.toContain("nth-child");
152
82
  });
153
83
 
154
- it("titles the hint too, so a truncated qualifier stays recoverable", () => {
155
- render(
156
- <MetricStrip>
157
- <Metric
158
- hint="Personal wallet (0x1234abcd5678efgh)"
159
- label="Balance"
160
- value="$248.55"
161
- />
162
- </MetricStrip>,
163
- );
164
- expect(screen.getByText("Personal wallet (0x1234abcd5678efgh)")).toHaveAttribute(
165
- "title",
166
- "Personal wallet (0x1234abcd5678efgh)",
167
- );
84
+ it("does not give a standalone Metric a strip divider", () => {
85
+ const { container } = render(<Metric label="Runs" value={0} />);
86
+ expect(container.firstElementChild!.className).not.toContain("border-l");
168
87
  });
169
88
 
170
- it("titles a string value so a truncated figure stays readable", () => {
171
- render(
172
- <MetricStrip>
173
- <Metric label="Balance" value="$1,284,003.10" />
89
+ it("wraps full labels, values and qualifiers without relying on a mouse title", () => {
90
+ render(<MetricStrip><Metric label="Very long account label without loss" value="$1,284,003.10" hint="Personal wallet (0x1234abcd5678efgh)" /></MetricStrip>);
91
+ for (const text of ["Very long account label without loss", "$1,284,003.10", "Personal wallet (0x1234abcd5678efgh)"]) {
92
+ const node = screen.getByText(text);
93
+ expect(node.className).toContain("[overflow-wrap:anywhere]");
94
+ expect(node.className).not.toContain("truncate");
95
+ }
96
+ expect(screen.getByText("$1,284,003.10")).toHaveAttribute("title", "$1,284,003.10");
97
+ expect(screen.getByText("Personal wallet (0x1234abcd5678efgh)")).toHaveAttribute("title", "Personal wallet (0x1234abcd5678efgh)");
98
+ });
99
+
100
+ it("preserves refs, attributes, caller classes and fragment/conditional DOM order", () => {
101
+ const strip = React.createRef<HTMLDListElement>();
102
+ const item = React.createRef<HTMLDivElement>();
103
+ const { container } = render(
104
+ <MetricStrip ref={strip} aria-label="Usage" className="mb-6 sm:grid-cols-3">
105
+ <><Metric ref={item} data-testid="first" className="max-sm:col-span-2 max-sm:border-b" label="Spend" value={0} /></>
106
+ {false && <Metric label="Hidden" value={0} />}
107
+ <Metric className="max-sm:border-l-0!" label="Calls" value={0} />
108
+ <Metric className="max-sm:border-l!" label="Average" value="—" />
174
109
  </MetricStrip>,
175
110
  );
176
- expect(screen.getByText("$1,284,003.10")).toHaveAttribute(
177
- "title",
178
- "$1,284,003.10",
179
- );
111
+ expect(strip.current).toBe(container.querySelector("dl"));
112
+ expect(strip.current).toHaveAttribute("aria-label", "Usage");
113
+ expect(item.current).toBe(screen.getByTestId("first"));
114
+ expect(item.current!.className).toContain("max-sm:col-span-2");
115
+ expect(strip.current!.className).toContain("sm:grid-cols-3");
116
+ expect(strip.current!.className).not.toContain("sm:grid-cols-4");
117
+ expect(Array.from(strip.current!.querySelectorAll("dt")).map((node) => node.textContent)).toEqual(["Spend", "Calls", "Average"]);
180
118
  });
181
119
  });