@ultimat3/ui 20.1.6 → 20.2.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/CATALOG.md +100 -1
- package/CLAUDE.md +1 -1
- package/README.md +1 -0
- package/package.json +5 -5
- package/src/components/BarChart.module.scss +45 -0
- package/src/components/BarChart.tsx +78 -0
- package/src/components/CommandPalette.module.scss +110 -0
- package/src/components/CommandPalette.tsx +127 -0
- package/src/components/CopyButton.module.scss +40 -0
- package/src/components/CopyButton.tsx +62 -0
- package/src/components/Kbd.module.scss +15 -0
- package/src/components/Kbd.tsx +17 -0
- package/src/components/Meter.module.scss +18 -0
- package/src/components/Meter.tsx +58 -0
- package/src/components/QrCode.module.scss +41 -0
- package/src/components/QrCode.tsx +49 -0
- package/src/components/Sparkline.module.scss +20 -0
- package/src/components/Sparkline.tsx +40 -0
- package/src/components/StatTile.module.scss +86 -0
- package/src/components/StatTile.tsx +49 -0
- package/src/components/bar-chart-view.ts +55 -0
- package/src/components/command-palette-view.ts +61 -0
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-encode.ts +187 -0
- package/src/components/qr-matrix.ts +329 -0
- package/src/components/sparkline-view.ts +36 -0
- package/src/components/stat-delta.ts +30 -0
- package/src/errors.ts +16 -0
- package/src/i18n-keys.ts +4 -0
- package/src/index.ts +32 -0
- package/src/theme/inline-script.ts +5 -0
- package/src/tokens/_mixins.scss +30 -0
- package/src/tokens/reset.scss +7 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.kbd {
|
|
4
|
+
@include t.data-text;
|
|
5
|
+
|
|
6
|
+
padding: t.space(1) t.space(2);
|
|
7
|
+
border: 1px solid t.role('line');
|
|
8
|
+
border-radius: t.radius(sm);
|
|
9
|
+
background: t.role('bg-soft');
|
|
10
|
+
color: t.role('fg-strong');
|
|
11
|
+
font-size: t.text(xs);
|
|
12
|
+
line-height: t.leading(tight);
|
|
13
|
+
box-shadow: t.shadow(sm);
|
|
14
|
+
white-space: nowrap;
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A keyboard key or chord as the user reads it — "⌘K", "Esc", "/". A native `<kbd>`, because a
|
|
2
|
+
// keyboard hint is inline code, not a badge: the element carries the meaning, so it works with no
|
|
3
|
+
// script and reads as a key to a screen reader.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import styles from './Kbd.module.scss';
|
|
8
|
+
|
|
9
|
+
export interface KbdProps {
|
|
10
|
+
/** The key or chord, already translated where a key name is a word. */
|
|
11
|
+
children: JSX.Element;
|
|
12
|
+
class?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Kbd(props: KbdProps): JSX.Element {
|
|
16
|
+
return <kbd class={cx(styles['kbd'], props.class)}>{props.children}</kbd>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// A thin horizontal bar showing one value against a maximum — a table cell's "how busy is this
|
|
2
|
+
// row" beside the number, or a quota. Decorative by default: with no `label` it is hidden from
|
|
3
|
+
// the accessibility tree, because the number beside it is the fact. With one it is a `meter` role
|
|
4
|
+
// with the value spoken.
|
|
5
|
+
//
|
|
6
|
+
// An SVG with a `width` ATTRIBUTE rather than a styled box: it paints identically on the server
|
|
7
|
+
// copy and the hydrated one and asks nothing of the CSP's `style-src-attr`.
|
|
8
|
+
|
|
9
|
+
import type { JSX } from 'solid-js';
|
|
10
|
+
import { cx } from '../cx';
|
|
11
|
+
import styles from './Meter.module.scss';
|
|
12
|
+
import { meterShare, meterWidth } from './meter-view';
|
|
13
|
+
import type { Tone } from './variants';
|
|
14
|
+
|
|
15
|
+
export interface MeterProps {
|
|
16
|
+
value: number;
|
|
17
|
+
max: number;
|
|
18
|
+
/** The accessible name. Omit for a purely decorative bar beside a visible number. */
|
|
19
|
+
label?: string | undefined;
|
|
20
|
+
tone?: Tone | undefined;
|
|
21
|
+
class?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Meter(props: MeterProps): JSX.Element {
|
|
25
|
+
const share = (): number => meterShare(props.value, props.max);
|
|
26
|
+
const classes = (): string =>
|
|
27
|
+
cx(styles['meter'], styles[`tone-${props.tone ?? 'accent'}`], props.class);
|
|
28
|
+
const bars = (): JSX.Element => [
|
|
29
|
+
<rect class={styles['track']} width={100} height={4} rx={2} />,
|
|
30
|
+
<rect class={styles['fill']} width={meterWidth(share())} height={4} rx={2} />,
|
|
31
|
+
];
|
|
32
|
+
// Two literal elements rather than one with conditional ARIA: the `meter` role's value
|
|
33
|
+
// attributes are only valid ON that role, and a lint that reads roles statically has to see it.
|
|
34
|
+
if (props.label === undefined) {
|
|
35
|
+
return (
|
|
36
|
+
<svg class={classes()} viewBox="0 0 100 4" preserveAspectRatio="none" aria-hidden="true">
|
|
37
|
+
{bars()}
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return (
|
|
42
|
+
// A native <meter> paints its fill through a vendor pseudo-element per browser, so it cannot
|
|
43
|
+
// be drawn with tokens or identically everywhere; the role goes on the SVG that draws it.
|
|
44
|
+
// biome-ignore lint/a11y/useSemanticElements: <meter> cannot be styled from tokens
|
|
45
|
+
<svg
|
|
46
|
+
class={classes()}
|
|
47
|
+
viewBox="0 0 100 4"
|
|
48
|
+
preserveAspectRatio="none"
|
|
49
|
+
role="meter"
|
|
50
|
+
aria-label={props.label}
|
|
51
|
+
aria-valuemin={0}
|
|
52
|
+
aria-valuemax={props.max}
|
|
53
|
+
aria-valuenow={props.value}
|
|
54
|
+
>
|
|
55
|
+
{bars()}
|
|
56
|
+
</svg>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.qr {
|
|
4
|
+
display: block;
|
|
5
|
+
inline-size: 100%;
|
|
6
|
+
block-size: auto;
|
|
7
|
+
max-inline-size: 10rem;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// why: this is the ONE component allowed a theme selector. A QR code is a machine-readable
|
|
11
|
+
// symbol, not themed decoration: scanners expect DARK modules on a LIGHT ground, and many refuse
|
|
12
|
+
// the inverse. The tokens are theme-bound and raw colours are refused, so the ROLES swap per
|
|
13
|
+
// theme instead — whichever role is light becomes the ground, whichever is dark the modules — and
|
|
14
|
+
// the swap has to cover BOTH ways a document goes dark: the OS preference with no `data-theme`
|
|
15
|
+
// set (the media path), and an explicit `data-theme='dark'` (the attribute path). A page that
|
|
16
|
+
// followed the theme drew a light-on-dark code no phone would read.
|
|
17
|
+
.ground {
|
|
18
|
+
fill: t.role('surface-raised');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.module {
|
|
22
|
+
fill: t.role('fg-strong');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@media (prefers-color-scheme: dark) {
|
|
26
|
+
html:not([data-theme='light']) .ground {
|
|
27
|
+
fill: t.role('fg-strong');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
html:not([data-theme='light']) .module {
|
|
31
|
+
fill: t.role('bg');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
html[data-theme='dark'] .ground {
|
|
36
|
+
fill: t.role('fg-strong');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
html[data-theme='dark'] .module {
|
|
40
|
+
fill: t.role('bg');
|
|
41
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A QR code of one short value — a link's short URL, a join code — as static SVG from the
|
|
2
|
+
// pure-TypeScript encoder in `qr-encode.ts` / `qr-matrix.ts`. No QR library: a route's JS budget
|
|
3
|
+
// counts raw minified bytes, and one `<rect>` per dark module costs nothing to hydrate, so the
|
|
4
|
+
// server-rendered shell IS the code. Versions 1-3 only (42 bytes); longer is `X_UI_QR_CAPACITY`.
|
|
5
|
+
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import styles from './QrCode.module.scss';
|
|
9
|
+
import { encodeQr, quietZoneOf } from './qr-matrix';
|
|
10
|
+
|
|
11
|
+
export interface QrCodeProps {
|
|
12
|
+
/** What the code encodes — a short URL, at most 42 UTF-8 bytes. Longer throws. */
|
|
13
|
+
value: string;
|
|
14
|
+
/** The accessible name, already translated — say what scanning it does, not "QR code". */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Light modules on every side. Default 4, the minimum a scanner needs to find the finders. */
|
|
17
|
+
quietZone?: number | undefined;
|
|
18
|
+
class?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function QrCode(props: QrCodeProps): JSX.Element {
|
|
22
|
+
const matrix = (): ReturnType<typeof encodeQr> => encodeQr(props.value);
|
|
23
|
+
const quiet = (): number => quietZoneOf(props.quietZone);
|
|
24
|
+
const dimension = (): number => matrix().size + quiet() * 2;
|
|
25
|
+
return (
|
|
26
|
+
<svg
|
|
27
|
+
role="img"
|
|
28
|
+
aria-label={props.label}
|
|
29
|
+
viewBox={`0 0 ${dimension()} ${dimension()}`}
|
|
30
|
+
class={cx(styles['qr'], props.class)}
|
|
31
|
+
shape-rendering="crispEdges"
|
|
32
|
+
>
|
|
33
|
+
<rect class={styles['ground']} x={0} y={0} width={dimension()} height={dimension()} />
|
|
34
|
+
{matrix().modules.flatMap((row, rowIndex) =>
|
|
35
|
+
row.map((dark, colIndex) =>
|
|
36
|
+
dark ? (
|
|
37
|
+
<rect
|
|
38
|
+
class={styles['module']}
|
|
39
|
+
x={quiet() + colIndex}
|
|
40
|
+
y={quiet() + rowIndex}
|
|
41
|
+
width={1}
|
|
42
|
+
height={1}
|
|
43
|
+
/>
|
|
44
|
+
) : null,
|
|
45
|
+
),
|
|
46
|
+
)}
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.sparkline {
|
|
4
|
+
display: block;
|
|
5
|
+
inline-size: 100%;
|
|
6
|
+
block-size: auto;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.line {
|
|
10
|
+
stroke: t.role('accent');
|
|
11
|
+
stroke-width: 2;
|
|
12
|
+
// The box scales with its column; the line must not (it rendered ~7px thick at full width).
|
|
13
|
+
vector-effect: non-scaling-stroke;
|
|
14
|
+
stroke-linejoin: round;
|
|
15
|
+
stroke-linecap: round;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.last {
|
|
19
|
+
fill: t.role('accent');
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// One series as a single line with no axes — the trend beside a figure, in a table cell, on a
|
|
2
|
+
// detail page. Static SVG for `BarChart`'s reason: the framework's own docs use a sparkline
|
|
3
|
+
// pulling in a chart library as the cautionary example, and this file IS that sparkline written
|
|
4
|
+
// the way the docs say to. The path comes from `sparkline-view.ts`.
|
|
5
|
+
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import type { ChartPoint } from './bar-chart-view';
|
|
9
|
+
import styles from './Sparkline.module.scss';
|
|
10
|
+
import { SPARKLINE, sparklinePath, sparkPoints } from './sparkline-view';
|
|
11
|
+
|
|
12
|
+
export interface SparklineProps {
|
|
13
|
+
/** The accessible name for the whole chart, already translated. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Oldest first. Every point comes from exactly this array — the caller zero-fills gaps. */
|
|
16
|
+
points: readonly ChartPoint[];
|
|
17
|
+
class?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Sparkline(props: SparklineProps): JSX.Element {
|
|
21
|
+
const last = (): ChartPoint | undefined => props.points.at(-1);
|
|
22
|
+
const lastPoint = () => sparkPoints(props.points).at(-1);
|
|
23
|
+
return (
|
|
24
|
+
<svg
|
|
25
|
+
role="img"
|
|
26
|
+
aria-label={props.label}
|
|
27
|
+
viewBox={`0 0 ${SPARKLINE.width} ${SPARKLINE.height}`}
|
|
28
|
+
class={cx(styles['sparkline'], props.class)}
|
|
29
|
+
>
|
|
30
|
+
<path class={styles['line']} d={sparklinePath(props.points)} fill="none" />
|
|
31
|
+
{last() === undefined ? null : (
|
|
32
|
+
<circle class={styles['last']} cx={lastPoint()?.x} cy={lastPoint()?.y} r={2}>
|
|
33
|
+
<title>
|
|
34
|
+
{last()?.key}: {last()?.value}
|
|
35
|
+
</title>
|
|
36
|
+
</circle>
|
|
37
|
+
)}
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.tile {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: t.space(2);
|
|
7
|
+
min-inline-size: 0;
|
|
8
|
+
padding: t.space(5);
|
|
9
|
+
border: 1px solid t.role('line', 0.6);
|
|
10
|
+
border-radius: t.radius(lg);
|
|
11
|
+
background: t.role('surface');
|
|
12
|
+
transition: border-color t.duration(fast) t.easing(out);
|
|
13
|
+
|
|
14
|
+
&:hover {
|
|
15
|
+
border-color: t.role('line');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.label {
|
|
20
|
+
@include t.label-caps;
|
|
21
|
+
|
|
22
|
+
margin: 0;
|
|
23
|
+
color: t.role('fg-muted');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.figure {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: baseline;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
gap: t.space(3);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.value {
|
|
34
|
+
@include t.truncate;
|
|
35
|
+
@include t.data-text;
|
|
36
|
+
|
|
37
|
+
margin: 0;
|
|
38
|
+
font-size: t.text('2xl');
|
|
39
|
+
font-weight: t.weight(medium);
|
|
40
|
+
letter-spacing: t.tracking(tight);
|
|
41
|
+
line-height: t.leading(tight);
|
|
42
|
+
color: t.role('fg-strong');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.delta {
|
|
46
|
+
@include t.data-text;
|
|
47
|
+
|
|
48
|
+
display: inline-flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: t.space(1);
|
|
51
|
+
padding: 0 t.space(2);
|
|
52
|
+
border-radius: t.radius(pill);
|
|
53
|
+
font-size: t.text(xs);
|
|
54
|
+
line-height: t.leading(loose);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.arrow {
|
|
58
|
+
inline-size: 0.625rem;
|
|
59
|
+
block-size: 0.625rem;
|
|
60
|
+
fill: none;
|
|
61
|
+
stroke: currentcolor;
|
|
62
|
+
stroke-width: 1.75;
|
|
63
|
+
stroke-linecap: round;
|
|
64
|
+
stroke-linejoin: round;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.trend-up {
|
|
68
|
+
background: t.role('success-soft');
|
|
69
|
+
color: t.role('success');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.trend-down {
|
|
73
|
+
background: t.role('danger-soft');
|
|
74
|
+
color: t.role('danger');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.trend-flat {
|
|
78
|
+
background: t.role('surface-raised');
|
|
79
|
+
color: t.role('fg-muted');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.hint {
|
|
83
|
+
margin: 0;
|
|
84
|
+
color: t.role('fg-muted');
|
|
85
|
+
font-size: t.text(xs);
|
|
86
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// One labelled figure with an optional trend chip and a line of context — the tile a dashboard's
|
|
2
|
+
// top row is made of. The figure arrives formatted: number formatting is the caller's
|
|
3
|
+
// (`Intl.NumberFormat` against the page locale), so the tile never guesses a locale.
|
|
4
|
+
//
|
|
5
|
+
// Intrinsic tags only: a runtime-chosen root (`const Tag = props.as ?? 'div'`) is called as a
|
|
6
|
+
// component by the island build, so a tile built on `Card` hydrated differently from how it was
|
|
7
|
+
// served. A `<div>` renders identically on both sides.
|
|
8
|
+
|
|
9
|
+
import type { JSX } from 'solid-js';
|
|
10
|
+
import { cx } from '../cx';
|
|
11
|
+
import styles from './StatTile.module.scss';
|
|
12
|
+
import type { StatDelta } from './stat-delta';
|
|
13
|
+
import { DELTA_ARROW_PATH } from './stat-delta';
|
|
14
|
+
|
|
15
|
+
export interface StatTileProps {
|
|
16
|
+
/** The tile's caption, already translated — "Total links", "Clicks today". */
|
|
17
|
+
label: string;
|
|
18
|
+
/** The figure, already formatted as the string this tile shows. */
|
|
19
|
+
value: string;
|
|
20
|
+
/** The stable hook a test or a live check reads the figure through: `data-stat`. */
|
|
21
|
+
stat: string;
|
|
22
|
+
/** The trend chip; `deltaOf()` builds one from a current and a baseline. */
|
|
23
|
+
delta?: StatDelta | undefined;
|
|
24
|
+
/** One short line of context under the figure: "vs yesterday", "849 clicks". */
|
|
25
|
+
hint?: string | undefined;
|
|
26
|
+
class?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function StatTile(props: StatTileProps): JSX.Element {
|
|
30
|
+
return (
|
|
31
|
+
<div class={cx(styles['tile'], props.class)}>
|
|
32
|
+
<p class={styles['label']}>{props.label}</p>
|
|
33
|
+
<div class={styles['figure']}>
|
|
34
|
+
<p class={styles['value']} data-stat={props.stat}>
|
|
35
|
+
{props.value}
|
|
36
|
+
</p>
|
|
37
|
+
{props.delta === undefined ? null : (
|
|
38
|
+
<span class={cx(styles['delta'], styles[`trend-${props.delta.trend}`])}>
|
|
39
|
+
<svg viewBox="0 0 10 10" aria-hidden="true" class={styles['arrow']}>
|
|
40
|
+
<path d={DELTA_ARROW_PATH[props.delta.trend]} />
|
|
41
|
+
</svg>
|
|
42
|
+
{props.delta.text}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
{props.hint === undefined ? null : <p class={styles['hint']}>{props.hint}</p>}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The geometry of a BarChart, apart from its markup: where each bar sits, how tall it is, where
|
|
2
|
+
// the grid lines fall. Pure, so the server render and a hydrated one cannot draw two charts.
|
|
3
|
+
|
|
4
|
+
export interface ChartPoint {
|
|
5
|
+
/** The bar's name — a day, a bucket, a label. Read by `<title>` and the axis. */
|
|
6
|
+
readonly key: string;
|
|
7
|
+
readonly value: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BarRect {
|
|
11
|
+
readonly x: number;
|
|
12
|
+
readonly y: number;
|
|
13
|
+
readonly width: number;
|
|
14
|
+
readonly height: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 600 × 128 at a 5:1-ish ratio, not 3:1: at dashboard width a taller box pushed the table — the
|
|
19
|
+
* thing people came for — below the fold. `plot` is where bars live; the strip under it belongs
|
|
20
|
+
* to the axis labels, which drawn ON the first and last bars were unreadable there.
|
|
21
|
+
*/
|
|
22
|
+
export const BAR_CHART = { width: 600, top: 12, plot: 104, height: 128, gap: 3 } as const;
|
|
23
|
+
|
|
24
|
+
/** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
|
|
25
|
+
export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
|
|
26
|
+
|
|
27
|
+
/** The busiest point, and never below 1, so an all-zero series still divides. */
|
|
28
|
+
export function maxOf(points: readonly ChartPoint[]): number {
|
|
29
|
+
let max = 1;
|
|
30
|
+
for (const point of points)
|
|
31
|
+
if (Number.isFinite(point.value) && point.value > max) max = point.value;
|
|
32
|
+
return max;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The y of a grid line at `step` of the plot height (1 = the top line = the busiest point). */
|
|
36
|
+
export function gridY(step: number): number {
|
|
37
|
+
return BAR_CHART.top + BAR_CHART.plot - step * BAR_CHART.plot;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One rect per point, oldest first. At least 2px tall so a zero still draws a visible bar. */
|
|
41
|
+
export function barRects(points: readonly ChartPoint[]): readonly BarRect[] {
|
|
42
|
+
const count = Math.max(points.length, 1);
|
|
43
|
+
const width = (BAR_CHART.width - BAR_CHART.gap * (count - 1)) / count;
|
|
44
|
+
const max = maxOf(points);
|
|
45
|
+
return points.map((point, index) => {
|
|
46
|
+
const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
|
|
47
|
+
const height = Math.max(2, (value / max) * BAR_CHART.plot);
|
|
48
|
+
return {
|
|
49
|
+
x: index * (width + BAR_CHART.gap),
|
|
50
|
+
y: BAR_CHART.top + BAR_CHART.plot - height,
|
|
51
|
+
width,
|
|
52
|
+
height,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The rules of a command palette, apart from its markup: which items a query keeps, where the
|
|
2
|
+
// roving selection goes on an arrow key, and what a key means. Pure, so the caller's island and
|
|
3
|
+
// a page test decide the same thing — and so the component owns no rule a caller could get wrong.
|
|
4
|
+
|
|
5
|
+
export interface CommandPaletteItem {
|
|
6
|
+
/** Stable across a re-filter — what roving selection and `onRunItem` both key off. */
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly label: string;
|
|
9
|
+
/** Secondary text — a target URL, a slug, a key chord. Empty renders nothing. */
|
|
10
|
+
readonly hint: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Case-insensitive substring over label and hint; an empty query keeps everything. */
|
|
14
|
+
export function filterItems(
|
|
15
|
+
items: readonly CommandPaletteItem[],
|
|
16
|
+
query: string,
|
|
17
|
+
): readonly CommandPaletteItem[] {
|
|
18
|
+
const needle = query.trim().toLowerCase();
|
|
19
|
+
if (needle === '') return items;
|
|
20
|
+
return items.filter(
|
|
21
|
+
(item) => item.label.toLowerCase().includes(needle) || item.hint.toLowerCase().includes(needle),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The active id after a step, wrapping at both ends; the first item when nothing was active. */
|
|
26
|
+
export function stepActive(
|
|
27
|
+
items: readonly CommandPaletteItem[],
|
|
28
|
+
activeId: string | undefined,
|
|
29
|
+
delta: 1 | -1,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
if (items.length === 0) return undefined;
|
|
32
|
+
const index = items.findIndex((item) => item.id === activeId);
|
|
33
|
+
const next = index === -1 ? 0 : (index + delta + items.length) % items.length;
|
|
34
|
+
return items[next]?.id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The active id a narrowed list should keep: the same one if it survived, else the first. */
|
|
38
|
+
export function settleActive(
|
|
39
|
+
items: readonly CommandPaletteItem[],
|
|
40
|
+
activeId: string | undefined,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
return items.some((item) => item.id === activeId) ? activeId : items[0]?.id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type PaletteKeyAction = 'next' | 'previous' | 'run' | 'close';
|
|
46
|
+
|
|
47
|
+
/** What a key pressed in the filter means, or `null` for a key the palette does not own. */
|
|
48
|
+
export function keyAction(key: string): PaletteKeyAction | null {
|
|
49
|
+
switch (key) {
|
|
50
|
+
case 'ArrowDown':
|
|
51
|
+
return 'next';
|
|
52
|
+
case 'ArrowUp':
|
|
53
|
+
return 'previous';
|
|
54
|
+
case 'Enter':
|
|
55
|
+
return 'run';
|
|
56
|
+
case 'Escape':
|
|
57
|
+
return 'close';
|
|
58
|
+
default:
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The arithmetic of a Meter, apart from its markup: a share of a maximum, clamped, and the width
|
|
2
|
+
// attribute that share becomes. Pure so a server render and a hydrated one cannot disagree.
|
|
3
|
+
|
|
4
|
+
/** `value / max` clamped to 0..1; a non-positive or non-finite `max` is an empty meter. */
|
|
5
|
+
export function meterShare(value: number, max: number): number {
|
|
6
|
+
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) return 0;
|
|
7
|
+
return Math.min(1, Math.max(0, value / max));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The fill's `width` on a 100-unit viewBox, to one decimal — stable across renders. */
|
|
11
|
+
export function meterWidth(share: number): string {
|
|
12
|
+
return (share * 100).toFixed(1);
|
|
13
|
+
}
|