@atelic-action/ui 0.7.2 → 0.9.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/README.md +33 -1
- package/package.json +8 -3
- package/src/components/Callout.tsx +60 -0
- package/src/components/Checklist.tsx +43 -0
- package/src/components/Collapsible.tsx +43 -0
- package/src/components/ColumnChart.tsx +97 -0
- package/src/components/DataTable.tsx +194 -0
- package/src/components/StackedBar.tsx +46 -0
- package/src/components/StarRating.tsx +153 -0
- package/src/components/StatRow.tsx +96 -0
- package/src/components/ThresholdScale.tsx +89 -0
- package/src/components/index.ts +34 -0
- package/src/components/tone.ts +7 -0
- package/src/styles/components.css +587 -0
- package/src/styles/star-rating.css +67 -0
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
41
41
|
| Import | Exports |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
|
|
44
|
-
| `@atelic-action/ui/components` | `NotFound` and
|
|
44
|
+
| `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
45
45
|
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
46
46
|
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
47
47
|
| `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
|
|
@@ -50,6 +50,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
50
50
|
| `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
|
|
51
51
|
| `@atelic-action/ui/styles/chrome.css` | Styles for everything under `chrome` |
|
|
52
52
|
| `@atelic-action/ui/styles/components.css` | Layout defaults for everything under `components` |
|
|
53
|
+
| `@atelic-action/ui/styles/star-rating.css` | `StarRating`'s styles alone, for an app that wears none of the site chrome; `components.css` already imports it |
|
|
53
54
|
|
|
54
55
|
Every component renders from props alone. None reads a config file or a router, so a site maps its own config onto the props in its shell:
|
|
55
56
|
|
|
@@ -247,6 +248,37 @@ The plain text alternative part ports as plain functions with no React anywhere
|
|
|
247
248
|
|
|
248
249
|
The palette carries three optional keys beyond the jq's eight: `up` (`#2F7A4B`, a calm green) and `down` (`#B23A2E`, a calm red, kept well apart from the orange accent), the colors a `StatStrip` delta wears by its sign, and `warm` (`#B7791F`, an amber between `up` and the accent) for a record's aside that is cooling but not cold. All three read on the cream card. They are optional so a client palette written before 0.5.0 still compiles, and a palette without them borrows the Atelic values. None maps to a site token.
|
|
249
250
|
|
|
251
|
+
## Page Components
|
|
252
|
+
|
|
253
|
+
The blocks a page composes, each styled in `styles/components.css` inside the `atelic-ui` layer, so a site's own unlayered rule resizes or recolors any of them. They were cut from the SkySpec baseline writeup on 2026-10-02 (ATE-600), which is the worked example of all eight on one page.
|
|
254
|
+
|
|
255
|
+
| Component | What it is | Notes |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| `Checklist` | A short list of checks, each passed or failed | Two columns when there is room, one on a phone. `passLabel` and `failLabel` name the icons for a screen reader |
|
|
258
|
+
| `DataTable` | A table whose first column names each row, sortable by any column marked `sortable` | A click on a heading sorts on a wide screen. On a phone each row stacks, every fact wearing its column's label, and a dropdown sorts. A cell is a node, or `{ display, sort }` when what it shows is not what it sorts by; a formatted number in a string (`"$12,047"`, `"0.24%"`) sorts as a number. It renders in `defaultSort` on the server |
|
|
259
|
+
| `StatRow` | A row of large figures, each over what it counts and its `period` | `trend` draws an arrow before a figure that is itself a change. `deltas` list comparisons beneath: an arrow, how far, against what (`basis`), and the earlier figure (`was`), each in its own tone, since a spend falling and a cost falling read differently |
|
|
260
|
+
| `Callout` | One finding: an icon in its tone beside the title, the explainer under it | `variant="box"` sets it apart on a tinted ground, for something missing or owed |
|
|
261
|
+
| `Collapsible` | A labeled line the reader opens for the detail behind it | A native `details`, so it works before any script loads and find in page opens it. `summary` carries the point, so the detail needs no explaining once open. It prints open |
|
|
262
|
+
| `StackedBar` | One bar split into the parts of a whole, with a key | A part of zero is keyed and not drawn |
|
|
263
|
+
| `ColumnChart` | A bar a period, the periods in groups that carry a label and a total | Months in quarters is the case it was cut for: a quarter reads against the one before it and the same one a year earlier |
|
|
264
|
+
| `ThresholdScale` | Zones on a scale and a pin for each reading | For a number judged against lines, like a load time against good and poor |
|
|
265
|
+
| `StarRating` | A row of stars showing a rating in half star steps, and taking one when given `onChange` | Each star is two hit targets; picking the current rating clears it. Editable, it is one slider to a keyboard and a screen reader; read only, a picture named by the rating. `fillColor` and `outlineColor` take any CSS color, `size` and `max` set the star and the scale |
|
|
266
|
+
|
|
267
|
+
**StarRating outside a site.** It is the one component that does not sit under `.mkt` or read a theme token: its styles are in `styles/star-rating.css`, and its two colors are the `--star-fill` and `--star-outline` custom properties, falling back to the text color and a faint tint of it. An app that installs the package for this component alone imports that one sheet, not `components.css`.
|
|
268
|
+
|
|
269
|
+
**Tones.** `good`, `warn`, `bad`, `info`, and `muted` are the one palette the package carries, because good, a warning, and broken read green, amber, and red whatever the brand is. A component sets `data-tone`, and `--tone`, `--tone-fill`, and `--tone-bg` follow for whatever sits inside. A site's theme may restate `--tone-good` and the rest on `.mkt`; `info` and `muted` come from the theme's own primary and neutral tokens.
|
|
270
|
+
|
|
271
|
+
## The Gallery
|
|
272
|
+
|
|
273
|
+
`gallery/` is one static page, The Components, that shows every page component on its own with made up data for a fictional bakery, wearing the practice's own palette and type. It lives at [ui.atelic.me](https://ui.atelic.me), noindexed and disallowed in its `robots.txt`, since it is for the people building with the package and never search inventory. It imports the components and stylesheets from `src/` by relative path rather than from npm, so it always shows the code in the repo, and it reads the package name and version from `package.json` at build time. It sits outside `src/`, so it is never published.
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
bun run gallery:dev # a dev server on the repo's own source
|
|
277
|
+
bun run gallery:build # the static page, written to gallery/dist
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`vercel.json` builds it the same way and serves `gallery/dist`. Typecheck and lint cover it, and CI builds it, so a component change that breaks the gallery fails CI. **A new page component gets a gallery section in the same change**, with each of its meaningfully different forms shown.
|
|
281
|
+
|
|
250
282
|
## Releasing
|
|
251
283
|
|
|
252
284
|
1. Bump `version` in `package.json` and merge it to `main`: patch for a fix, minor for a new component or prop, major for a breaking prop or class rename.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -28,13 +28,16 @@
|
|
|
28
28
|
"./styles/base.css": "./src/styles/base.css",
|
|
29
29
|
"./styles/chrome.css": "./src/styles/chrome.css",
|
|
30
30
|
"./styles/components.css": "./src/styles/components.css",
|
|
31
|
+
"./styles/star-rating.css": "./src/styles/star-rating.css",
|
|
31
32
|
"./package.json": "./package.json"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"lint": "biome check .",
|
|
35
36
|
"lint:fix": "biome check . --write",
|
|
36
|
-
"typecheck": "tsc --noEmit",
|
|
37
|
-
"test:run": "vitest run"
|
|
37
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p gallery",
|
|
38
|
+
"test:run": "vitest run",
|
|
39
|
+
"gallery:dev": "vite gallery",
|
|
40
|
+
"gallery:build": "vite build gallery"
|
|
38
41
|
},
|
|
39
42
|
"peerDependencies": {
|
|
40
43
|
"lucide-react": "^1.23.0",
|
|
@@ -47,11 +50,13 @@
|
|
|
47
50
|
"@testing-library/react": "^16.0.1",
|
|
48
51
|
"@types/react": "^19.0.0",
|
|
49
52
|
"@types/react-dom": "^19.0.0",
|
|
53
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
50
54
|
"jsdom": "^25.0.1",
|
|
51
55
|
"lucide-react": "^1.23.0",
|
|
52
56
|
"react": "^19.0.0",
|
|
53
57
|
"react-dom": "^19.0.0",
|
|
54
58
|
"typescript": "^5.6.3",
|
|
59
|
+
"vite": "^8.3.2",
|
|
55
60
|
"vitest": "^4.0.0"
|
|
56
61
|
},
|
|
57
62
|
"trustedDependencies": [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CircleAlert, CircleCheck, CircleX, Info } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { Tone } from "./tone";
|
|
4
|
+
|
|
5
|
+
export interface CalloutProps {
|
|
6
|
+
/** How it reads; picks the icon and the color. Defaults to a warning. */
|
|
7
|
+
tone?: Tone;
|
|
8
|
+
/** The point, in a sentence. The icon sits beside it. */
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
/** What the icon means, for a screen reader, e.g. "Needs work". */
|
|
11
|
+
toneLabel?: string;
|
|
12
|
+
/** `box` sets it apart on a tinted ground, for something missing or owed. */
|
|
13
|
+
variant?: "plain" | "box";
|
|
14
|
+
className?: string;
|
|
15
|
+
/** The one explainer under the title. */
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const icons = {
|
|
20
|
+
good: CircleCheck,
|
|
21
|
+
warn: CircleAlert,
|
|
22
|
+
bad: CircleX,
|
|
23
|
+
info: Info,
|
|
24
|
+
muted: Info,
|
|
25
|
+
} as const;
|
|
26
|
+
const labels = {
|
|
27
|
+
good: "Working",
|
|
28
|
+
warn: "Needs work",
|
|
29
|
+
bad: "Broken",
|
|
30
|
+
info: "Note",
|
|
31
|
+
muted: "Note",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One finding: an icon in its tone beside the title, and the explainer
|
|
36
|
+
* under it. Plain, it sits in a list of findings; boxed, it is a callout
|
|
37
|
+
* for something a reader must not miss.
|
|
38
|
+
*/
|
|
39
|
+
export function Callout({
|
|
40
|
+
tone = "warn",
|
|
41
|
+
title,
|
|
42
|
+
toneLabel,
|
|
43
|
+
variant = "plain",
|
|
44
|
+
className,
|
|
45
|
+
children,
|
|
46
|
+
}: CalloutProps) {
|
|
47
|
+
const Icon = icons[tone];
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={["callout", variant === "box" && "is-box", className].filter(Boolean).join(" ")}
|
|
51
|
+
data-tone={tone}
|
|
52
|
+
>
|
|
53
|
+
<Icon className="callout-icon" size={18} role="img" aria-label={toneLabel ?? labels[tone]} />
|
|
54
|
+
<div className="callout-text">
|
|
55
|
+
<div className="callout-title">{title}</div>
|
|
56
|
+
{children && <div className="callout-body">{children}</div>}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CircleCheck, CircleX } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface ChecklistItem {
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
/** One line on what was checked or what failed. */
|
|
7
|
+
detail?: ReactNode;
|
|
8
|
+
pass: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ChecklistProps {
|
|
12
|
+
items: ChecklistItem[];
|
|
13
|
+
/** What a screen reader calls a pass and a fail. */
|
|
14
|
+
passLabel?: string;
|
|
15
|
+
failLabel?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A short list of checks, each passed or failed, in two columns when there is room. */
|
|
20
|
+
export function Checklist({
|
|
21
|
+
items,
|
|
22
|
+
passLabel = "Passes",
|
|
23
|
+
failLabel = "Fails",
|
|
24
|
+
className,
|
|
25
|
+
}: ChecklistProps) {
|
|
26
|
+
return (
|
|
27
|
+
<ul className={["checklist", className].filter(Boolean).join(" ")}>
|
|
28
|
+
{items.map((item, i) => {
|
|
29
|
+
const Icon = item.pass ? CircleCheck : CircleX;
|
|
30
|
+
return (
|
|
31
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: checks are static content in a fixed order
|
|
32
|
+
<li key={i} data-tone={item.pass ? "good" : "bad"}>
|
|
33
|
+
<Icon size={18} role="img" aria-label={item.pass ? passLabel : failLabel} />
|
|
34
|
+
<span>
|
|
35
|
+
<span className="checklist-label">{item.label}</span>
|
|
36
|
+
{item.detail && <span className="checklist-detail">{item.detail}</span>}
|
|
37
|
+
</span>
|
|
38
|
+
</li>
|
|
39
|
+
);
|
|
40
|
+
})}
|
|
41
|
+
</ul>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface CollapsibleProps {
|
|
5
|
+
/** What is inside, e.g. "Your Main Pages". Always visible. */
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* What the detail shows, in a sentence, so a reader who never opens it
|
|
9
|
+
* still has the point and the detail needs no explaining once open.
|
|
10
|
+
*/
|
|
11
|
+
summary?: ReactNode;
|
|
12
|
+
/** Open on first render; closed by default. */
|
|
13
|
+
defaultOpen?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A labeled line the reader opens to see the detail behind it. It is a
|
|
20
|
+
* native details element, so it opens with a click, a tap, Enter, or Space,
|
|
21
|
+
* works before any script loads, and a browser's find in page opens it. It
|
|
22
|
+
* prints open.
|
|
23
|
+
*/
|
|
24
|
+
export function Collapsible({
|
|
25
|
+
label,
|
|
26
|
+
summary,
|
|
27
|
+
defaultOpen,
|
|
28
|
+
className,
|
|
29
|
+
children,
|
|
30
|
+
}: CollapsibleProps) {
|
|
31
|
+
return (
|
|
32
|
+
<details className={["collapsible", className].filter(Boolean).join(" ")} open={defaultOpen}>
|
|
33
|
+
<summary>
|
|
34
|
+
<span className="collapsible-text">
|
|
35
|
+
<span className="collapsible-label">{label}</span>
|
|
36
|
+
{summary && <span className="collapsible-summary">{summary}</span>}
|
|
37
|
+
</span>
|
|
38
|
+
<ChevronDown className="collapsible-chevron" size={18} aria-hidden="true" />
|
|
39
|
+
</summary>
|
|
40
|
+
<div className="collapsible-body">{children}</div>
|
|
41
|
+
</details>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Tone } from "./tone";
|
|
2
|
+
|
|
3
|
+
export interface ColumnChartBar {
|
|
4
|
+
/** Under the bar, e.g. "Jul". */
|
|
5
|
+
label: string;
|
|
6
|
+
value: number;
|
|
7
|
+
/** Over the bar, e.g. "1.7k"; defaults to the value with separators. */
|
|
8
|
+
display?: string;
|
|
9
|
+
tone?: Tone;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ColumnChartGroup {
|
|
13
|
+
/** Under the group, e.g. "2026 Q3". */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** The group's own figure beside its label, e.g. "3,948". */
|
|
16
|
+
total?: string;
|
|
17
|
+
/** Colors every bar in the group that sets no tone of its own. */
|
|
18
|
+
tone?: Tone;
|
|
19
|
+
bars: ColumnChartBar[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ColumnChartProps {
|
|
23
|
+
/** Bars in groups, e.g. months in quarters. One group draws a plain chart. */
|
|
24
|
+
groups: ColumnChartGroup[];
|
|
25
|
+
/** What each tone means, e.g. "The same quarter, a year apart". */
|
|
26
|
+
legend?: Array<{ label: string; tone: Tone }>;
|
|
27
|
+
/** The tallest a bar draws, in pixels. */
|
|
28
|
+
height?: number;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A column chart: one bar a period, the periods in groups that each carry
|
|
34
|
+
* their own label and total, so a reader compares a quarter with the one
|
|
35
|
+
* before it and with the same quarter a year earlier in one picture.
|
|
36
|
+
*/
|
|
37
|
+
export function ColumnChart({ groups, legend, height = 120, className }: ColumnChartProps) {
|
|
38
|
+
const all = groups.flatMap((group) => group.bars);
|
|
39
|
+
const max = Math.max(...all.map((bar) => bar.value), 1);
|
|
40
|
+
const figure = (bar: ColumnChartBar) => bar.display ?? bar.value.toLocaleString("en-US");
|
|
41
|
+
const summary = groups
|
|
42
|
+
.map(
|
|
43
|
+
(group) =>
|
|
44
|
+
`${group.label ? `${group.label}${group.total ? `, ${group.total}` : ""}: ` : ""}${group.bars
|
|
45
|
+
.map((bar) => `${bar.label} ${bar.value.toLocaleString("en-US")}`)
|
|
46
|
+
.join(", ")}`,
|
|
47
|
+
)
|
|
48
|
+
.join("; ");
|
|
49
|
+
return (
|
|
50
|
+
<div className={["column-chart", className].filter(Boolean).join(" ")}>
|
|
51
|
+
<div className="column-chart-plot" role="img" aria-label={summary}>
|
|
52
|
+
{groups.map((group, g) => (
|
|
53
|
+
<div
|
|
54
|
+
className="column-chart-group"
|
|
55
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: groups are static content in time order
|
|
56
|
+
key={g}
|
|
57
|
+
style={{ flexGrow: group.bars.length }}
|
|
58
|
+
>
|
|
59
|
+
<div className="column-chart-bars">
|
|
60
|
+
{group.bars.map((bar, b) => (
|
|
61
|
+
<div
|
|
62
|
+
className="column-chart-col"
|
|
63
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: bars are static content in time order
|
|
64
|
+
key={b}
|
|
65
|
+
data-tone={bar.tone ?? group.tone ?? "muted"}
|
|
66
|
+
>
|
|
67
|
+
<span className="column-chart-value">{figure(bar)}</span>
|
|
68
|
+
<span
|
|
69
|
+
className="column-chart-bar"
|
|
70
|
+
style={{ height: `${Math.round((bar.value / max) * height)}px` }}
|
|
71
|
+
/>
|
|
72
|
+
<span className="column-chart-label">{bar.label}</span>
|
|
73
|
+
</div>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
{(group.label || group.total) && (
|
|
77
|
+
<div className="column-chart-group-label">
|
|
78
|
+
{group.label && <span>{group.label}</span>}
|
|
79
|
+
{group.total && <b>{group.total}</b>}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
{legend && (
|
|
86
|
+
<ul className="chart-key">
|
|
87
|
+
{legend.map((entry) => (
|
|
88
|
+
<li key={entry.label} data-tone={entry.tone}>
|
|
89
|
+
<span className="chart-swatch" aria-hidden="true" />
|
|
90
|
+
<span>{entry.label}</span>
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useId, useMemo, useState } from "react";
|
|
3
|
+
|
|
4
|
+
export type SortDirection = "asc" | "desc";
|
|
5
|
+
|
|
6
|
+
export interface DataTableColumn {
|
|
7
|
+
label: string;
|
|
8
|
+
align?: "left" | "center" | "right";
|
|
9
|
+
/**
|
|
10
|
+
* Lets a reader sort by this column. Needs every cell in it to carry a
|
|
11
|
+
* sort value, or to be a plain string or number.
|
|
12
|
+
*/
|
|
13
|
+
sortable?: boolean;
|
|
14
|
+
/** The direction a first click sorts in; numbers usually read best largest first. */
|
|
15
|
+
firstSort?: SortDirection;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A cell whose display is not what it sorts by, e.g. an icon over a number. */
|
|
19
|
+
export interface DataTableValueCell {
|
|
20
|
+
display: ReactNode;
|
|
21
|
+
sort: number | string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DataTableCell = ReactNode | DataTableValueCell;
|
|
25
|
+
|
|
26
|
+
export interface DataTableRow {
|
|
27
|
+
/** One cell per column; the first names the row. */
|
|
28
|
+
cells: DataTableCell[];
|
|
29
|
+
/** How the row reads, e.g. "bad"; set as data-tone for the site to color. */
|
|
30
|
+
tone?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DataTableProps {
|
|
34
|
+
columns: DataTableColumn[];
|
|
35
|
+
rows: DataTableRow[];
|
|
36
|
+
/** The sort the table opens in. */
|
|
37
|
+
defaultSort?: { column: number; direction: SortDirection };
|
|
38
|
+
/** The label on the phone's sort control. */
|
|
39
|
+
sortLabel?: string;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isValueCell(cell: DataTableCell): cell is DataTableValueCell {
|
|
44
|
+
return typeof cell === "object" && cell !== null && "sort" in cell && "display" in cell;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sortValue(cell: DataTableCell): number | string {
|
|
48
|
+
if (isValueCell(cell)) return cell.sort;
|
|
49
|
+
if (typeof cell === "number") return cell;
|
|
50
|
+
if (typeof cell === "string") {
|
|
51
|
+
const number = Number(cell.replace(/[$,%\s]/g, ""));
|
|
52
|
+
return cell.trim() !== "" && Number.isFinite(number) ? number : cell.toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function compare(a: number | string, b: number | string): number {
|
|
58
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
59
|
+
return String(a).localeCompare(String(b), "en", { numeric: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A table whose first column names each row. A reader sorts it by any
|
|
64
|
+
* sortable column: a click on the heading on a wide screen, a dropdown on a
|
|
65
|
+
* phone, where each row stacks and every fact wears its column's label so
|
|
66
|
+
* nothing scrolls sideways. It renders sorted by `defaultSort` on the server
|
|
67
|
+
* and sorts in place once the page is live.
|
|
68
|
+
*/
|
|
69
|
+
export function DataTable({
|
|
70
|
+
columns,
|
|
71
|
+
rows,
|
|
72
|
+
defaultSort,
|
|
73
|
+
sortLabel = "Sort by",
|
|
74
|
+
className,
|
|
75
|
+
}: DataTableProps) {
|
|
76
|
+
const [sort, setSort] = useState(defaultSort ?? null);
|
|
77
|
+
const selectId = useId();
|
|
78
|
+
const anySortable = columns.some((column) => column.sortable);
|
|
79
|
+
|
|
80
|
+
const sorted = useMemo(() => {
|
|
81
|
+
// Each row keeps its place in the source, which is its key: a sort moves
|
|
82
|
+
// a row's elements rather than rewriting every row in place.
|
|
83
|
+
const indexed = rows.map((row, index) => ({ row, index }));
|
|
84
|
+
if (!sort) return indexed;
|
|
85
|
+
const direction = sort.direction === "asc" ? 1 : -1;
|
|
86
|
+
return indexed.sort((a, b) => {
|
|
87
|
+
const order = compare(
|
|
88
|
+
sortValue(a.row.cells[sort.column]),
|
|
89
|
+
sortValue(b.row.cells[sort.column]),
|
|
90
|
+
);
|
|
91
|
+
return order === 0 ? a.index - b.index : order * direction;
|
|
92
|
+
});
|
|
93
|
+
}, [rows, sort]);
|
|
94
|
+
|
|
95
|
+
const sortBy = (column: number) => {
|
|
96
|
+
setSort((current) => {
|
|
97
|
+
if (current && current.column === column) {
|
|
98
|
+
return { column, direction: current.direction === "asc" ? "desc" : "asc" };
|
|
99
|
+
}
|
|
100
|
+
return { column, direction: columns[column]?.firstSort ?? "asc" };
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className={["data-table", className].filter(Boolean).join(" ")}>
|
|
106
|
+
{anySortable && (
|
|
107
|
+
<div className="data-table-sort">
|
|
108
|
+
<label htmlFor={selectId}>{sortLabel}</label>
|
|
109
|
+
<select
|
|
110
|
+
id={selectId}
|
|
111
|
+
value={sort ? `${sort.column}:${sort.direction}` : ""}
|
|
112
|
+
onChange={(event) => {
|
|
113
|
+
const [column, direction] = event.target.value.split(":");
|
|
114
|
+
if (column === undefined || column === "") return;
|
|
115
|
+
setSort({ column: Number(column), direction: direction as SortDirection });
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{!sort && <option value="">As listed</option>}
|
|
119
|
+
{columns.flatMap((column, index) =>
|
|
120
|
+
column.sortable
|
|
121
|
+
? (["desc", "asc"] as const).map((direction) => (
|
|
122
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: columns are fixed, and the index is the option's value
|
|
123
|
+
<option key={`${index}:${direction}`} value={`${index}:${direction}`}>
|
|
124
|
+
{`${column.label}, ${direction === "desc" ? "high to low" : "low to high"}`}
|
|
125
|
+
</option>
|
|
126
|
+
))
|
|
127
|
+
: [],
|
|
128
|
+
)}
|
|
129
|
+
</select>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
<table>
|
|
133
|
+
<thead>
|
|
134
|
+
<tr>
|
|
135
|
+
{columns.map((column, index) => {
|
|
136
|
+
const active = sort?.column === index;
|
|
137
|
+
const Icon = !active ? ArrowUpDown : sort?.direction === "asc" ? ArrowUp : ArrowDown;
|
|
138
|
+
return (
|
|
139
|
+
<th
|
|
140
|
+
scope="col"
|
|
141
|
+
key={column.label}
|
|
142
|
+
data-align={column.align ?? "left"}
|
|
143
|
+
aria-sort={
|
|
144
|
+
column.sortable
|
|
145
|
+
? active
|
|
146
|
+
? sort?.direction === "asc"
|
|
147
|
+
? "ascending"
|
|
148
|
+
: "descending"
|
|
149
|
+
: "none"
|
|
150
|
+
: undefined
|
|
151
|
+
}
|
|
152
|
+
>
|
|
153
|
+
{column.sortable ? (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => sortBy(index)}
|
|
157
|
+
data-active={active || undefined}
|
|
158
|
+
>
|
|
159
|
+
{column.label}
|
|
160
|
+
<Icon size={12} aria-hidden="true" />
|
|
161
|
+
</button>
|
|
162
|
+
) : (
|
|
163
|
+
column.label
|
|
164
|
+
)}
|
|
165
|
+
</th>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</tr>
|
|
169
|
+
</thead>
|
|
170
|
+
<tbody>
|
|
171
|
+
{sorted.map(({ row, index }) => (
|
|
172
|
+
<tr key={index} data-tone={row.tone}>
|
|
173
|
+
{row.cells.map((cell, c) => {
|
|
174
|
+
const align = columns[c]?.align ?? "left";
|
|
175
|
+
const body = isValueCell(cell) ? cell.display : cell;
|
|
176
|
+
return c === 0 ? (
|
|
177
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: cells sit in column order
|
|
178
|
+
<th scope="row" key={c} data-align={align}>
|
|
179
|
+
{body}
|
|
180
|
+
</th>
|
|
181
|
+
) : (
|
|
182
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: cells sit in column order
|
|
183
|
+
<td key={c} data-align={align} data-label={columns[c]?.label}>
|
|
184
|
+
{body}
|
|
185
|
+
</td>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
188
|
+
</tr>
|
|
189
|
+
))}
|
|
190
|
+
</tbody>
|
|
191
|
+
</table>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Tone } from "./tone";
|
|
2
|
+
|
|
3
|
+
export interface StackedBarSegment {
|
|
4
|
+
label: string;
|
|
5
|
+
value: number;
|
|
6
|
+
/** The figure as printed, e.g. "1,158"; defaults to the value with separators. */
|
|
7
|
+
display?: string;
|
|
8
|
+
tone: Tone;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StackedBarProps {
|
|
12
|
+
segments: StackedBarSegment[];
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One bar split into the parts of a whole, with a key naming each part and its figure. */
|
|
17
|
+
export function StackedBar({ segments, className }: StackedBarProps) {
|
|
18
|
+
const total = segments.reduce((sum, segment) => sum + segment.value, 0) || 1;
|
|
19
|
+
const figure = (segment: StackedBarSegment) =>
|
|
20
|
+
segment.display ?? segment.value.toLocaleString("en-US");
|
|
21
|
+
const summary = segments.map((segment) => `${segment.label}: ${figure(segment)}`).join(", ");
|
|
22
|
+
return (
|
|
23
|
+
<div className={["stacked-bar", className].filter(Boolean).join(" ")}>
|
|
24
|
+
<div className="stacked-bar-track" role="img" aria-label={summary}>
|
|
25
|
+
{segments
|
|
26
|
+
.filter((segment) => segment.value > 0)
|
|
27
|
+
.map((segment) => (
|
|
28
|
+
<span
|
|
29
|
+
key={segment.label}
|
|
30
|
+
data-tone={segment.tone}
|
|
31
|
+
style={{ flexGrow: segment.value / total }}
|
|
32
|
+
/>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
<ul className="chart-key">
|
|
36
|
+
{segments.map((segment) => (
|
|
37
|
+
<li key={segment.label} data-tone={segment.tone}>
|
|
38
|
+
<span className="chart-swatch" aria-hidden="true" />
|
|
39
|
+
<b>{figure(segment)}</b>
|
|
40
|
+
<span>{segment.label}</span>
|
|
41
|
+
</li>
|
|
42
|
+
))}
|
|
43
|
+
</ul>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|