zz-meridian 0.21.0 → 0.23.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/package.json +1 -1
- package/payload/CHANGELOG.md +16 -0
- package/payload/package.json +1 -1
- package/payload/src/components/patterns/metric-tile/README.md +2 -2
- package/payload/src/components/patterns/metric-tile/index.tsx +6 -3
- package/payload/src/components/patterns/prose/README.md +5 -1
- package/payload/src/components/patterns/prose/index.tsx +49 -4
- package/payload/src/components/patterns/prose/preview.tsx +13 -0
- package/payload/src/components/ui/delta/README.md +3 -3
- package/payload/src/components/ui/delta/index.tsx +18 -4
- package/payload/src/components/ui/delta/preview.tsx +4 -0
- package/payload/tests/metric-tile-delta-format.test.tsx +25 -0
- package/payload/tests/prose-cell-labels.test.tsx +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
|
|
4
4
|
|
|
5
|
+
## [0.23.0] · 2026-10-20
|
|
6
|
+
|
|
7
|
+
### New
|
|
8
|
+
|
|
9
|
+
- **`Prose` names each body cell's column, and stacks a narrow table into labelled cards** ([#75](https://github.com/zhixuan312/zz-meridian/issues/75)). Every body `td` carries its column's heading as `data-label`, written while the markdown is rendered, so it is in the server's HTML and a page can style a narrow table from `td[data-label]` on the first paint, with no second markdown parser. `stack` does it for you: a table under 480px of its own width reads as one card per row, the header row hidden, the first cell the title and each later cell under its column's heading. Wider, it is the same table; without `stack`, nothing changes.
|
|
10
|
+
|
|
11
|
+
## [0.22.0] · 2026-10-20
|
|
12
|
+
|
|
13
|
+
### New
|
|
14
|
+
|
|
15
|
+
- **A tile whose figure is a share reads its change in points** ([#74](https://github.com/zhixuan312/zz-meridian/issues/74)). `MetricTile` takes `deltaFormat` and hands it to `Delta`, whose `format` now takes a name as well as a function: `percent` (the default, a share of the earlier value) or `points`, so a share that went from 33.0% to 46.4% reads "▲13.4 pts" (`delta={0.134}`) with the same arrow, colour and screen-reader word, where it could only read "▲40.6%". `DeltaFormat` and `DELTA_FORMATS` are exported from `@/components/ui/delta`.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **A Delta with no change reads its format** (#74): "0.0 pts" under `points`, or what a function makes of zero, where it printed a literal "0%" whatever the format. With no format given it still reads "0%".
|
|
20
|
+
|
|
5
21
|
## [0.21.0] · 2026-10-20
|
|
6
22
|
|
|
7
23
|
### New
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
|
|
@@ -20,7 +20,7 @@ Card, Delta, Status dot, Tooltip, Sparkline. Tiles sit in a `Row split="tiles"`,
|
|
|
20
20
|
|
|
21
21
|
## Data
|
|
22
22
|
|
|
23
|
-
- `value` is the period's figure; `points` one value a point of the same period, oldest first (days on a page of days), or `null` at a point nothing was measured for, which the Sparkline draws as a gap rather than a fall to zero; `delta` the change as a fraction (0.057 is +5.7%), or `null` when there is no earlier period. `compare` names what the change is measured against (default "vs previous period"; "vs the half hour before" when the window is not the period). `note` is a short line in that place when no comparison fits, such as who is past due, carrying `data-note`; in a narrow tile it takes a second line before it is cut ("296 active · 14 trials · 10 past due" in a third of a page with the assistant's panel open), and a note that is text keeps the whole of itself in its title when the tile cuts it. On a compact tile it stays on the tile's one line.
|
|
23
|
+
- `value` is the period's figure; `points` one value a point of the same period, oldest first (days on a page of days), or `null` at a point nothing was measured for, which the Sparkline draws as a gap rather than a fall to zero; `delta` the change as a fraction (0.057 is +5.7%), or `null` when there is no earlier period; `deltaFormat="points"` reads it in percentage points for a figure that is itself a share (a share from 33.0% to 46.4% is `delta={0.134}`, "13.4 pts"). `compare` names what the change is measured against (default "vs previous period"; "vs the half hour before" when the window is not the period). `note` is a short line in that place when no comparison fits, such as who is past due, carrying `data-note`; in a narrow tile it takes a second line before it is cut ("296 active · 14 trials · 10 past due" in a third of a page with the assistant's panel open), and a note that is text keeps the whole of itself in its title when the tile cuts it. On a compact tile it stays on the tile's one line.
|
|
24
24
|
- `intent` says which way is good: `up` (requests), `down` (errors, latency), `neutral` (compute cost).
|
|
25
25
|
- `value` may be a word for a categorical state ("Likely new", "On track"); it is set at `text-2xl` instead of the figure size and wraps to a second line rather than losing its end ("03 Oct 2026" in a narrow tile), and `format` is not used.
|
|
26
26
|
- `format` turns the number into its text: a name (`count`, `compact`, `cost`, `cost-compact`, `duration`, `percent`), which a server page can pass, or a function from a client component; the default split recognises "$298.43" (any currency symbol from `app.currency`), "2.9M", "0.90%" and "294ms".
|
|
@@ -139,4 +139,4 @@ import { MetricTile } from '@/components/patterns/metric-tile';
|
|
|
139
139
|
<MetricTile size="compact" label="Northwind Labs" value={41_200} format="cost-compact" delta={0.12} compare="vs last week" note="Scale" points={last14Days} />
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
-
Props: `label`, `value`, `format`, `split`, `delta`, `compare`, `intent`, `note`, `points`, `baseline`, `finding`, `hint`, `icon`, `emphasis`, `size`, `className`. A compact tile holds a container, so it takes its cell's width and never sizes itself from what it holds: give it a grid cell or a width.
|
|
142
|
+
Props: `label`, `value`, `format`, `split`, `delta`, `deltaFormat`, `compare`, `intent`, `note`, `points`, `baseline`, `finding`, `hint`, `icon`, `emphasis`, `size`, `className`. A compact tile holds a container, so it takes its cell's width and never sizes itself from what it holds: give it a grid cell or a width.
|
|
@@ -5,7 +5,7 @@ import { cn } from '@/lib/cn';
|
|
|
5
5
|
import { formatDate } from '@/lib/format-date';
|
|
6
6
|
import { fitFigure, formatBy, splitFigure, type NumberFormat } from '@/lib/format';
|
|
7
7
|
import { Card } from '@/components/ui/card';
|
|
8
|
-
import { Delta } from '@/components/ui/delta';
|
|
8
|
+
import { Delta, type DeltaFormat } from '@/components/ui/delta';
|
|
9
9
|
import { Tooltip } from '@/components/ui/tooltip';
|
|
10
10
|
import { Sparkline } from '@/components/charts/sparkline';
|
|
11
11
|
import { useMeridianIndex, usePointMeridian } from '@/components/charts/meridian';
|
|
@@ -47,6 +47,7 @@ export function MetricTile({
|
|
|
47
47
|
hint,
|
|
48
48
|
value,
|
|
49
49
|
delta,
|
|
50
|
+
deltaFormat,
|
|
50
51
|
note,
|
|
51
52
|
compare = 'vs previous period',
|
|
52
53
|
intent = 'up',
|
|
@@ -67,6 +68,8 @@ export function MetricTile({
|
|
|
67
68
|
value: number | string;
|
|
68
69
|
/** Change against the previous period, as a fraction. */
|
|
69
70
|
delta?: number | null;
|
|
71
|
+
/** How the change reads: `percent` (of the earlier value, the default) or `points`, for a figure that is itself a share. */
|
|
72
|
+
deltaFormat?: DeltaFormat | ((n: number) => string);
|
|
70
73
|
/**
|
|
71
74
|
* A short line where the change would sit, for a figure no period comparison fits: who is past due, what is next.
|
|
72
75
|
* On a compact tile it follows the figure on the tile's one line: "$41.2K · next plan".
|
|
@@ -126,7 +129,7 @@ export function MetricTile({
|
|
|
126
129
|
// The figure alone is drawn. Down a wall the words it is against would stand once a tile, so the card that
|
|
127
130
|
// holds the wall says them; here they follow the figure for a screen reader, which meets a tile on its own.
|
|
128
131
|
<p className={cn('flex h-5 items-center justify-self-start', COMPACT_CHANGE)}>
|
|
129
|
-
<Delta value={delta} intent={intent} />
|
|
132
|
+
<Delta value={delta} intent={intent} format={deltaFormat} />
|
|
130
133
|
<span className="sr-only">{' '}{delta === null ? 'No earlier period to compare' : compare}</span>
|
|
131
134
|
</p>
|
|
132
135
|
) : null}
|
|
@@ -185,7 +188,7 @@ export function MetricTile({
|
|
|
185
188
|
<span className="truncate text-ink-3" title="No earlier period to compare">No earlier period to compare</span>
|
|
186
189
|
) : delta !== undefined ? (
|
|
187
190
|
<>
|
|
188
|
-
<Delta value={delta} intent={intent} />
|
|
191
|
+
<Delta value={delta} intent={intent} format={deltaFormat} />
|
|
189
192
|
<span className="leading-5 whitespace-nowrap text-ink-3">{compare}</span>
|
|
190
193
|
</>
|
|
191
194
|
) : note ? (
|
|
@@ -10,7 +10,7 @@ Status: beta
|
|
|
10
10
|
2. **Headings**: the writer's `#` is an h2 (`text-xl`), `##` an h3 with a hairline above it, `###` an h4: the page keeps the only h1.
|
|
11
11
|
3. **Lists**: discs and numbers in `ink-3`; task boxes drawn as checkboxes named "Done" or "Not done".
|
|
12
12
|
4. **Code**: inline on `surface-sunk` with a `line` border; a fenced block on `surface-sunk`, `radius-lg`, wrapping instead of scrolling.
|
|
13
|
-
5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells.
|
|
13
|
+
5. **Tables**: inside a `line`-bordered frame, a `surface-sunk` header row, `text-sm` cells. Every body cell carries its column's heading as `data-label`, in the server's HTML. With `stack`, a table under 480px of its own width reads as one card per row: the header row hidden, the first cell the title in `ink`, each later cell under its heading in `text-xs` `ink-3`.
|
|
14
14
|
6. **Quote**: a 2px `accent` rule on the left, `ink` text.
|
|
15
15
|
7. **Links**: `.link`, in `accent-ink`, turning `ink` on hover, never underlined; a link out of the product sends no referrer.
|
|
16
16
|
|
|
@@ -34,6 +34,7 @@ A markdown string. Line endings are normalised and the ends trimmed; nothing els
|
|
|
34
34
|
| An image from another origin | Its alt text in italics (or "Image not shown"), never fetched |
|
|
35
35
|
| A long token in code or a link: a URL, a path, a hash | Breaks anywhere rather than widen its paragraph or table; plain words wrap between words |
|
|
36
36
|
| A table with more columns than the card holds | Scrolls inside its own frame; the page never scrolls sideways |
|
|
37
|
+
| `stack`, and a table under 480px of its own width | One card per row, each value under its column's heading; no header row |
|
|
37
38
|
| Empty | Nothing: the caller shows the empty state ("No notes yet") |
|
|
38
39
|
|
|
39
40
|
## Surfaces
|
|
@@ -71,4 +72,7 @@ import { Prose } from '@/components/patterns/prose';
|
|
|
71
72
|
|
|
72
73
|
<Card className="p-(--card-pad)"><Prose>{incident.review}</Prose></Card>
|
|
73
74
|
<Prose size="sm">{comment.body}</Prose>
|
|
75
|
+
<Prose stack>{decision.record}</Prose> // tables become labelled cards in a narrow column
|
|
74
76
|
```
|
|
77
|
+
|
|
78
|
+
`stack` is for a document whose tables carry sentences (a decision record, a comparison) read in a narrow column. A table of short figures reads better scrolling in its frame, which is the default. Without `stack`, a page can still style its own narrow layout from `td[data-label]`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo } from 'react';
|
|
1
|
+
import { memo, type ReactNode } from 'react';
|
|
2
2
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
|
3
3
|
import remarkGfm from 'remark-gfm';
|
|
4
4
|
import { cn } from '@/lib/cn';
|
|
@@ -7,6 +7,25 @@ import { safeMarkdownUrl } from '@/lib/safe-markdown';
|
|
|
7
7
|
/** A link that leaves the product sends no referrer: the reader's address is not the author's business. */
|
|
8
8
|
const external = (href?: string) => Boolean(href && /^(https?:)?\/\//i.test(href));
|
|
9
9
|
|
|
10
|
+
type Hast = { type: string; tagName?: string; value?: string; properties?: Record<string, unknown>; children?: Hast[] };
|
|
11
|
+
const text = (n: Hast): string => n.value ?? (n.children ?? []).map(text).join('');
|
|
12
|
+
const elements = (n: Hast, tag: string): Hast[] => (n.children ?? []).flatMap((c) => (c.tagName === tag ? [c] : c.tagName === 'table' ? [] : elements(c, tag)));
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Each body cell carries its column's heading as `data-label`, written while the markdown is turned into HTML, so a
|
|
16
|
+
* table can be read as labelled cards with CSS alone on the server's first paint. A cell past the last heading, or
|
|
17
|
+
* under an empty one, carries none.
|
|
18
|
+
*/
|
|
19
|
+
const labelCells = () => (tree: Hast) => {
|
|
20
|
+
for (const table of elements({ type: 'root', children: [tree] }, 'table')) {
|
|
21
|
+
const headings = elements(table, 'th').map((th) => text(th).trim());
|
|
22
|
+
for (const tr of elements(table, 'tbody').flatMap((b) => elements(b, 'tr')))
|
|
23
|
+
elements(tr, 'td').forEach((td, i) => {
|
|
24
|
+
if (headings[i]) td.properties = { ...td.properties, dataLabel: headings[i] };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
10
29
|
/**
|
|
11
30
|
* Markdown a person wrote (a document, a note, an agent's reply), set for reading on Meridian's roles: a 72-character
|
|
12
31
|
* measure, quiet tables, code on the sunk surface. Each element is drawn here rather than by a typography plugin, so
|
|
@@ -39,7 +58,7 @@ const COMPONENTS: Components = {
|
|
|
39
58
|
thead: ({ children }) => <thead className="border-b border-line bg-surface-sunk">{children}</thead>,
|
|
40
59
|
th: ({ children }) => <th className="px-3 py-2 text-xs font-medium whitespace-nowrap text-ink-3">{children}</th>,
|
|
41
60
|
tr: ({ children }) => <tr className="border-b border-line last:border-0">{children}</tr>,
|
|
42
|
-
td: ({ children }) => <td className="px-3 py-2.5 align-top text-ink-2">{children}</td>,
|
|
61
|
+
td: ({ children, 'data-label': label }: { children?: ReactNode; 'data-label'?: string }) => <td data-label={label} className="px-3 py-2.5 align-top text-ink-2">{children}</td>,
|
|
43
62
|
blockquote: ({ children }) => <blockquote className="my-4 border-l-2 border-accent pl-4 text-ink">{children}</blockquote>,
|
|
44
63
|
hr: () => <hr className="my-8 border-line" />,
|
|
45
64
|
// An image the URL policy dropped reads as its alt text, never as a broken-image icon.
|
|
@@ -52,10 +71,36 @@ const COMPONENTS: Components = {
|
|
|
52
71
|
rest.type === 'checkbox' ? <input {...rest} aria-label={rest.checked ? 'Done' : 'Not done'} className="mr-2 size-3.5 align-[-2px] accent-(--accent)" /> : <input {...rest} />),
|
|
53
72
|
};
|
|
54
73
|
|
|
55
|
-
|
|
74
|
+
/**
|
|
75
|
+
* `stack`: below 480px of its own width a table reads as one card per row: the header row hidden, the first cell the
|
|
76
|
+
* card's title, each later cell on its own line under its column's heading. The frame is the container, so a table in a
|
|
77
|
+
* narrow column stacks wherever the column is narrow, not only on a phone.
|
|
78
|
+
*/
|
|
79
|
+
const STACKED: Components = {
|
|
80
|
+
...COMPONENTS,
|
|
81
|
+
table: ({ children }) => (
|
|
82
|
+
<div className="my-5 overflow-x-auto rounded-lg border border-line @container">
|
|
83
|
+
<table className={cn('w-full text-left text-sm leading-normal',
|
|
84
|
+
'@max-[480px]:block @max-[480px]:[&>thead]:hidden @max-[480px]:[&>tbody]:block @max-[480px]:[&_tr]:block @max-[480px]:[&_tr]:px-3 @max-[480px]:[&_tr]:py-2.5',
|
|
85
|
+
'@max-[480px]:[&_td]:block @max-[480px]:[&_td]:p-0 @max-[480px]:[&_td+td]:mt-2 @max-[480px]:[&_td:first-child]:font-medium @max-[480px]:[&_td:first-child]:text-ink',
|
|
86
|
+
'@max-[480px]:[&_td:not(:first-child)[data-label]]:before:block @max-[480px]:[&_td:not(:first-child)[data-label]]:before:text-xs @max-[480px]:[&_td:not(:first-child)[data-label]]:before:font-medium @max-[480px]:[&_td:not(:first-child)[data-label]]:before:text-ink-3 @max-[480px]:[&_td:not(:first-child)[data-label]]:before:content-[attr(data-label)]')}>
|
|
87
|
+
{children}
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const Prose = memo(function Prose({ children, size = 'base', stack = false, className }: {
|
|
94
|
+
children: string;
|
|
95
|
+
/** `sm` for a side panel, a comment or a quoted body. */
|
|
96
|
+
size?: 'base' | 'sm';
|
|
97
|
+
/** A table narrower than 480px reads as labelled cards, one per row. */
|
|
98
|
+
stack?: boolean;
|
|
99
|
+
className?: string;
|
|
100
|
+
}) {
|
|
56
101
|
return (
|
|
57
102
|
<div className={cn('min-w-0 text-ink-2 break-words', size === 'base' ? 'text-base leading-[1.7]' : 'text-sm leading-relaxed', className)}>
|
|
58
|
-
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={safeMarkdownUrl} components={COMPONENTS}>
|
|
103
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[labelCells]} urlTransform={safeMarkdownUrl} components={stack ? STACKED : COMPONENTS}>
|
|
59
104
|
{children.replace(/\r\n/g, '\n').trim()}
|
|
60
105
|
</ReactMarkdown>
|
|
61
106
|
</div>
|
|
@@ -40,6 +40,11 @@ const HOSTILE = `A reply pasted from somewhere else:
|
|
|
40
40
|
|
|
41
41
|
`;
|
|
42
42
|
|
|
43
|
+
const DECISIONS = `| Choice | Chosen | Rejected because |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| Queue | Postgres \`SKIP LOCKED\` | a broker is one more service to run for 40 jobs a minute |
|
|
46
|
+
| Retries | Three, with backoff | a fourth retry has never succeeded in the last 90 days |`;
|
|
47
|
+
|
|
43
48
|
export default function ProsePreview() {
|
|
44
49
|
return (
|
|
45
50
|
<>
|
|
@@ -58,6 +63,14 @@ export default function ProsePreview() {
|
|
|
58
63
|
<div className="w-80 p-4"><Prose size="sm">{REVIEW.split('## What we are changing')[1]}</Prose></div>
|
|
59
64
|
</Plane>
|
|
60
65
|
</Specimen>
|
|
66
|
+
<Specimen label="Stacked" note="With stack, a table under 480px of its own width reads as one card per row: the first cell its title, each later cell under its column's heading. Wider, it is the same table." stack>
|
|
67
|
+
<Plane on="surface">
|
|
68
|
+
<div className="flex flex-wrap gap-6 p-4">
|
|
69
|
+
<div className="w-80"><Prose size="sm" stack>{DECISIONS}</Prose></div>
|
|
70
|
+
<div className="w-[560px] max-w-full"><Prose size="sm" stack>{DECISIONS}</Prose></div>
|
|
71
|
+
</div>
|
|
72
|
+
</Plane>
|
|
73
|
+
</Specimen>
|
|
61
74
|
</>
|
|
62
75
|
);
|
|
63
76
|
}
|
|
@@ -18,11 +18,11 @@ Status: beta
|
|
|
18
18
|
| Down | Latency, error rate, cost per request | `critical-ink` | `positive-ink` |
|
|
19
19
|
| Neutral | Spend, volume where neither direction is good | `ink-2` | `ink-2` |
|
|
20
20
|
|
|
21
|
-
A change under 0.05% is flat: no arrow, "0
|
|
21
|
+
A change under 0.05% is flat: no arrow, `ink-2`, and the format reading no change ("0.0 pts" under `points`; "0%" with no format given). A missing comparison (`null`) is an em dash in `ink-3`: "we measured no change" and "there is nothing to compare with" are different facts.
|
|
22
22
|
|
|
23
23
|
## Sizes
|
|
24
24
|
|
|
25
|
-
One size, `text-xs`, so it sits on the caption line under a figure. The format defaults to a
|
|
25
|
+
One size, `text-xs`, so it sits on the caption line under a figure. The format defaults to `percent`, the change as a share of the earlier value with one decimal; `points` reads a change in a value that is itself a share in percentage points ("13.4 pts", where `percent` would print a share that went from 33.0% to 46.4% as up 40.6%); pass a function for absolute changes ("43ms").
|
|
26
26
|
|
|
27
27
|
## States
|
|
28
28
|
|
|
@@ -64,4 +64,4 @@ import { Delta } from '@/components/ui/delta';
|
|
|
64
64
|
<Delta value={23.73} format={(n) => `$${n}`} intent="neutral" />
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
Props: `value` (a fraction, or null), `format
|
|
67
|
+
Props: `value` (a fraction, or null), `format` (`percent` | `points` | a function), `intent` (`up` | `down` | `neutral`), `className`.
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
import { cn } from '@/lib/cn';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* How a change reads: `percent` is the change as a share of the earlier value (0.12 reads 12.0%); `points` is a change in
|
|
5
|
+
* a value that is itself a share, in percentage points (0.134 reads 13.4 pts), so a share that went from 33.0% to 46.4%
|
|
6
|
+
* never reads as having risen 40.6%. A name, so a server page can pass it to a client tile (#74).
|
|
7
|
+
*/
|
|
8
|
+
export type DeltaFormat = 'percent' | 'points';
|
|
9
|
+
export const DELTA_FORMATS: Record<DeltaFormat, (n: number) => string> = {
|
|
10
|
+
percent: (n) => `${Math.abs(n * 100).toFixed(1)}%`,
|
|
11
|
+
points: (n) => `${Math.abs(n * 100).toFixed(1)} pts`,
|
|
12
|
+
};
|
|
13
|
+
|
|
3
14
|
/**
|
|
4
15
|
* A change against the previous period. The arrow carries the direction; the colour carries the judgement, which is
|
|
5
16
|
* not the same thing: for latency or errors, down is good. `intent` says which way is good.
|
|
6
17
|
*/
|
|
7
18
|
export function Delta({
|
|
8
19
|
value,
|
|
9
|
-
format
|
|
20
|
+
format,
|
|
10
21
|
intent = 'up',
|
|
11
22
|
className,
|
|
12
23
|
}: {
|
|
13
|
-
/** The change as a fraction (0.12 is +12
|
|
24
|
+
/** The change as a fraction (0.12 is +12%, or +12 points under `points`), or null when there is nothing to compare with. */
|
|
14
25
|
value: number | null;
|
|
15
|
-
|
|
26
|
+
/** How the change reads, a name or a function of its magnitude; `percent` by default. No change reads it too ("0.0 pts"). */
|
|
27
|
+
format?: DeltaFormat | ((n: number) => string);
|
|
16
28
|
/** Which direction is good. `neutral` paints both directions in ink. */
|
|
17
29
|
intent?: 'up' | 'down' | 'neutral';
|
|
18
30
|
className?: string;
|
|
19
31
|
}) {
|
|
20
32
|
if (value === null || !Number.isFinite(value)) return <span className={cn('text-xs text-ink-3', className)}>—</span>;
|
|
21
33
|
const flat = Math.abs(value) < 0.0005;
|
|
34
|
+
const read = typeof format === 'function' ? format : DELTA_FORMATS[format ?? 'percent'];
|
|
22
35
|
const up = value > 0;
|
|
23
36
|
const good = intent === 'neutral' || flat ? null : (up && intent === 'up') || (!up && intent === 'down');
|
|
24
37
|
return (
|
|
@@ -33,7 +46,8 @@ export function Delta({
|
|
|
33
46
|
<path d="M5 1.5 8.5 7h-7z" fill="currentColor" />
|
|
34
47
|
</svg>
|
|
35
48
|
<span className="sr-only">{flat ? 'unchanged' : up ? 'up' : 'down'} </span>
|
|
36
|
-
{
|
|
49
|
+
{/* No change is read as the format reads a change, so a points tile says "0.0 pts", never a literal 0%. */}
|
|
50
|
+
{flat ? (format ? read(0) : '0%') : read(value)}
|
|
37
51
|
</span>
|
|
38
52
|
);
|
|
39
53
|
}
|
|
@@ -19,6 +19,10 @@ export default function DeltaPreview() {
|
|
|
19
19
|
<State label="Flat"><Delta value={0} /></State>
|
|
20
20
|
<State label="Nothing to compare"><Delta value={null} /></State>
|
|
21
21
|
</Specimen>
|
|
22
|
+
<Specimen label="In points" note='format="points": a change in a figure that is itself a share, a refusal share from 33.0% to 46.4%, reads 13.4 points, never 40.6% of itself. No change reads in the same format.'>
|
|
23
|
+
<State label="Rise"><Delta value={0.134} format="points" intent="down" /></State>
|
|
24
|
+
<State label="Flat"><Delta value={0} format="points" intent="down" /></State>
|
|
25
|
+
</Specimen>
|
|
22
26
|
<Specimen label="In context">
|
|
23
27
|
<span className="flex items-center gap-2 text-xs"><Delta value={0.057} /><span className="text-ink-3">vs previous period</span></span>
|
|
24
28
|
</Specimen>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { MetricTile } from '@/components/patterns/metric-tile';
|
|
4
|
+
import { Delta } from '@/components/ui/delta';
|
|
5
|
+
|
|
6
|
+
describe('a tile whose figure is a share (#74)', () => {
|
|
7
|
+
it('reads its change in points under deltaFormat="points", with the same arrow, colour and word', () => {
|
|
8
|
+
const { container } = render(<MetricTile label="Share refused" value={0.464} format="percent" delta={0.134} deltaFormat="points" intent="down" />);
|
|
9
|
+
const change = container.querySelector('.t-num.inline-flex')!;
|
|
10
|
+
expect(change).toHaveTextContent(/^up 13\.4 pts$/);
|
|
11
|
+
expect(change).toHaveClass('text-critical-ink');
|
|
12
|
+
});
|
|
13
|
+
it('still reads a change as a percentage of the earlier value without it', () => {
|
|
14
|
+
const { container } = render(<MetricTile label="Requests" value={1200} delta={0.406} />);
|
|
15
|
+
expect(container.querySelector('.t-num.inline-flex')).toHaveTextContent(/^up 40\.6%$/);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('a Delta with no change', () => {
|
|
20
|
+
it('reads it in its format, never a literal 0%', () => {
|
|
21
|
+
expect(render(<Delta value={0} format="points" />).container).toHaveTextContent(/^unchanged 0\.0 pts$/);
|
|
22
|
+
expect(render(<Delta value={0} format={(n) => `${n.toFixed(2)} ms`} />).container).toHaveTextContent(/^unchanged 0\.00 ms$/);
|
|
23
|
+
expect(render(<Delta value={0} />).container).toHaveTextContent(/^unchanged 0%$/);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { Prose } from '@/components/patterns/prose';
|
|
4
|
+
|
|
5
|
+
const DOC = `| Choice | Chosen | Rejected because |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Store | **Postgres** | the queue needs order |
|
|
8
|
+
| Cache | none | |
|
|
9
|
+
|
|
10
|
+
| A | \`B\` |
|
|
11
|
+
|---|---|
|
|
12
|
+
| 1 | 2 | 3 |`;
|
|
13
|
+
|
|
14
|
+
describe('Prose names each body cell\'s column (#75)', () => {
|
|
15
|
+
test('writes the column heading on every body cell, per table, as plain text', () => {
|
|
16
|
+
const { container } = render(<Prose>{DOC}</Prose>);
|
|
17
|
+
const [first, second] = [...container.querySelectorAll('table')];
|
|
18
|
+
expect([...first.querySelectorAll('tbody td')].map((td) => td.getAttribute('data-label'))).toEqual(['Choice', 'Chosen', 'Rejected because', 'Choice', 'Chosen', 'Rejected because']);
|
|
19
|
+
expect([...second.querySelectorAll('tbody td')].map((td) => td.getAttribute('data-label'))).toEqual(['A', 'B']);
|
|
20
|
+
expect(container.querySelectorAll('th[data-label]')).toHaveLength(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('stack makes each table\'s frame the container its rows stack in', () => {
|
|
24
|
+
const plain = render(<Prose>{DOC}</Prose>);
|
|
25
|
+
expect(plain.container.querySelector('.\\@container')).toBeNull();
|
|
26
|
+
plain.unmount();
|
|
27
|
+
const { container } = render(<Prose stack>{DOC}</Prose>);
|
|
28
|
+
expect(container.querySelectorAll('.\\@container > table')).toHaveLength(2);
|
|
29
|
+
expect(container.querySelector('tbody td')).toHaveAttribute('data-label', 'Choice');
|
|
30
|
+
});
|
|
31
|
+
});
|