@atelic-action/ui 0.10.0 → 0.11.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 +8 -3
- package/package.json +1 -1
- package/src/components/Disclosure.tsx +73 -0
- package/src/components/StatRow.tsx +16 -2
- package/src/components/index.ts +1 -0
- package/src/styles/components.css +108 -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 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)) |
|
|
44
|
+
| `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`, `Disclosure`), 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` |
|
|
@@ -256,8 +256,9 @@ The blocks a page composes, each styled in `styles/components.css` inside the `a
|
|
|
256
256
|
|---|---|---|
|
|
257
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
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 |
|
|
259
|
+
| `StatRow` | A row of large figures, each over what it counts and its `period`. `variant="quiet"` is the glance row for a working page: a light figure in the text color over a small capitalized label, hairlines between; `unit` sets a figure's unit small beside it | `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
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
|
+
| `Disclosure` | A section the page opens and closes: a header row that folds a body the caller controls | For an app. The caller holds `open`, so it can remember what was folded, and a closed body is not rendered. The label is the toggle; `meta` (a count, a flag) sits beside it, outside its name, and a tap on it still folds. `headingLevel` makes the label a real heading |
|
|
261
262
|
| `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
263
|
| `StackedBar` | One bar split into the parts of a whole, with a key | A part of zero is keyed and not drawn |
|
|
263
264
|
| `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 |
|
|
@@ -275,7 +276,7 @@ The blocks a page composes, each styled in `styles/components.css` inside the `a
|
|
|
275
276
|
The page components also work in an app that has its own base styles (Tailwind, shadcn) and wears none of the site chrome. Three things make that so (0.10.0):
|
|
276
277
|
|
|
277
278
|
1. **A second scope.** Every rule in `styles/components.css` answers to `.atelic-ui` as well as `.mkt`. `.atelic-ui` has no resets behind it, so an app puts the class on the region that holds a package component and nothing else in it restyles. Prefer a region to `body`: the components use plain class names (`.stat .num`, `.callout`, `.note`), and an app with markup of its own under those names would have a page wide scope reach it. `body` is right only for an app that has checked it has none, and it is the one way to cover a component rendered in a portal.
|
|
278
|
-
2. **The app imports one sheet**, `@atelic-action/ui/styles/components.css`, and neither `base.css` nor `chrome.css`. An app's own unlayered CSS always wins over the package. With Tailwind
|
|
279
|
+
2. **The app imports one sheet**, `@atelic-action/ui/styles/components.css`, and neither `base.css` nor `chrome.css`. An app's own unlayered CSS always wins over the package. With Tailwind the app **must declare the layer order before any stylesheet loads**, in a `<style>` in the `<head>` of its `index.html`: `@layer properties, theme, base, atelic-ui, components, utilities;`. A layer ranks by where it is first named, and a bundler is free to name the package's first, which ranks it below Tailwind's reset; the reset then strips the padding, margins, and borders off every component, while colors and type still look right, so it is easy to miss. Named there, the package ranks above the reset and below the app's utilities.
|
|
279
280
|
3. **The app maps its tokens** onto the ones the components read, in its own stylesheet on the same scope:
|
|
280
281
|
|
|
281
282
|
| Token | What it colors |
|
|
@@ -300,10 +301,14 @@ The page components also work in an app that has its own base styles (Tailwind,
|
|
|
300
301
|
--neutral-100: hsl(var(--muted));
|
|
301
302
|
--surface-alt: hsl(var(--muted));
|
|
302
303
|
--ui-primary: hsl(var(--brand));
|
|
304
|
+
--primary-bright: hsl(var(--brand));
|
|
305
|
+
--primary-tint: color-mix(in srgb, hsl(var(--brand)) 12%, transparent);
|
|
303
306
|
--ui-card: hsl(var(--card));
|
|
304
307
|
}
|
|
305
308
|
```
|
|
306
309
|
|
|
310
|
+
**Unit tests.** The package ships TSX source, and Vitest compiles it like any other module. A file under `node_modules` has no tsconfig of its own to name a JSX runtime, so a test that renders a package component fails with `React is not defined` until the test config says which to use: `esbuild: { jsx: "automatic" }` at the top level of `vitest.config.ts`. The app build needs nothing, since the React plugin sets it for Vite.
|
|
311
|
+
|
|
307
312
|
`StarRating` needs none of this: its sheet is unscoped and reads no token. The chrome (`SiteHeader`, `Footer`, and the rest) and `NotFound` remain site shaped and are not meant for a signed in app.
|
|
308
313
|
|
|
309
314
|
## The Gallery
|
package/package.json
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useId } from "react";
|
|
3
|
+
|
|
4
|
+
export interface DisclosureProps {
|
|
5
|
+
/** What the section is, and the toggle's accessible name. */
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
open: boolean;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
/**
|
|
10
|
+
* Sits beside the label and outside the toggle's name: a count, a flag, a
|
|
11
|
+
* small chart. A tap on it still folds the section.
|
|
12
|
+
*/
|
|
13
|
+
meta?: ReactNode;
|
|
14
|
+
/** Makes the label a real heading at this level; leave it out for none. */
|
|
15
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
16
|
+
/** The panel's id; minted when left out. */
|
|
17
|
+
panelId?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A section the page opens and closes: a header row that folds a body the
|
|
24
|
+
* caller controls. Where `Collapsible` is a native details element that keeps
|
|
25
|
+
* its own state and its detail in the page, this one is for an app. The
|
|
26
|
+
* caller holds `open`, so it can remember which sections were folded, and a
|
|
27
|
+
* closed body is not rendered, so a long list costs nothing until asked for.
|
|
28
|
+
*
|
|
29
|
+
* The label is the toggle, so its name reads "Produce, collapsed" and not
|
|
30
|
+
* "Produce 10 items"; `meta` is a sibling for that reason. The toggle's
|
|
31
|
+
* `::after` stretches over the whole header row, so a tap anywhere on it,
|
|
32
|
+
* meta and chevron included, folds the section.
|
|
33
|
+
*/
|
|
34
|
+
export function Disclosure({
|
|
35
|
+
label,
|
|
36
|
+
open,
|
|
37
|
+
onToggle,
|
|
38
|
+
meta,
|
|
39
|
+
headingLevel,
|
|
40
|
+
panelId,
|
|
41
|
+
className,
|
|
42
|
+
children,
|
|
43
|
+
}: DisclosureProps) {
|
|
44
|
+
const minted = useId();
|
|
45
|
+
const id = panelId ?? minted;
|
|
46
|
+
const Root = headingLevel ? "section" : "div";
|
|
47
|
+
const Title = headingLevel ? (`h${headingLevel}` as const) : "div";
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Root className={["disclosure", open && "is-open", className].filter(Boolean).join(" ")}>
|
|
51
|
+
<div className="disclosure-head">
|
|
52
|
+
<Title className="disclosure-title">
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className="disclosure-toggle"
|
|
56
|
+
onClick={onToggle}
|
|
57
|
+
aria-expanded={open}
|
|
58
|
+
aria-controls={id}
|
|
59
|
+
>
|
|
60
|
+
{label}
|
|
61
|
+
</button>
|
|
62
|
+
</Title>
|
|
63
|
+
{meta != null && meta !== false && <span className="disclosure-meta">{meta}</span>}
|
|
64
|
+
<ChevronDown className="disclosure-chevron" size={16} aria-hidden="true" />
|
|
65
|
+
</div>
|
|
66
|
+
{open && (
|
|
67
|
+
<div className="disclosure-body" id={id}>
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</Root>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -22,6 +22,8 @@ export interface StatDelta {
|
|
|
22
22
|
export interface StatItem {
|
|
23
23
|
/** The big figure, e.g. "$199", "3,948", "23%"; may embed an inline glyph. */
|
|
24
24
|
value: ReactNode;
|
|
25
|
+
/** The figure's unit, set small after it on the same baseline, e.g. "mi". */
|
|
26
|
+
unit?: ReactNode;
|
|
25
27
|
/** An arrow before the figure, when the figure is itself a change. */
|
|
26
28
|
trend?: StatDirection;
|
|
27
29
|
/** What the figure counts. */
|
|
@@ -38,6 +40,13 @@ export interface StatItem {
|
|
|
38
40
|
|
|
39
41
|
export interface StatRowProps {
|
|
40
42
|
stats: StatItem[];
|
|
43
|
+
/**
|
|
44
|
+
* How loud the row is. `loud`, the default, is a report's headline figures:
|
|
45
|
+
* heavy, large, in the accent. `quiet` is a glance row for a working page:
|
|
46
|
+
* a light figure in the text color over a small capitalized label, the
|
|
47
|
+
* stats divided by a hairline. A tone still colors a quiet figure.
|
|
48
|
+
*/
|
|
49
|
+
variant?: "loud" | "quiet";
|
|
41
50
|
className?: string;
|
|
42
51
|
}
|
|
43
52
|
|
|
@@ -50,9 +59,13 @@ const spoken = { up: "Up", down: "Down", flat: "Unchanged" } as const;
|
|
|
50
59
|
* closes the stat, with the period it covers. The row wraps to as many
|
|
51
60
|
* columns as fit, and arrows are read aloud as up or down.
|
|
52
61
|
*/
|
|
53
|
-
export function StatRow({ stats, className }: StatRowProps) {
|
|
62
|
+
export function StatRow({ stats, variant = "loud", className }: StatRowProps) {
|
|
54
63
|
return (
|
|
55
|
-
<div
|
|
64
|
+
<div
|
|
65
|
+
className={["stat-row", variant === "quiet" && "is-quiet", className]
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join(" ")}
|
|
68
|
+
>
|
|
56
69
|
{stats.map((stat, i) => {
|
|
57
70
|
const Trend = stat.trend ? arrow[stat.trend] : null;
|
|
58
71
|
return (
|
|
@@ -63,6 +76,7 @@ export function StatRow({ stats, className }: StatRowProps) {
|
|
|
63
76
|
<Trend className="stat-trend" role="img" aria-label={spoken[stat.trend]} />
|
|
64
77
|
)}
|
|
65
78
|
{stat.value}
|
|
79
|
+
{stat.unit != null && <span className="stat-unit">{stat.unit}</span>}
|
|
66
80
|
</div>
|
|
67
81
|
{stat.deltas?.map((delta) => {
|
|
68
82
|
const Icon = arrow[delta.direction];
|
package/src/components/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export {
|
|
|
16
16
|
type DataTableValueCell,
|
|
17
17
|
type SortDirection,
|
|
18
18
|
} from "./DataTable";
|
|
19
|
+
export { Disclosure, type DisclosureProps } from "./Disclosure";
|
|
19
20
|
export { NotFound, type NotFoundProps } from "./NotFound";
|
|
20
21
|
export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
|
|
21
22
|
export { describeStarRating, StarRating, type StarRatingProps } from "./StarRating";
|
|
@@ -148,6 +148,114 @@
|
|
|
148
148
|
margin-top: 4px;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
:is(.mkt, .atelic-ui) .stat-unit {
|
|
152
|
+
margin-left: 0.2em;
|
|
153
|
+
font-size: 0.4em;
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
letter-spacing: 0;
|
|
156
|
+
color: var(--neutral-500);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ---- StatRow, quiet: a glance row for a working page. A light figure in
|
|
160
|
+
the text color over a small capitalized label, the stats divided by a
|
|
161
|
+
hairline. `--stat-size` sets the figure. ---- */
|
|
162
|
+
:is(.mkt, .atelic-ui) .stat-row.is-quiet {
|
|
163
|
+
display: flex;
|
|
164
|
+
gap: 0;
|
|
165
|
+
}
|
|
166
|
+
:is(.mkt, .atelic-ui) .stat-row.is-quiet .stat {
|
|
167
|
+
flex: 1;
|
|
168
|
+
min-width: 64px;
|
|
169
|
+
padding-right: 16px;
|
|
170
|
+
}
|
|
171
|
+
:is(.mkt, .atelic-ui) .stat-row.is-quiet .stat + .stat {
|
|
172
|
+
padding-left: 16px;
|
|
173
|
+
border-left: 1px solid var(--neutral-200);
|
|
174
|
+
}
|
|
175
|
+
:is(.mkt, .atelic-ui) .stat-row.is-quiet .stat .num {
|
|
176
|
+
align-items: baseline;
|
|
177
|
+
gap: 4px;
|
|
178
|
+
font: 300 var(--stat-size, 28px) / 1 var(--font-sans);
|
|
179
|
+
/* After the shorthand, which would otherwise reset it. */
|
|
180
|
+
font-variant-numeric: tabular-nums;
|
|
181
|
+
letter-spacing: -0.02em;
|
|
182
|
+
color: var(--tone, var(--ink));
|
|
183
|
+
}
|
|
184
|
+
:is(.mkt, .atelic-ui) .stat-row.is-quiet .stat-unit {
|
|
185
|
+
margin-left: 0;
|
|
186
|
+
font: 500 12px / 1 var(--font-mono);
|
|
187
|
+
}
|
|
188
|
+
:is(.mkt, .atelic-ui) .stat-row.is-quiet .stat .lbl {
|
|
189
|
+
margin-top: 7px;
|
|
190
|
+
font: 500 10px / 1.2 var(--font-sans);
|
|
191
|
+
letter-spacing: 0.1em;
|
|
192
|
+
text-transform: uppercase;
|
|
193
|
+
color: var(--neutral-500);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ---- Disclosure: a section the page opens and closes. The toggle's
|
|
197
|
+
::after covers the whole header row, so the meta and the chevron fold
|
|
198
|
+
it too while staying out of its name. ---- */
|
|
199
|
+
:is(.mkt, .atelic-ui) .disclosure {
|
|
200
|
+
border: 1px solid var(--neutral-200);
|
|
201
|
+
border-radius: 16px;
|
|
202
|
+
background: var(--ui-card, var(--card));
|
|
203
|
+
overflow: hidden;
|
|
204
|
+
}
|
|
205
|
+
:is(.mkt, .atelic-ui) .disclosure-head {
|
|
206
|
+
position: relative;
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
gap: 10px;
|
|
210
|
+
padding: 14px 18px;
|
|
211
|
+
}
|
|
212
|
+
:is(.mkt, .atelic-ui) .disclosure-title {
|
|
213
|
+
display: flex;
|
|
214
|
+
min-width: 0;
|
|
215
|
+
margin: 0;
|
|
216
|
+
font: inherit;
|
|
217
|
+
}
|
|
218
|
+
:is(.mkt, .atelic-ui) .disclosure-toggle {
|
|
219
|
+
border: none;
|
|
220
|
+
background: transparent;
|
|
221
|
+
padding: 0;
|
|
222
|
+
text-align: left;
|
|
223
|
+
font: 500 17px / 1 var(--font-sans);
|
|
224
|
+
letter-spacing: -0.01em;
|
|
225
|
+
color: var(--ink);
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
}
|
|
228
|
+
:is(.mkt, .atelic-ui) .disclosure-toggle::after {
|
|
229
|
+
content: "";
|
|
230
|
+
position: absolute;
|
|
231
|
+
inset: 0;
|
|
232
|
+
}
|
|
233
|
+
:is(.mkt, .atelic-ui) .disclosure-toggle:focus-visible {
|
|
234
|
+
outline: none;
|
|
235
|
+
}
|
|
236
|
+
:is(.mkt, .atelic-ui) .disclosure-toggle:focus-visible::after {
|
|
237
|
+
outline: 2px solid var(--disclosure-focus, var(--ink));
|
|
238
|
+
outline-offset: -2px;
|
|
239
|
+
border-radius: 15px;
|
|
240
|
+
}
|
|
241
|
+
:is(.mkt, .atelic-ui) .disclosure-meta {
|
|
242
|
+
display: inline-flex;
|
|
243
|
+
align-items: center;
|
|
244
|
+
color: var(--neutral-500);
|
|
245
|
+
}
|
|
246
|
+
:is(.mkt, .atelic-ui) .disclosure-chevron {
|
|
247
|
+
margin-left: auto;
|
|
248
|
+
flex-shrink: 0;
|
|
249
|
+
color: var(--neutral-500);
|
|
250
|
+
transition: transform 200ms ease;
|
|
251
|
+
}
|
|
252
|
+
:is(.mkt, .atelic-ui) .disclosure.is-open .disclosure-chevron {
|
|
253
|
+
transform: rotate(180deg);
|
|
254
|
+
}
|
|
255
|
+
:is(.mkt, .atelic-ui) .disclosure-body {
|
|
256
|
+
border-top: 1px solid var(--neutral-200);
|
|
257
|
+
}
|
|
258
|
+
|
|
151
259
|
/* ---- Collapsible: a labeled line that opens to the detail behind it ---- */
|
|
152
260
|
:is(.mkt, .atelic-ui) .collapsible > summary {
|
|
153
261
|
display: flex;
|