@tangle-network/ui 11.2.4 → 11.4.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 +26 -0
- package/dist/{chunk-HSMVDKL3.js → chunk-EJ5NBW7V.js} +275 -0
- package/dist/chunk-HGML45QH.js +104 -0
- package/dist/index.d.ts +3 -43
- package/dist/index.js +16 -98
- package/dist/primitives.d.ts +171 -1
- package/dist/primitives.js +13 -1
- package/dist/redaction.d.ts +42 -0
- package/dist/redaction.js +7 -0
- package/package.json +7 -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
- package/src/redaction/index.ts +12 -0
- package/src/run/run-row-shell.tsx +4 -1
- /package/src/primitives/{faint-text.test.tsx → faint-text-tokens.test.tsx} +0 -0
package/dist/primitives.d.ts
CHANGED
|
@@ -336,4 +336,174 @@ declare function useTheme(): {
|
|
|
336
336
|
};
|
|
337
337
|
declare function ThemeToggle(): react_jsx_runtime.JSX.Element;
|
|
338
338
|
|
|
339
|
-
|
|
339
|
+
/**
|
|
340
|
+
* The one masthead every console page opens with.
|
|
341
|
+
*
|
|
342
|
+
* A page identifies itself in three separate registers, and each gets its own
|
|
343
|
+
* slot here so they cannot compete for the same one:
|
|
344
|
+
*
|
|
345
|
+
* - `title` — what the page IS. The `<h1>`, and the only `<h1>`.
|
|
346
|
+
* - `description` — one line on what it is for. Prose belongs here, never in
|
|
347
|
+
* the title slot: a paragraph rendered where a heading goes reads as body
|
|
348
|
+
* copy to a sighted reader and as nothing at all to a screen reader, which
|
|
349
|
+
* is how a page ends up with no accessible name.
|
|
350
|
+
* - `actions` — what the reader can DO from here, right-aligned and wrapping
|
|
351
|
+
* under the title on a narrow viewport rather than squeezing it.
|
|
352
|
+
*
|
|
353
|
+
* `meta` is the fourth register and the one most often missing: the small facts
|
|
354
|
+
* that qualify everything below (a count, a scope, a window). They sit on their
|
|
355
|
+
* own line under the title so a reader can tell the difference between "this
|
|
356
|
+
* page has nothing in it" and "this filter matches nothing".
|
|
357
|
+
*
|
|
358
|
+
* `titleId` is exposed so a page can point `aria-labelledby` at the heading
|
|
359
|
+
* from a region further down without minting a second copy of the string.
|
|
360
|
+
*/
|
|
361
|
+
interface PageHeaderProps extends Omit<React$1.HTMLAttributes<HTMLElement>, "title"> {
|
|
362
|
+
title: React$1.ReactNode;
|
|
363
|
+
description?: React$1.ReactNode;
|
|
364
|
+
actions?: React$1.ReactNode;
|
|
365
|
+
meta?: React$1.ReactNode;
|
|
366
|
+
titleId?: string;
|
|
367
|
+
/** Renders the title one step down for a nested/tab surface. */
|
|
368
|
+
level?: 1 | 2;
|
|
369
|
+
}
|
|
370
|
+
declare const PageHeader: React$1.ForwardRefExoticComponent<PageHeaderProps & React$1.RefAttributes<HTMLElement>>;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A run/resource state, told three ways at once.
|
|
374
|
+
*
|
|
375
|
+
* Status is the one place a console cannot afford to speak in colour alone: a
|
|
376
|
+
* red dot and a green dot are the same dot to roughly one man in twelve, and
|
|
377
|
+
* identical in a greyscale print or a screenshot pasted into a ticket. So every
|
|
378
|
+
* pill carries all three channels — a GLYPH whose silhouette differs per tone,
|
|
379
|
+
* the tone's COLOUR, and the state's own LABEL as text.
|
|
380
|
+
*
|
|
381
|
+
* The glyphs are chosen to survive at 8px and to differ in outline rather than
|
|
382
|
+
* in fill: a ring reads as "still open", a solid disc as "settled", a slashed
|
|
383
|
+
* disc as "stopped". Two states never share one silhouette.
|
|
384
|
+
*
|
|
385
|
+
* Each tone draws its fill, border and text from ONE matched token triple.
|
|
386
|
+
* That pairing is the whole point: a status colour is solved against its own
|
|
387
|
+
* background, and nothing guarantees it against an arbitrary one. A component
|
|
388
|
+
* that always brings its own background cannot be placed onto a plane that
|
|
389
|
+
* breaks it.
|
|
390
|
+
*/
|
|
391
|
+
type StatusTone = "success" | "warning" | "danger" | "info" | "neutral" | "running";
|
|
392
|
+
interface StatusPillProps extends Omit<React$1.HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
393
|
+
tone: StatusTone;
|
|
394
|
+
children: React$1.ReactNode;
|
|
395
|
+
/**
|
|
396
|
+
* Drops the fill and border, leaving a toned GLYPH beside a label in the
|
|
397
|
+
* inherited body colour.
|
|
398
|
+
*
|
|
399
|
+
* The label deliberately does not keep the tone. Every status text token
|
|
400
|
+
* clears the 4.5:1 body floor on the page canvas — a gate holds them there —
|
|
401
|
+
* but in light the margin is thin (4.51:1 for warning, 4.54:1 for success),
|
|
402
|
+
* and the canvas is only one of the planes a caller can put a pill on. The
|
|
403
|
+
* glyph keeps the tone instead because a glyph is non-text content against a
|
|
404
|
+
* 3:1 floor, so the tone still reads with room to spare on any plane.
|
|
405
|
+
*
|
|
406
|
+
* For a control that supplies its own surface (a chip, a selected row).
|
|
407
|
+
*/
|
|
408
|
+
bare?: boolean;
|
|
409
|
+
size?: "sm" | "md";
|
|
410
|
+
}
|
|
411
|
+
declare const StatusPill: React$1.ForwardRefExoticComponent<StatusPillProps & React$1.RefAttributes<HTMLSpanElement>>;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The headline figures for a console page, as ONE statement rather than four.
|
|
415
|
+
*
|
|
416
|
+
* A row of separate stat cards is the default and it is usually wrong here.
|
|
417
|
+
* Each card is a box that must be tall enough for its longest member, so a row
|
|
418
|
+
* mixing "$248.55 / Personal wallet" against a bare "0" leaves the short ones
|
|
419
|
+
* mostly empty; and four bordered boxes read as four unrelated facts when they
|
|
420
|
+
* are four readings of one account. `MetricStrip` puts them on one plane
|
|
421
|
+
* divided by hairlines: same information, one object, no holes.
|
|
422
|
+
*
|
|
423
|
+
* The divider is a border on the item rather than a `divide-*` utility on the
|
|
424
|
+
* parent, because these wrap. `divide-x` draws from DOM order and leaves a
|
|
425
|
+
* stray rule at the start of every wrapped line; a per-item leading border
|
|
426
|
+
* suppressed at each row start does not.
|
|
427
|
+
*/
|
|
428
|
+
interface MetricStripProps extends React$1.HTMLAttributes<HTMLDListElement> {
|
|
429
|
+
children: React$1.ReactNode;
|
|
430
|
+
}
|
|
431
|
+
declare const MetricStrip: React$1.ForwardRefExoticComponent<MetricStripProps & React$1.RefAttributes<HTMLDListElement>>;
|
|
432
|
+
interface MetricProps extends React$1.HTMLAttributes<HTMLDivElement> {
|
|
433
|
+
label: React$1.ReactNode;
|
|
434
|
+
value: React$1.ReactNode;
|
|
435
|
+
/** The qualifying line: which wallet, which window, what the limit is. */
|
|
436
|
+
hint?: React$1.ReactNode;
|
|
437
|
+
/** Raises the value's tone and shows a pill beside the label. A metric is
|
|
438
|
+
* `attention` only when a person has to DO something — not merely when a
|
|
439
|
+
* number is zero. */
|
|
440
|
+
attention?: {
|
|
441
|
+
tone: StatusTone;
|
|
442
|
+
label: string;
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* One reading inside a `MetricStrip`.
|
|
447
|
+
*
|
|
448
|
+
* `<dt>` carries the label and `<dd>` the value, so the pair is announced as a
|
|
449
|
+
* described term rather than as two loose strings — the semantic that makes a
|
|
450
|
+
* figure legible without the visual grouping.
|
|
451
|
+
*
|
|
452
|
+
* The value is `tabular-nums`: these sit in a row and change on a timer, and
|
|
453
|
+
* proportional digits make the column jitter every time a 1 becomes an 8.
|
|
454
|
+
*/
|
|
455
|
+
declare const Metric: React$1.ForwardRefExoticComponent<MetricProps & React$1.RefAttributes<HTMLDivElement>>;
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The control row above a table or list: search, filters, then the view switch.
|
|
459
|
+
*
|
|
460
|
+
* It exists because the alternative keeps producing the same two failures. A
|
|
461
|
+
* bare flex row gives every control its natural width, so a search field sits
|
|
462
|
+
* at 1120px on one line and three selects stack full-width beneath it — three
|
|
463
|
+
* enormous empty bars where a compact bar belongs. And each control brought its
|
|
464
|
+
* own visual language, so one row carried a native `<select>`, a custom input
|
|
465
|
+
* and a segmented control side by side.
|
|
466
|
+
*
|
|
467
|
+
* So search and filters SHARE the free space and `actions` stays at its content
|
|
468
|
+
* width, and the row is the one place those controls are composed, which is what
|
|
469
|
+
* keeps them looking like one set.
|
|
470
|
+
*
|
|
471
|
+
* Sharing is deliberate, and the alternative is worse. Giving the filter row its
|
|
472
|
+
* content width starves search: measured with a 537px filter row in an 832px
|
|
473
|
+
* toolbar, the search field collapses to 89px. Letting both flex bounds the
|
|
474
|
+
* filter row instead, so it scrolls within its half — 313px each in that same
|
|
475
|
+
* case — and search stays usable however many filters a page adds. `max-w-sm`
|
|
476
|
+
* then stops search claiming more than it can use when there are few filters.
|
|
477
|
+
*
|
|
478
|
+
* On a narrow viewport the row becomes a column and the filters scroll
|
|
479
|
+
* horizontally as a group rather than wrapping into a tall stack that pushes
|
|
480
|
+
* the table itself below the fold.
|
|
481
|
+
*/
|
|
482
|
+
/**
|
|
483
|
+
* Slots only — `children` is omitted deliberately. A free child would render as
|
|
484
|
+
* a bare flex item with none of the `min-w-0` / `shrink-0` guards the named
|
|
485
|
+
* slots carry, so it would size itself off its content and push the row into
|
|
486
|
+
* the overflow this layout exists to prevent.
|
|
487
|
+
*/
|
|
488
|
+
interface ToolbarProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
489
|
+
/** The one control that expands to fill the row. */
|
|
490
|
+
search?: React$1.ReactNode;
|
|
491
|
+
/** Filters, in reading order. Kept on one scrollable line when space runs out. */
|
|
492
|
+
filters?: React$1.ReactNode;
|
|
493
|
+
/** View switches and exports, pinned to the end. */
|
|
494
|
+
actions?: React$1.ReactNode;
|
|
495
|
+
}
|
|
496
|
+
declare const Toolbar: React$1.ForwardRefExoticComponent<ToolbarProps & React$1.RefAttributes<HTMLDivElement>>;
|
|
497
|
+
/**
|
|
498
|
+
* A labelled filter control. The label is visible, not a placeholder: a select
|
|
499
|
+
* whose current value IS its label ("All products") tells the reader what is
|
|
500
|
+
* selected but never what the control governs, so a row of three reads as three
|
|
501
|
+
* unrelated words.
|
|
502
|
+
*/
|
|
503
|
+
interface FilterFieldProps extends React$1.HTMLAttributes<HTMLDivElement> {
|
|
504
|
+
label: string;
|
|
505
|
+
htmlFor?: string;
|
|
506
|
+
}
|
|
507
|
+
declare const FilterField: React$1.ForwardRefExoticComponent<FilterFieldProps & React$1.RefAttributes<HTMLDivElement>>;
|
|
508
|
+
|
|
509
|
+
export { Avatar, AvatarFallback, AvatarImage, Badge, type BadgeProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, type DropZoneProps, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, type EmptyStateProps, FilterField, type FilterFieldProps, InlineCode, type InlineCodeProps, Input, type InputProps, Label, Metric, type MetricProps, MetricStrip, type MetricStripProps, PageHeader, type PageHeaderProps, Progress, SegmentedControl, type SegmentedControlOption, type SegmentedControlProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, SidebarDropZone, type SidebarDropZoneProps, Skeleton, SkeletonCard, SkeletonTable, StatCard, type StatCardProps, StatusPill, type StatusPillProps, type StatusTone, Switch, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, Textarea, type TextareaProps, ThemeToggle, type Toast, ToastContainer, ToastProvider, Toolbar, type ToolbarProps, type UploadFile, UploadProgress, type UploadProgressProps, badgeVariants, useTheme, useToast };
|
package/dist/primitives.js
CHANGED
|
@@ -11,9 +11,13 @@ import {
|
|
|
11
11
|
DialogTrigger,
|
|
12
12
|
DropZone,
|
|
13
13
|
EmptyState,
|
|
14
|
+
FilterField,
|
|
14
15
|
Input,
|
|
15
16
|
Label,
|
|
16
17
|
Logo,
|
|
18
|
+
Metric,
|
|
19
|
+
MetricStrip,
|
|
20
|
+
PageHeader,
|
|
17
21
|
Progress,
|
|
18
22
|
SegmentedControl,
|
|
19
23
|
Select,
|
|
@@ -31,6 +35,7 @@ import {
|
|
|
31
35
|
SkeletonCard,
|
|
32
36
|
SkeletonTable,
|
|
33
37
|
StatCard,
|
|
38
|
+
StatusPill,
|
|
34
39
|
Switch,
|
|
35
40
|
TangleKnot,
|
|
36
41
|
TerminalCursor,
|
|
@@ -41,10 +46,11 @@ import {
|
|
|
41
46
|
ThemeToggle,
|
|
42
47
|
ToastContainer,
|
|
43
48
|
ToastProvider,
|
|
49
|
+
Toolbar,
|
|
44
50
|
UploadProgress,
|
|
45
51
|
useTheme,
|
|
46
52
|
useToast
|
|
47
|
-
} from "./chunk-
|
|
53
|
+
} from "./chunk-EJ5NBW7V.js";
|
|
48
54
|
import {
|
|
49
55
|
Avatar,
|
|
50
56
|
AvatarFallback,
|
|
@@ -144,10 +150,14 @@ export {
|
|
|
144
150
|
DropdownMenuSubTrigger,
|
|
145
151
|
DropdownMenuTrigger,
|
|
146
152
|
EmptyState,
|
|
153
|
+
FilterField,
|
|
147
154
|
InlineCode,
|
|
148
155
|
Input,
|
|
149
156
|
Label,
|
|
150
157
|
Logo,
|
|
158
|
+
Metric,
|
|
159
|
+
MetricStrip,
|
|
160
|
+
PageHeader,
|
|
151
161
|
Progress,
|
|
152
162
|
SegmentedControl,
|
|
153
163
|
Select,
|
|
@@ -165,6 +175,7 @@ export {
|
|
|
165
175
|
SkeletonCard,
|
|
166
176
|
SkeletonTable,
|
|
167
177
|
StatCard,
|
|
178
|
+
StatusPill,
|
|
168
179
|
Switch,
|
|
169
180
|
Table,
|
|
170
181
|
TableBody,
|
|
@@ -187,6 +198,7 @@ export {
|
|
|
187
198
|
ThemeToggle,
|
|
188
199
|
ToastContainer,
|
|
189
200
|
ToastProvider,
|
|
201
|
+
Toolbar,
|
|
190
202
|
UploadProgress,
|
|
191
203
|
badgeVariants,
|
|
192
204
|
buttonVariants,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Viewer for a server-produced redacted document. Renders text inline and each
|
|
5
|
+
* redacted span as a masked chip; clicking a chip asks the server to reveal that
|
|
6
|
+
* one span. The original plaintext is NEVER in the document the client holds —
|
|
7
|
+
* the chip carries only an id + kind; `onReveal` round-trips to the server, where
|
|
8
|
+
* `@tangle-network/agent-app/redact`'s `revealSpan` runs the authorization check
|
|
9
|
+
* and writes the audit trail. So authz + audit are server-truth; this is display.
|
|
10
|
+
*
|
|
11
|
+
* Structural types (no `@tangle-network/agent-app` dependency) — the viewer needs
|
|
12
|
+
* only `{ id, kind }` per span; the cipher stays server-side.
|
|
13
|
+
*/
|
|
14
|
+
type RedactedDocSegment = {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "redacted";
|
|
19
|
+
id: string;
|
|
20
|
+
kind: string;
|
|
21
|
+
};
|
|
22
|
+
interface RedactedDocumentData {
|
|
23
|
+
segments: RedactedDocSegment[];
|
|
24
|
+
}
|
|
25
|
+
interface RevealResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
value?: string;
|
|
28
|
+
/** e.g. `forbidden` | `not_found` when `ok` is false. */
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
interface RedactedDocumentProps {
|
|
32
|
+
document: RedactedDocumentData;
|
|
33
|
+
/** Reveal one span by id. Wire to a server route that calls agent-app's
|
|
34
|
+
* `revealSpan` (authz + audit happen there). Resolves with the original. */
|
|
35
|
+
onReveal: (spanId: string) => Promise<RevealResult>;
|
|
36
|
+
/** Display label for a redaction kind (default: the kind, upper-cased). */
|
|
37
|
+
labelForKind?: (kind: string) => string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
declare function RedactedDocument({ document, onReveal, labelForKind, className, }: RedactedDocumentProps): ReactNode;
|
|
41
|
+
|
|
42
|
+
export { type RedactedDocSegment, RedactedDocument, type RedactedDocumentData, type RedactedDocumentProps, type RevealResult };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.4.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",
|
|
@@ -101,6 +101,11 @@
|
|
|
101
101
|
"types": "./dist/nav.d.ts",
|
|
102
102
|
"import": "./dist/nav.js",
|
|
103
103
|
"default": "./dist/nav.js"
|
|
104
|
+
},
|
|
105
|
+
"./redaction": {
|
|
106
|
+
"types": "./dist/redaction.d.ts",
|
|
107
|
+
"import": "./dist/redaction.js",
|
|
108
|
+
"default": "./dist/redaction.js"
|
|
104
109
|
}
|
|
105
110
|
},
|
|
106
111
|
"dependencies": {
|
|
@@ -141,7 +146,7 @@
|
|
|
141
146
|
"react-dom": "^18 || ^19",
|
|
142
147
|
"react-router": "^7 || ^8",
|
|
143
148
|
"yjs": "^13.6.0",
|
|
144
|
-
"@tangle-network/brand": "^1.
|
|
149
|
+
"@tangle-network/brand": "^1.5.0"
|
|
145
150
|
},
|
|
146
151
|
"peerDependenciesMeta": {
|
|
147
152
|
"@nanostores/react": {
|
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";
|
|
@@ -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 };
|