@tangle-network/ui 11.2.4 → 11.3.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 +14 -0
- package/dist/{chunk-HSMVDKL3.js → chunk-EJ5NBW7V.js} +275 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +13 -1
- package/dist/primitives.d.ts +171 -1
- package/dist/primitives.js +13 -1
- package/package.json +2 -2
- package/src/primitives/index.ts +12 -0
- package/src/primitives/metric-strip.test.tsx +181 -0
- package/src/primitives/metric-strip.tsx +122 -0
- package/src/primitives/page-header.test.tsx +57 -0
- package/src/primitives/page-header.tsx +86 -0
- package/src/primitives/status-pill.test.tsx +104 -0
- package/src/primitives/status-pill.tsx +193 -0
- package/src/primitives/toolbar.test.tsx +89 -0
- package/src/primitives/toolbar.tsx +105 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Metric, MetricStrip } from "./metric-strip";
|
|
4
|
+
|
|
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
|
+
});
|
|
16
|
+
|
|
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", () => {
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<MetricStrip>
|
|
23
|
+
<Metric label="Spend" value="$12.00" hint="This month" />
|
|
24
|
+
<Metric label="Runs" value="41" />
|
|
25
|
+
</MetricStrip>,
|
|
26
|
+
);
|
|
27
|
+
const dl = container.querySelector("dl") as HTMLElement;
|
|
28
|
+
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
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
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", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<MetricStrip>
|
|
47
|
+
<Metric label="Runs" value="41" />
|
|
48
|
+
</MetricStrip>,
|
|
49
|
+
);
|
|
50
|
+
expect(container.querySelectorAll("dd")).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
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
|
+
);
|
|
61
|
+
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
|
+
);
|
|
72
|
+
expect(screen.getByText("Empty")).toBeInTheDocument();
|
|
73
|
+
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");
|
|
88
|
+
expect(container.querySelectorAll("dd")).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
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");
|
|
105
|
+
});
|
|
106
|
+
|
|
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", () => {
|
|
113
|
+
const { container } = render(
|
|
114
|
+
<MetricStrip>
|
|
115
|
+
<Metric label="Balance" value="$0.00" />
|
|
116
|
+
</MetricStrip>,
|
|
117
|
+
);
|
|
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();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
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
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
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" />
|
|
174
|
+
</MetricStrip>,
|
|
175
|
+
);
|
|
176
|
+
expect(screen.getByText("$1,284,003.10")).toHaveAttribute(
|
|
177
|
+
"title",
|
|
178
|
+
"$1,284,003.10",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { StatusPill, type StatusTone } from "./status-pill";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The headline figures for a console page, as ONE statement rather than four.
|
|
7
|
+
*
|
|
8
|
+
* A row of separate stat cards is the default and it is usually wrong here.
|
|
9
|
+
* Each card is a box that must be tall enough for its longest member, so a row
|
|
10
|
+
* mixing "$248.55 / Personal wallet" against a bare "0" leaves the short ones
|
|
11
|
+
* mostly empty; and four bordered boxes read as four unrelated facts when they
|
|
12
|
+
* are four readings of one account. `MetricStrip` puts them on one plane
|
|
13
|
+
* divided by hairlines: same information, one object, no holes.
|
|
14
|
+
*
|
|
15
|
+
* The divider is a border on the item rather than a `divide-*` utility on the
|
|
16
|
+
* parent, because these wrap. `divide-x` draws from DOM order and leaves a
|
|
17
|
+
* stray rule at the start of every wrapped line; a per-item leading border
|
|
18
|
+
* suppressed at each row start does not.
|
|
19
|
+
*/
|
|
20
|
+
export interface MetricStripProps
|
|
21
|
+
extends React.HTMLAttributes<HTMLDListElement> {
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MetricStrip = React.forwardRef<HTMLDListElement, MetricStripProps>(
|
|
26
|
+
({ className, children, ...props }, ref) => (
|
|
27
|
+
<dl
|
|
28
|
+
ref={ref}
|
|
29
|
+
className={cn(
|
|
30
|
+
"grid grid-cols-2 rounded-[var(--radius-lg)] border border-border bg-card shadow-[var(--shadow-card)] sm:grid-cols-4",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</dl>
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
MetricStrip.displayName = "MetricStrip";
|
|
40
|
+
|
|
41
|
+
export interface MetricProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
42
|
+
label: React.ReactNode;
|
|
43
|
+
value: React.ReactNode;
|
|
44
|
+
/** The qualifying line: which wallet, which window, what the limit is. */
|
|
45
|
+
hint?: React.ReactNode;
|
|
46
|
+
/** Raises the value's tone and shows a pill beside the label. A metric is
|
|
47
|
+
* `attention` only when a person has to DO something — not merely when a
|
|
48
|
+
* number is zero. */
|
|
49
|
+
attention?: { tone: StatusTone; label: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One reading inside a `MetricStrip`.
|
|
54
|
+
*
|
|
55
|
+
* `<dt>` carries the label and `<dd>` the value, so the pair is announced as a
|
|
56
|
+
* described term rather than as two loose strings — the semantic that makes a
|
|
57
|
+
* figure legible without the visual grouping.
|
|
58
|
+
*
|
|
59
|
+
* The value is `tabular-nums`: these sit in a row and change on a timer, and
|
|
60
|
+
* proportional digits make the column jitter every time a 1 becomes an 8.
|
|
61
|
+
*/
|
|
62
|
+
const Metric = React.forwardRef<HTMLDivElement, MetricProps>(
|
|
63
|
+
({ className, label, value, hint, attention, ...props }, ref) => (
|
|
64
|
+
<div
|
|
65
|
+
ref={ref}
|
|
66
|
+
className={cn(
|
|
67
|
+
// Leading hairline, DRAWN only where a divider belongs — never drawn
|
|
68
|
+
// and then suppressed. Stating it as `border-l` plus `border-l-0`
|
|
69
|
+
// variants puts both in one conflict group, where the winner is decided
|
|
70
|
+
// by emitted-rule order rather than by intent; that resolved the wrong
|
|
71
|
+
// way at every breakpoint and left a rule down the start of each row.
|
|
72
|
+
// `:not(:nth-child(Nn+1))` is the row-start test for the column count
|
|
73
|
+
// at that breakpoint, so no rule ever contradicts another.
|
|
74
|
+
//
|
|
75
|
+
// N is COUPLED to the grid template on `MetricStrip`: 2 columns below
|
|
76
|
+
// `sm`, 4 from `sm`. A new breakpoint there — `md:grid-cols-3`, say —
|
|
77
|
+
// needs its `md:[&:not(:nth-child(3n+1))]:border-l` here in the same
|
|
78
|
+
// change, or the dividers land mid-row at that width.
|
|
79
|
+
"border-border p-4 sm:p-5",
|
|
80
|
+
"max-sm:[&:not(:nth-child(2n+1))]:border-l",
|
|
81
|
+
"sm:[&:not(:nth-child(4n+1))]:border-l",
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
<dt className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
87
|
+
<span className="truncate">{label}</span>
|
|
88
|
+
{attention && (
|
|
89
|
+
<StatusPill tone={attention.tone}>{attention.label}</StatusPill>
|
|
90
|
+
)}
|
|
91
|
+
</dt>
|
|
92
|
+
{/* `truncate`, and a step down until the column is wide enough for the
|
|
93
|
+
longest figure this strip carries. A balance renders as "$248.55" on a
|
|
94
|
+
funded account and as "$1,284,003.10" on a busy one, and at four
|
|
95
|
+
columns the wide case has nowhere to go — it either overflows its cell
|
|
96
|
+
or pushes the whole strip past the page. */}
|
|
97
|
+
<dd
|
|
98
|
+
className={cn(
|
|
99
|
+
"mt-1 truncate font-semibold text-xl tabular-nums tracking-tight lg:text-2xl",
|
|
100
|
+
attention?.tone === "danger" && "text-[var(--surface-danger-text)]",
|
|
101
|
+
)}
|
|
102
|
+
title={typeof value === "string" ? value : undefined}
|
|
103
|
+
>
|
|
104
|
+
{value}
|
|
105
|
+
</dd>
|
|
106
|
+
{hint && (
|
|
107
|
+
// Titled for the same reason the value is: the line truncates, and a
|
|
108
|
+
// hint is where the qualifying detail lives — which wallet, which
|
|
109
|
+
// window — so a clipped one is the case most worth recovering.
|
|
110
|
+
<dd
|
|
111
|
+
className="mt-0.5 truncate text-[var(--text-dim)] text-xs"
|
|
112
|
+
title={typeof hint === "string" ? hint : undefined}
|
|
113
|
+
>
|
|
114
|
+
{hint}
|
|
115
|
+
</dd>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
Metric.displayName = "Metric";
|
|
121
|
+
|
|
122
|
+
export { Metric, MetricStrip };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { PageHeader } from "./page-header";
|
|
4
|
+
|
|
5
|
+
describe("PageHeader", () => {
|
|
6
|
+
it("renders the title as the page's only h1", () => {
|
|
7
|
+
render(<PageHeader title="Workflows" />);
|
|
8
|
+
const headings = screen.getAllByRole("heading", { level: 1 });
|
|
9
|
+
expect(headings).toHaveLength(1);
|
|
10
|
+
expect(headings[0]).toHaveTextContent("Workflows");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("steps the title down to h2 for a nested surface", () => {
|
|
14
|
+
render(<PageHeader level={2} title="Skills" />);
|
|
15
|
+
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
|
16
|
+
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
|
|
17
|
+
"Skills",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("puts the id on the heading so a region can point aria-labelledby at it", () => {
|
|
22
|
+
render(<PageHeader titleId="keys-title" title="API keys" />);
|
|
23
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveAttribute(
|
|
24
|
+
"id",
|
|
25
|
+
"keys-title",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// The description is prose ABOUT the page, so it must not land in the
|
|
30
|
+
// heading — a paragraph rendered where a heading goes reads as body copy to a
|
|
31
|
+
// sighted reader and as the page's accessible name to a screen reader.
|
|
32
|
+
it("keeps the description out of the heading", () => {
|
|
33
|
+
render(<PageHeader title="Billing" description="Spend and invoices." />);
|
|
34
|
+
const heading = screen.getByRole("heading", { level: 1 });
|
|
35
|
+
expect(heading).toHaveTextContent("Billing");
|
|
36
|
+
expect(heading).not.toHaveTextContent("Spend and invoices.");
|
|
37
|
+
expect(screen.getByText("Spend and invoices.")).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders the actions and meta slots when given", () => {
|
|
41
|
+
render(
|
|
42
|
+
<PageHeader
|
|
43
|
+
title="Teams"
|
|
44
|
+
actions={<button type="button">Invite</button>}
|
|
45
|
+
meta={<span>4 members</span>}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
expect(screen.getByRole("button", { name: "Invite" })).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText("4 members")).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("omits the optional slots entirely when not given", () => {
|
|
53
|
+
const { container } = render(<PageHeader title="Alerts" />);
|
|
54
|
+
expect(container.querySelector("p")).toBeNull();
|
|
55
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one masthead every console page opens with.
|
|
6
|
+
*
|
|
7
|
+
* A page identifies itself in three separate registers, and each gets its own
|
|
8
|
+
* slot here so they cannot compete for the same one:
|
|
9
|
+
*
|
|
10
|
+
* - `title` — what the page IS. The `<h1>`, and the only `<h1>`.
|
|
11
|
+
* - `description` — one line on what it is for. Prose belongs here, never in
|
|
12
|
+
* the title slot: a paragraph rendered where a heading goes reads as body
|
|
13
|
+
* copy to a sighted reader and as nothing at all to a screen reader, which
|
|
14
|
+
* is how a page ends up with no accessible name.
|
|
15
|
+
* - `actions` — what the reader can DO from here, right-aligned and wrapping
|
|
16
|
+
* under the title on a narrow viewport rather than squeezing it.
|
|
17
|
+
*
|
|
18
|
+
* `meta` is the fourth register and the one most often missing: the small facts
|
|
19
|
+
* that qualify everything below (a count, a scope, a window). They sit on their
|
|
20
|
+
* own line under the title so a reader can tell the difference between "this
|
|
21
|
+
* page has nothing in it" and "this filter matches nothing".
|
|
22
|
+
*
|
|
23
|
+
* `titleId` is exposed so a page can point `aria-labelledby` at the heading
|
|
24
|
+
* from a region further down without minting a second copy of the string.
|
|
25
|
+
*/
|
|
26
|
+
export interface PageHeaderProps
|
|
27
|
+
extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
|
|
28
|
+
title: React.ReactNode;
|
|
29
|
+
description?: React.ReactNode;
|
|
30
|
+
actions?: React.ReactNode;
|
|
31
|
+
meta?: React.ReactNode;
|
|
32
|
+
titleId?: string;
|
|
33
|
+
/** Renders the title one step down for a nested/tab surface. */
|
|
34
|
+
level?: 1 | 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(
|
|
38
|
+
(
|
|
39
|
+
{
|
|
40
|
+
className,
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
actions,
|
|
44
|
+
meta,
|
|
45
|
+
titleId,
|
|
46
|
+
level = 1,
|
|
47
|
+
...props
|
|
48
|
+
},
|
|
49
|
+
ref,
|
|
50
|
+
) => {
|
|
51
|
+
const Heading = level === 1 ? "h1" : "h2";
|
|
52
|
+
return (
|
|
53
|
+
<header ref={ref} className={cn("mb-6", className)} {...props}>
|
|
54
|
+
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
|
|
55
|
+
<div className="min-w-0">
|
|
56
|
+
<Heading
|
|
57
|
+
id={titleId}
|
|
58
|
+
className={cn(
|
|
59
|
+
"text-balance font-semibold tracking-tight",
|
|
60
|
+
level === 1 ? "text-2xl" : "text-lg",
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
{title}
|
|
64
|
+
</Heading>
|
|
65
|
+
{description && (
|
|
66
|
+
<p className="mt-1 max-w-prose text-muted-foreground text-sm">
|
|
67
|
+
{description}
|
|
68
|
+
</p>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
{actions && (
|
|
72
|
+
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
{meta && (
|
|
76
|
+
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-[var(--text-dim)] text-xs">
|
|
77
|
+
{meta}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</header>
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
PageHeader.displayName = "PageHeader";
|
|
85
|
+
|
|
86
|
+
export { PageHeader };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { StatusPill, type StatusTone } from "./status-pill";
|
|
4
|
+
|
|
5
|
+
const TONES: StatusTone[] = [
|
|
6
|
+
"success",
|
|
7
|
+
"warning",
|
|
8
|
+
"danger",
|
|
9
|
+
"info",
|
|
10
|
+
"running",
|
|
11
|
+
"neutral",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("StatusPill", () => {
|
|
15
|
+
it("states the status as text, not colour alone", () => {
|
|
16
|
+
render(<StatusPill tone="danger">Failed</StatusPill>);
|
|
17
|
+
expect(screen.getByText("Failed")).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The glyph is the channel that survives greyscale and colour blindness, so
|
|
21
|
+
// every tone must carry one and it must be hidden from the accessibility tree
|
|
22
|
+
// — the label beside it already says the same thing.
|
|
23
|
+
it.each(TONES)("gives %s a decorative glyph", (tone) => {
|
|
24
|
+
const { container } = render(<StatusPill tone={tone}>Status</StatusPill>);
|
|
25
|
+
const glyph = container.querySelector("svg");
|
|
26
|
+
expect(glyph).not.toBeNull();
|
|
27
|
+
expect(glyph).toHaveAttribute("aria-hidden", "true");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each(TONES)("brings %s's own paired background", (tone) => {
|
|
31
|
+
const { container } = render(<StatusPill tone={tone}>Status</StatusPill>);
|
|
32
|
+
const pill = container.firstElementChild as HTMLElement;
|
|
33
|
+
expect(pill.className).toContain(`bg-[var(--surface-`);
|
|
34
|
+
expect(pill.className).toContain("border");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// A tone colour is solved against its own background. `bare` drops that
|
|
38
|
+
// background, so the tone may ride on the glyph only — lifting it onto the
|
|
39
|
+
// label would put it on whatever plane the caller happens to be on.
|
|
40
|
+
it.each(TONES)("keeps %s's tone off the label in bare mode", (tone) => {
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<StatusPill bare tone={tone}>
|
|
43
|
+
Status
|
|
44
|
+
</StatusPill>,
|
|
45
|
+
);
|
|
46
|
+
const pill = container.firstElementChild as HTMLElement;
|
|
47
|
+
expect(pill.className).toContain("bg-transparent");
|
|
48
|
+
expect(pill.className).not.toContain("bg-[var(--surface-");
|
|
49
|
+
|
|
50
|
+
const glyphWrapper = container.querySelector("svg")
|
|
51
|
+
?.parentElement as HTMLElement;
|
|
52
|
+
expect(glyphWrapper.className).toContain("text-[var(--surface-");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Guards the reason TONE_TEXT is declared rather than recovered by searching
|
|
56
|
+
// TONE_SURFACE for a `text-` class: a miss there yields undefined and removes
|
|
57
|
+
// the tone from the only channel bare mode has left.
|
|
58
|
+
it.each(TONES)("resolves a real text class for %s in bare mode", (tone) => {
|
|
59
|
+
const { container } = render(
|
|
60
|
+
<StatusPill bare tone={tone}>
|
|
61
|
+
Status
|
|
62
|
+
</StatusPill>,
|
|
63
|
+
);
|
|
64
|
+
const glyphWrapper = container.querySelector("svg")
|
|
65
|
+
?.parentElement as HTMLElement;
|
|
66
|
+
expect(glyphWrapper.className).not.toContain("undefined");
|
|
67
|
+
expect(glyphWrapper.className).toMatch(/text-\[var\(--surface-[a-z]+-text\)\]/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// `px-2` from the size and `px-0` from bare are the same utility group, so
|
|
71
|
+
// which one lands is decided by the class merge rather than by reading order.
|
|
72
|
+
// Asserting the resolved class list keeps that a guarantee: a bare pill has to
|
|
73
|
+
// sit flush against the text beside it, and silently keeping the size padding
|
|
74
|
+
// would space it like a chip that lost only its background.
|
|
75
|
+
it.each(["sm", "md"] as const)("drops the %s size padding in bare mode", (size) => {
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<StatusPill bare size={size} tone="info">
|
|
78
|
+
Queued
|
|
79
|
+
</StatusPill>,
|
|
80
|
+
);
|
|
81
|
+
const cls = (container.firstElementChild as HTMLElement).className;
|
|
82
|
+
expect(cls).toContain("px-0");
|
|
83
|
+
expect(cls).not.toMatch(/(^| )px-2(\.5)?( |$)/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("gives no two tones the same silhouette", () => {
|
|
87
|
+
const shapes = TONES.map((tone) => {
|
|
88
|
+
const { container } = render(<StatusPill tone={tone}>S</StatusPill>);
|
|
89
|
+
return container.querySelector("svg")?.innerHTML ?? "";
|
|
90
|
+
});
|
|
91
|
+
expect(new Set(shapes).size).toBe(shapes.length);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("takes a larger size", () => {
|
|
95
|
+
const { container } = render(
|
|
96
|
+
<StatusPill size="md" tone="info">
|
|
97
|
+
Queued
|
|
98
|
+
</StatusPill>,
|
|
99
|
+
);
|
|
100
|
+
expect((container.firstElementChild as HTMLElement).className).toContain(
|
|
101
|
+
"text-sm",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|