@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,193 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A run/resource state, told three ways at once.
|
|
6
|
+
*
|
|
7
|
+
* Status is the one place a console cannot afford to speak in colour alone: a
|
|
8
|
+
* red dot and a green dot are the same dot to roughly one man in twelve, and
|
|
9
|
+
* identical in a greyscale print or a screenshot pasted into a ticket. So every
|
|
10
|
+
* pill carries all three channels — a GLYPH whose silhouette differs per tone,
|
|
11
|
+
* the tone's COLOUR, and the state's own LABEL as text.
|
|
12
|
+
*
|
|
13
|
+
* The glyphs are chosen to survive at 8px and to differ in outline rather than
|
|
14
|
+
* in fill: a ring reads as "still open", a solid disc as "settled", a slashed
|
|
15
|
+
* disc as "stopped". Two states never share one silhouette.
|
|
16
|
+
*
|
|
17
|
+
* Each tone draws its fill, border and text from ONE matched token triple.
|
|
18
|
+
* That pairing is the whole point: a status colour is solved against its own
|
|
19
|
+
* background, and nothing guarantees it against an arbitrary one. A component
|
|
20
|
+
* that always brings its own background cannot be placed onto a plane that
|
|
21
|
+
* breaks it.
|
|
22
|
+
*/
|
|
23
|
+
export type StatusTone =
|
|
24
|
+
| "success"
|
|
25
|
+
| "warning"
|
|
26
|
+
| "danger"
|
|
27
|
+
| "info"
|
|
28
|
+
| "neutral"
|
|
29
|
+
| "running";
|
|
30
|
+
|
|
31
|
+
export interface StatusPillProps
|
|
32
|
+
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
33
|
+
tone: StatusTone;
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
/**
|
|
36
|
+
* Drops the fill and border, leaving a toned GLYPH beside a label in the
|
|
37
|
+
* inherited body colour.
|
|
38
|
+
*
|
|
39
|
+
* The label deliberately does not keep the tone. Every status text token
|
|
40
|
+
* clears the 4.5:1 body floor on the page canvas — a gate holds them there —
|
|
41
|
+
* but in light the margin is thin (4.51:1 for warning, 4.54:1 for success),
|
|
42
|
+
* and the canvas is only one of the planes a caller can put a pill on. The
|
|
43
|
+
* glyph keeps the tone instead because a glyph is non-text content against a
|
|
44
|
+
* 3:1 floor, so the tone still reads with room to spare on any plane.
|
|
45
|
+
*
|
|
46
|
+
* For a control that supplies its own surface (a chip, a selected row).
|
|
47
|
+
*/
|
|
48
|
+
bare?: boolean;
|
|
49
|
+
size?: "sm" | "md";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TONE_SURFACE: Record<StatusTone, string> = {
|
|
53
|
+
success:
|
|
54
|
+
"bg-[var(--surface-success-bg)] text-[var(--surface-success-text)] border-[var(--surface-success-border)]",
|
|
55
|
+
warning:
|
|
56
|
+
"bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)] border-[var(--surface-warning-border)]",
|
|
57
|
+
danger:
|
|
58
|
+
"bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)] border-[var(--surface-danger-border)]",
|
|
59
|
+
info: "bg-[var(--surface-info-bg)] text-[var(--surface-info-text)] border-[var(--surface-info-border)]",
|
|
60
|
+
// `running` shares the info triple on purpose. `--status-running` exists but
|
|
61
|
+
// is the green dot on a sandbox — a machine's power state, not a run's
|
|
62
|
+
// outcome — and it ships as one colour with no paired background or text
|
|
63
|
+
// tier, which is the pairing this component is built to guarantee. Green also
|
|
64
|
+
// already means SUCCEEDED here, so a green "running" pill would say a run had
|
|
65
|
+
// finished well while it was still going.
|
|
66
|
+
running:
|
|
67
|
+
"bg-[var(--surface-info-bg)] text-[var(--surface-info-text)] border-[var(--surface-info-border)]",
|
|
68
|
+
neutral:
|
|
69
|
+
"bg-[var(--surface-neutral-bg)] text-[var(--surface-neutral-text)] border-[var(--surface-neutral-border)]",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The text tier alone, for `bare`. Declared rather than recovered from
|
|
74
|
+
* `TONE_SURFACE` by string search: picking the `text-` class out of that string
|
|
75
|
+
* silently yields `undefined` the moment the triple gains a second `text-`
|
|
76
|
+
* utility, and an undefined class removes the tone from the one channel `bare`
|
|
77
|
+
* has left — the glyph — with nothing to signal that it happened.
|
|
78
|
+
*/
|
|
79
|
+
const TONE_TEXT: Record<StatusTone, string> = {
|
|
80
|
+
success: "text-[var(--surface-success-text)]",
|
|
81
|
+
warning: "text-[var(--surface-warning-text)]",
|
|
82
|
+
danger: "text-[var(--surface-danger-text)]",
|
|
83
|
+
info: "text-[var(--surface-info-text)]",
|
|
84
|
+
running: "text-[var(--surface-info-text)]",
|
|
85
|
+
neutral: "text-[var(--surface-neutral-text)]",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One glyph per silhouette. `currentColor` throughout so the mark inherits the
|
|
90
|
+
* tone's text colour and can never drift from the label beside it.
|
|
91
|
+
*/
|
|
92
|
+
function ToneGlyph({ tone }: { tone: StatusTone }) {
|
|
93
|
+
// `aria-hidden` is written on every `<svg>` rather than carried in this
|
|
94
|
+
// spread: the label beside the glyph already names the state, so a title here
|
|
95
|
+
// would announce it twice — and a lint rule that reads JSX statically cannot
|
|
96
|
+
// see the attribute through a spread, so it reports each mark as an unlabelled
|
|
97
|
+
// image.
|
|
98
|
+
const common = {
|
|
99
|
+
viewBox: "0 0 8 8",
|
|
100
|
+
className: "size-2 shrink-0",
|
|
101
|
+
};
|
|
102
|
+
switch (tone) {
|
|
103
|
+
// Settled and good: a solid disc.
|
|
104
|
+
case "success":
|
|
105
|
+
return (
|
|
106
|
+
<svg {...common} aria-hidden="true" fill="currentColor">
|
|
107
|
+
<circle cx="4" cy="4" r="3.25" />
|
|
108
|
+
</svg>
|
|
109
|
+
);
|
|
110
|
+
// Settled and bad: a disc with a bar through it, so it differs from
|
|
111
|
+
// success in OUTLINE and not only in hue.
|
|
112
|
+
case "danger":
|
|
113
|
+
return (
|
|
114
|
+
<svg
|
|
115
|
+
{...common}
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
fill="none"
|
|
118
|
+
stroke="currentColor"
|
|
119
|
+
strokeWidth="1.5"
|
|
120
|
+
>
|
|
121
|
+
<circle cx="4" cy="4" r="3.1" fill="currentColor" opacity="0.35" />
|
|
122
|
+
<path d="M1.9 6.1 6.1 1.9" strokeLinecap="round" />
|
|
123
|
+
</svg>
|
|
124
|
+
);
|
|
125
|
+
// Needs a person: a triangle.
|
|
126
|
+
case "warning":
|
|
127
|
+
return (
|
|
128
|
+
<svg {...common} aria-hidden="true" fill="currentColor">
|
|
129
|
+
<path d="M4 0.6 7.7 7.1H0.3z" />
|
|
130
|
+
</svg>
|
|
131
|
+
);
|
|
132
|
+
// Still moving: an open ring, visibly hollow at 8px.
|
|
133
|
+
case "running":
|
|
134
|
+
return (
|
|
135
|
+
<svg
|
|
136
|
+
{...common}
|
|
137
|
+
aria-hidden="true"
|
|
138
|
+
fill="none"
|
|
139
|
+
stroke="currentColor"
|
|
140
|
+
strokeWidth="1.75"
|
|
141
|
+
>
|
|
142
|
+
<circle cx="4" cy="4" r="2.9" />
|
|
143
|
+
</svg>
|
|
144
|
+
);
|
|
145
|
+
// Informational: a square, the only right-angled mark in the set.
|
|
146
|
+
case "info":
|
|
147
|
+
return (
|
|
148
|
+
<svg {...common} aria-hidden="true" fill="currentColor">
|
|
149
|
+
<rect x="0.9" y="0.9" width="6.2" height="6.2" rx="1.2" />
|
|
150
|
+
</svg>
|
|
151
|
+
);
|
|
152
|
+
// Nothing has happened: a dash. No enclosed area at all.
|
|
153
|
+
default:
|
|
154
|
+
return (
|
|
155
|
+
<svg
|
|
156
|
+
{...common}
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
fill="none"
|
|
159
|
+
stroke="currentColor"
|
|
160
|
+
strokeWidth="1.75"
|
|
161
|
+
>
|
|
162
|
+
<path d="M1 4h6" strokeLinecap="round" />
|
|
163
|
+
</svg>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const StatusPill = React.forwardRef<HTMLSpanElement, StatusPillProps>(
|
|
169
|
+
({ className, tone, children, bare = false, size = "sm", ...props }, ref) => (
|
|
170
|
+
<span
|
|
171
|
+
ref={ref}
|
|
172
|
+
className={cn(
|
|
173
|
+
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-full font-medium",
|
|
174
|
+
size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
|
|
175
|
+
bare
|
|
176
|
+
? "border border-transparent bg-transparent px-0"
|
|
177
|
+
: cn("border", TONE_SURFACE[tone]),
|
|
178
|
+
className,
|
|
179
|
+
)}
|
|
180
|
+
{...props}
|
|
181
|
+
>
|
|
182
|
+
{/* In `bare` mode the tone rides on the glyph alone, so it is scoped to
|
|
183
|
+
the wrapper the glyph sits in rather than applied to the whole pill. */}
|
|
184
|
+
<span className={cn("inline-flex", bare && TONE_TEXT[tone])}>
|
|
185
|
+
<ToneGlyph tone={tone} />
|
|
186
|
+
</span>
|
|
187
|
+
{children}
|
|
188
|
+
</span>
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
StatusPill.displayName = "StatusPill";
|
|
192
|
+
|
|
193
|
+
export { StatusPill };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { FilterField, Toolbar } from "./toolbar";
|
|
4
|
+
|
|
5
|
+
describe("Toolbar", () => {
|
|
6
|
+
it("renders each slot it is given", () => {
|
|
7
|
+
render(
|
|
8
|
+
<Toolbar
|
|
9
|
+
actions={<button type="button">Export</button>}
|
|
10
|
+
filters={<span>Product</span>}
|
|
11
|
+
search={<input aria-label="Search" type="search" />}
|
|
12
|
+
/>,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByLabelText("Search")).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText("Product")).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("omits a slot's wrapper entirely when it is not given", () => {
|
|
20
|
+
const { container } = render(
|
|
21
|
+
<Toolbar search={<input aria-label="Search" type="search" />} />,
|
|
22
|
+
);
|
|
23
|
+
const row = container.firstElementChild as HTMLElement;
|
|
24
|
+
expect(row.children).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Search and filters both flex, on purpose: giving the filter row its content
|
|
28
|
+
// width starves search — a 537px filter row in an 832px toolbar collapses the
|
|
29
|
+
// search field to 89px. Sharing bounds the filter row so it scrolls within its
|
|
30
|
+
// half and search stays usable. Only `actions` sits at content width.
|
|
31
|
+
it("lets search and filters share the row, holding actions at content width", () => {
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<Toolbar
|
|
34
|
+
actions={<button type="button">Export</button>}
|
|
35
|
+
filters={<span>Product</span>}
|
|
36
|
+
search={<input aria-label="Search" type="search" />}
|
|
37
|
+
/>,
|
|
38
|
+
);
|
|
39
|
+
const [searchSlot, filterSlot, actionsSlot] = Array.from(
|
|
40
|
+
(container.firstElementChild as HTMLElement).children,
|
|
41
|
+
) as HTMLElement[];
|
|
42
|
+
expect(searchSlot.className).toContain("lg:flex-1");
|
|
43
|
+
expect(searchSlot.className).toContain("min-w-0");
|
|
44
|
+
// Both flex, so neither can starve the other.
|
|
45
|
+
expect(filterSlot.className).toContain("lg:flex-1");
|
|
46
|
+
expect(filterSlot.className).toContain("min-w-0");
|
|
47
|
+
expect(actionsSlot.className).toContain("shrink-0");
|
|
48
|
+
expect(actionsSlot.className).not.toContain("flex-1");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("scrolls the filter row instead of wrapping it", () => {
|
|
52
|
+
const { container } = render(<Toolbar filters={<span>Product</span>} />);
|
|
53
|
+
const filterSlot = (container.firstElementChild as HTMLElement)
|
|
54
|
+
.children[0] as HTMLElement;
|
|
55
|
+
expect(filterSlot.className).toContain("overflow-x-auto");
|
|
56
|
+
expect(filterSlot.className).toContain("min-w-0");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// A free child would render as a bare flex item with none of the slots'
|
|
60
|
+
// guards, so it would size off its content and push the row into overflow.
|
|
61
|
+
it("renders nothing for a stray child", () => {
|
|
62
|
+
const { container } = render(
|
|
63
|
+
// @ts-expect-error children is deliberately not part of the API
|
|
64
|
+
<Toolbar>
|
|
65
|
+
<span>stray</span>
|
|
66
|
+
</Toolbar>,
|
|
67
|
+
);
|
|
68
|
+
expect(screen.queryByText("stray")).toBeNull();
|
|
69
|
+
expect((container.firstElementChild as HTMLElement).children).toHaveLength(
|
|
70
|
+
0,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("FilterField", () => {
|
|
76
|
+
it("labels the control visibly rather than by placeholder", () => {
|
|
77
|
+
render(
|
|
78
|
+
<FilterField htmlFor="product" label="Product">
|
|
79
|
+
<select id="product">
|
|
80
|
+
<option>All products</option>
|
|
81
|
+
</select>
|
|
82
|
+
</FilterField>,
|
|
83
|
+
);
|
|
84
|
+
expect(screen.getByText("Product")).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByLabelText("Product")).toBe(
|
|
86
|
+
screen.getByRole("combobox"),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The control row above a table or list: search, filters, then the view switch.
|
|
6
|
+
*
|
|
7
|
+
* It exists because the alternative keeps producing the same two failures. A
|
|
8
|
+
* bare flex row gives every control its natural width, so a search field sits
|
|
9
|
+
* at 1120px on one line and three selects stack full-width beneath it — three
|
|
10
|
+
* enormous empty bars where a compact bar belongs. And each control brought its
|
|
11
|
+
* own visual language, so one row carried a native `<select>`, a custom input
|
|
12
|
+
* and a segmented control side by side.
|
|
13
|
+
*
|
|
14
|
+
* So search and filters SHARE the free space and `actions` stays at its content
|
|
15
|
+
* width, and the row is the one place those controls are composed, which is what
|
|
16
|
+
* keeps them looking like one set.
|
|
17
|
+
*
|
|
18
|
+
* Sharing is deliberate, and the alternative is worse. Giving the filter row its
|
|
19
|
+
* content width starves search: measured with a 537px filter row in an 832px
|
|
20
|
+
* toolbar, the search field collapses to 89px. Letting both flex bounds the
|
|
21
|
+
* filter row instead, so it scrolls within its half — 313px each in that same
|
|
22
|
+
* case — and search stays usable however many filters a page adds. `max-w-sm`
|
|
23
|
+
* then stops search claiming more than it can use when there are few filters.
|
|
24
|
+
*
|
|
25
|
+
* On a narrow viewport the row becomes a column and the filters scroll
|
|
26
|
+
* horizontally as a group rather than wrapping into a tall stack that pushes
|
|
27
|
+
* the table itself below the fold.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Slots only — `children` is omitted deliberately. A free child would render as
|
|
31
|
+
* a bare flex item with none of the `min-w-0` / `shrink-0` guards the named
|
|
32
|
+
* slots carry, so it would size itself off its content and push the row into
|
|
33
|
+
* the overflow this layout exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export interface ToolbarProps
|
|
36
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
37
|
+
/** The one control that expands to fill the row. */
|
|
38
|
+
search?: React.ReactNode;
|
|
39
|
+
/** Filters, in reading order. Kept on one scrollable line when space runs out. */
|
|
40
|
+
filters?: React.ReactNode;
|
|
41
|
+
/** View switches and exports, pinned to the end. */
|
|
42
|
+
actions?: React.ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const Toolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
|
|
46
|
+
({ className, search, filters, actions, ...props }, ref) => (
|
|
47
|
+
<div
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={cn(
|
|
50
|
+
"mb-4 flex flex-col gap-3 lg:flex-row lg:items-center",
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{search && <div className="min-w-0 lg:max-w-sm lg:flex-1">{search}</div>}
|
|
56
|
+
{filters && (
|
|
57
|
+
// `py-1` leaves room for a focus ring above and below; horizontally the
|
|
58
|
+
// scroll container clips it, which is the trade a single-line filter row
|
|
59
|
+
// makes. No negative margin — pulling the row wider than its parent is
|
|
60
|
+
// what made every page report 4px of horizontal overflow.
|
|
61
|
+
<div className="flex min-w-0 items-center gap-2 overflow-x-auto py-1 lg:flex-1">
|
|
62
|
+
{filters}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
{actions && (
|
|
66
|
+
<div className="flex shrink-0 items-center gap-2 lg:ml-auto">
|
|
67
|
+
{actions}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
Toolbar.displayName = "Toolbar";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A labelled filter control. The label is visible, not a placeholder: a select
|
|
77
|
+
* whose current value IS its label ("All products") tells the reader what is
|
|
78
|
+
* selected but never what the control governs, so a row of three reads as three
|
|
79
|
+
* unrelated words.
|
|
80
|
+
*/
|
|
81
|
+
export interface FilterFieldProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
82
|
+
label: string;
|
|
83
|
+
htmlFor?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const FilterField = React.forwardRef<HTMLDivElement, FilterFieldProps>(
|
|
87
|
+
({ className, label, htmlFor, children, ...props }, ref) => (
|
|
88
|
+
<div
|
|
89
|
+
ref={ref}
|
|
90
|
+
className={cn("flex shrink-0 items-center gap-2", className)}
|
|
91
|
+
{...props}
|
|
92
|
+
>
|
|
93
|
+
<label
|
|
94
|
+
htmlFor={htmlFor}
|
|
95
|
+
className="whitespace-nowrap text-muted-foreground text-xs"
|
|
96
|
+
>
|
|
97
|
+
{label}
|
|
98
|
+
</label>
|
|
99
|
+
{children}
|
|
100
|
+
</div>
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
FilterField.displayName = "FilterField";
|
|
104
|
+
|
|
105
|
+
export { FilterField, Toolbar };
|