@tangle-network/ui 11.2.3 → 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 +24 -0
- package/dist/{chunk-XZFJAF6G.js → chunk-EJ5NBW7V.js} +279 -4
- 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/faint-text.test.tsx +127 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/input.tsx +2 -2
- 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/stat-card.tsx +5 -1
- package/src/primitives/status-pill.test.tsx +104 -0
- package/src/primitives/status-pill.tsx +193 -0
- package/src/primitives/terminal-display.tsx +1 -1
- package/src/primitives/toolbar.test.tsx +89 -0
- package/src/primitives/toolbar.tsx +105 -0
- package/src/stories/theme-showcase.stories.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.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",
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
"react-dom": "^18 || ^19",
|
|
142
142
|
"react-router": "^7 || ^8",
|
|
143
143
|
"yjs": "^13.6.0",
|
|
144
|
-
"@tangle-network/brand": "^1.
|
|
144
|
+
"@tangle-network/brand": "^1.4.0"
|
|
145
145
|
},
|
|
146
146
|
"peerDependenciesMeta": {
|
|
147
147
|
"@nanostores/react": {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { Input, Textarea } from "./input"
|
|
4
|
+
import { StatCard } from "./stat-card"
|
|
5
|
+
import { TerminalLine } from "./terminal-display"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The quietest line in each primitive — a field hint, a stat subtitle, a log
|
|
9
|
+
* timestamp — must take its colour from a TOKEN, never from a faded stronger
|
|
10
|
+
* one.
|
|
11
|
+
*
|
|
12
|
+
* A translucent foreground does not render the colour it names: it renders that
|
|
13
|
+
* colour composited over whatever plane is behind it. So `text-muted-foreground/70`
|
|
14
|
+
* measures one ratio on a card and a different one on the canvas, and the same
|
|
15
|
+
* markup is legible in one place and not in another. That is how these three
|
|
16
|
+
* landed at 3.65:1, 2.91:1 and 3.4:1 while looking, in the source, like they had
|
|
17
|
+
* been styled deliberately.
|
|
18
|
+
*
|
|
19
|
+
* These assert the RULE rather than the current class string — the point is that
|
|
20
|
+
* faint text carries no alpha, not that it carries one specific token today.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A class with its variant chain removed: `dark:hover:text-white/50` → `text-white/50`.
|
|
25
|
+
*
|
|
26
|
+
* Split on the colons that separate variants, which are the ones at bracket
|
|
27
|
+
* depth zero — an arbitrary value carries its own (`text-[color:var(--x)]`,
|
|
28
|
+
* `supports-[display:grid]:`) and splitting on those would mangle the utility.
|
|
29
|
+
*/
|
|
30
|
+
function bareUtility(cls: string): string {
|
|
31
|
+
let depth = 0
|
|
32
|
+
let start = 0
|
|
33
|
+
for (let i = 0; i < cls.length; i++) {
|
|
34
|
+
const c = cls[i]
|
|
35
|
+
if (c === "[") depth++
|
|
36
|
+
else if (c === "]") depth--
|
|
37
|
+
else if (c === ":" && depth === 0) start = i + 1
|
|
38
|
+
}
|
|
39
|
+
return cls.slice(start)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Colour utilities, where a trailing `/NN` is an alpha modifier rather than a fraction. */
|
|
43
|
+
const COLOUR = "text|bg|border|ring|outline|decoration|placeholder|caret|accent|fill|stroke|divide|from|via|to|shadow"
|
|
44
|
+
/** `text-white/70`, `bg-card/60`, `text-[var(--x)]/40`. */
|
|
45
|
+
const ALPHA_MODIFIER = new RegExp(`^(?:${COLOUR})-.+/\\d{1,3}$`)
|
|
46
|
+
/** `opacity-50` — the same fade by another route. */
|
|
47
|
+
const OPACITY_UTILITY = /^opacity-\d{1,3}$/
|
|
48
|
+
|
|
49
|
+
function expectSolidColour(el: HTMLElement | null, what: string) {
|
|
50
|
+
expect(el, `${what} should render`).not.toBeNull()
|
|
51
|
+
// Per class, not across the whole string: a variant prefix has no whitespace
|
|
52
|
+
// in it, so a single regex over the joined list never sees where one class
|
|
53
|
+
// ends — and `dark:text-white/50` slips past a pattern anchored on spaces.
|
|
54
|
+
const faded = (el?.className ?? "")
|
|
55
|
+
.split(/\s+/)
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.filter((cls) => {
|
|
58
|
+
const bare = bareUtility(cls)
|
|
59
|
+
return ALPHA_MODIFIER.test(bare) || OPACITY_UTILITY.test(bare)
|
|
60
|
+
})
|
|
61
|
+
expect(faded, `${what} must take a token, not a faded colour`).toEqual([])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("the fade detector itself", () => {
|
|
65
|
+
// The guard below is only worth as much as this table. An earlier version
|
|
66
|
+
// anchored on whitespace and so never saw a variant prefix — `dark:text-white/50`
|
|
67
|
+
// read as clean, which is the exact mistake the guard exists to catch.
|
|
68
|
+
const FADED = [
|
|
69
|
+
"text-muted-foreground/70",
|
|
70
|
+
"text-[var(--text-dim)]/70",
|
|
71
|
+
"dark:text-white/50",
|
|
72
|
+
"hover:text-muted-foreground/70",
|
|
73
|
+
"group-hover:text-foreground/40",
|
|
74
|
+
"md:bg-card/60",
|
|
75
|
+
"supports-[display:grid]:text-white/50",
|
|
76
|
+
"opacity-50",
|
|
77
|
+
"hover:opacity-50",
|
|
78
|
+
]
|
|
79
|
+
const SOLID = [
|
|
80
|
+
"text-[var(--text-dim)]",
|
|
81
|
+
"text-sm",
|
|
82
|
+
"text-muted-foreground",
|
|
83
|
+
"dark:text-white",
|
|
84
|
+
// A slash that is a FRACTION, not an alpha modifier — must not trip the guard.
|
|
85
|
+
"w-1/2",
|
|
86
|
+
"basis-1/3",
|
|
87
|
+
// A colon inside an arbitrary value, which must survive variant-stripping.
|
|
88
|
+
"text-[color:var(--text-dim)]",
|
|
89
|
+
]
|
|
90
|
+
const isFaded = (cls: string) =>
|
|
91
|
+
ALPHA_MODIFIER.test(bareUtility(cls)) || OPACITY_UTILITY.test(bareUtility(cls))
|
|
92
|
+
|
|
93
|
+
for (const cls of FADED) {
|
|
94
|
+
it(`catches ${cls}`, () => expect(isFaded(cls)).toBe(true))
|
|
95
|
+
}
|
|
96
|
+
for (const cls of SOLID) {
|
|
97
|
+
it(`allows ${cls}`, () => expect(isFaded(cls)).toBe(false))
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("faint text is a token, not a faded one", () => {
|
|
102
|
+
it("Input renders its hint at full strength", () => {
|
|
103
|
+
render(<Input label="Name" hint="Shown on your public profile" />)
|
|
104
|
+
expectSolidColour(
|
|
105
|
+
screen.getByText("Shown on your public profile"),
|
|
106
|
+
"the Input hint",
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("Textarea renders its hint at full strength", () => {
|
|
111
|
+
render(<Textarea label="Bio" hint="Markdown is supported" />)
|
|
112
|
+
expectSolidColour(
|
|
113
|
+
screen.getByText("Markdown is supported"),
|
|
114
|
+
"the Textarea hint",
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("StatCard renders its subtitle at full strength", () => {
|
|
119
|
+
render(<StatCard title="Sandboxes" value={12} subtitle="up from 9" />)
|
|
120
|
+
expectSolidColour(screen.getByText("up from 9"), "the StatCard subtitle")
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("TerminalLine renders its timestamp at full strength", () => {
|
|
124
|
+
render(<TerminalLine timestamp="12:04:11">compiling</TerminalLine>)
|
|
125
|
+
expectSolidColour(screen.getByText("[12:04:11]"), "the TerminalLine timestamp")
|
|
126
|
+
})
|
|
127
|
+
})
|
package/src/primitives/index.ts
CHANGED
|
@@ -118,3 +118,15 @@ export { CodeBlock, CopyButton, InlineCode } from "./code-block";
|
|
|
118
118
|
export type { CodeBlockProps, InlineCodeProps } from "./code-block";
|
|
119
119
|
|
|
120
120
|
export { ThemeToggle, useTheme } from "./theme-toggle";
|
|
121
|
+
|
|
122
|
+
export { PageHeader } from "./page-header";
|
|
123
|
+
export type { PageHeaderProps } from "./page-header";
|
|
124
|
+
|
|
125
|
+
export { StatusPill } from "./status-pill";
|
|
126
|
+
export type { StatusPillProps, StatusTone } from "./status-pill";
|
|
127
|
+
|
|
128
|
+
export { MetricStrip, Metric } from "./metric-strip";
|
|
129
|
+
export type { MetricStripProps, MetricProps } from "./metric-strip";
|
|
130
|
+
|
|
131
|
+
export { Toolbar, FilterField } from "./toolbar";
|
|
132
|
+
export type { ToolbarProps, FilterFieldProps } from "./toolbar";
|
package/src/primitives/input.tsx
CHANGED
|
@@ -65,7 +65,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
65
65
|
{input}
|
|
66
66
|
{error && <p className="text-[var(--surface-danger-text)] text-sm font-medium">{error}</p>}
|
|
67
67
|
{hint && !error && (
|
|
68
|
-
<p className="text-
|
|
68
|
+
<p className="text-[var(--text-dim)] text-sm">{hint}</p>
|
|
69
69
|
)}
|
|
70
70
|
</div>
|
|
71
71
|
);
|
|
@@ -125,7 +125,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
125
125
|
{textarea}
|
|
126
126
|
{error && <p className="text-[var(--surface-danger-text)] text-sm">{error}</p>}
|
|
127
127
|
{hint && !error && (
|
|
128
|
-
<p className="text-
|
|
128
|
+
<p className="text-[var(--text-dim)] text-sm">{hint}</p>
|
|
129
129
|
)}
|
|
130
130
|
</div>
|
|
131
131
|
);
|
|
@@ -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 };
|
|
@@ -58,8 +58,12 @@ const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
|
|
|
58
58
|
<div className="space-y-1">
|
|
59
59
|
<p className="text-muted-foreground text-sm">{title}</p>
|
|
60
60
|
<p className="font-bold text-3xl tracking-tight">{value}</p>
|
|
61
|
+
{/* A token, never `opacity-70` over the muted tone: a translucent
|
|
62
|
+
foreground renders as the token COMPOSITED over whatever plane sits
|
|
63
|
+
behind it, so the same class lands a different colour — and a
|
|
64
|
+
different contrast ratio — on a card than on the canvas. */}
|
|
61
65
|
{subtitle && (
|
|
62
|
-
<p className="text-
|
|
66
|
+
<p className="text-[var(--text-dim)] text-xs">{subtitle}</p>
|
|
63
67
|
)}
|
|
64
68
|
{trend && trendStatus && (
|
|
65
69
|
<div
|