@ultimat3/ui 29.3.2 → 30.0.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 +491 -24
- package/CLAUDE.md +23 -16
- package/README.md +338 -33
- package/package.json +16 -5
- package/src/components/AppShell.module.scss +23 -7
- package/src/components/AppShell.tsx +9 -0
- package/src/components/BarChart.module.scss +26 -0
- package/src/components/BarChart.tsx +242 -93
- package/src/components/Board.module.scss +30 -0
- package/src/components/Board.tsx +47 -0
- package/src/components/BottomNav.module.scss +49 -0
- package/src/components/BottomNav.tsx +57 -0
- package/src/components/Calendar.module.scss +118 -0
- package/src/components/Calendar.tsx +246 -0
- package/src/components/ChartFrame.module.scss +40 -3
- package/src/components/ChartFrame.tsx +84 -28
- package/src/components/CodeBlock.module.scss +101 -0
- package/src/components/CodeBlock.tsx +76 -0
- package/src/components/CommandPalette.module.scss +25 -0
- package/src/components/CommandPalette.tsx +50 -22
- package/src/components/ConfirmDialog.module.scss +36 -0
- package/src/components/ConfirmDialog.tsx +188 -0
- package/src/components/DataTable.tsx +92 -33
- package/src/components/DatePicker.module.scss +37 -0
- package/src/components/DatePicker.tsx +134 -0
- package/src/components/DateRangePicker.module.scss +54 -0
- package/src/components/DateRangePicker.tsx +156 -0
- package/src/components/DescriptionList.module.scss +46 -0
- package/src/components/DescriptionList.tsx +38 -0
- package/src/components/DiffView.module.scss +124 -0
- package/src/components/DiffView.tsx +169 -0
- package/src/components/DonutChart.tsx +13 -4
- package/src/components/Drawer.module.scss +40 -0
- package/src/components/Drawer.tsx +56 -0
- package/src/components/Gauge.module.scss +8 -0
- package/src/components/Gauge.tsx +40 -13
- package/src/components/HeatStrip.module.scss +133 -0
- package/src/components/HeatStrip.tsx +64 -0
- package/src/components/JsonView.module.scss +64 -0
- package/src/components/JsonView.tsx +68 -0
- package/src/components/LineChart.tsx +45 -18
- package/src/components/LiveIndicator.module.scss +44 -0
- package/src/components/LiveIndicator.tsx +45 -0
- package/src/components/LogViewer.module.scss +102 -0
- package/src/components/LogViewer.tsx +123 -0
- package/src/components/Markdown.module.scss +87 -0
- package/src/components/Markdown.tsx +84 -0
- package/src/components/Meter.module.scss +7 -0
- package/src/components/Meter.tsx +16 -0
- package/src/components/NavList.module.scss +66 -0
- package/src/components/NavList.tsx +95 -0
- package/src/components/Pagination.module.scss +6 -2
- package/src/components/Pagination.tsx +57 -13
- package/src/components/Popover.tsx +12 -2
- package/src/components/PullToRefresh.module.scss +41 -0
- package/src/components/PullToRefresh.tsx +108 -0
- package/src/components/Radio.module.scss +51 -0
- package/src/components/Radio.tsx +12 -1
- package/src/components/SearchableSelect.module.scss +216 -0
- package/src/components/SearchableSelect.tsx +331 -0
- package/src/components/SearchableSelectChips.tsx +41 -0
- package/src/components/SearchableSelectNative.tsx +89 -0
- package/src/components/ShortcutSheet.module.scss +33 -0
- package/src/components/ShortcutSheet.tsx +67 -0
- package/src/components/Slider.module.scss +50 -0
- package/src/components/Slider.tsx +68 -0
- package/src/components/SortableList.module.scss +72 -0
- package/src/components/SortableList.tsx +42 -0
- package/src/components/Sparkline.module.scss +10 -2
- package/src/components/Sparkline.tsx +27 -10
- package/src/components/SplitPane.module.scss +39 -0
- package/src/components/SplitPane.tsx +96 -0
- package/src/components/StatTile.module.scss +29 -3
- package/src/components/StatTile.tsx +75 -16
- package/src/components/Stepper.module.scss +78 -0
- package/src/components/Stepper.tsx +73 -0
- package/src/components/Tabs.module.scss +13 -1
- package/src/components/Tabs.tsx +77 -1
- package/src/components/TerminalView.module.scss +68 -0
- package/src/components/TerminalView.tsx +97 -0
- package/src/components/TimeRangePicker.module.scss +85 -0
- package/src/components/TimeRangePicker.tsx +91 -0
- package/src/components/Timeline.module.scss +74 -0
- package/src/components/Timeline.tsx +60 -0
- package/src/components/Tree.module.scss +98 -0
- package/src/components/Tree.tsx +185 -0
- package/src/components/VirtualList.module.scss +60 -0
- package/src/components/VirtualList.tsx +251 -0
- package/src/components/app-shell-view.ts +4 -1
- package/src/components/bar-chart-view.ts +121 -39
- package/src/components/board-view.ts +25 -0
- package/src/components/bottom-nav-view.ts +18 -0
- package/src/components/calendar-view.ts +226 -0
- package/src/components/chart-checks.ts +59 -0
- package/src/components/chart-frame-view.ts +33 -2
- package/src/components/chart-reference-view.ts +41 -0
- package/src/components/command-palette-view.ts +47 -6
- package/src/components/confirm-dialog-view.ts +47 -0
- package/src/components/data-table-rows.ts +93 -0
- package/src/components/date-picker-view.ts +38 -0
- package/src/components/date-range-view.ts +46 -0
- package/src/components/diff-view-view.ts +85 -0
- package/src/components/drawer-gesture-view.ts +33 -0
- package/src/components/gauge-view.ts +29 -0
- package/src/components/heat-strip-view.ts +68 -0
- package/src/components/json-view-view.ts +54 -0
- package/src/components/line-chart-view.ts +87 -25
- package/src/components/log-viewer-view.ts +51 -0
- package/src/components/markdown-nodes.tsx +150 -0
- package/src/components/nav-list-view.ts +37 -0
- package/src/components/pull-refresh-view.ts +93 -0
- package/src/components/rank-options.ts +61 -0
- package/src/components/searchable-select-keys.ts +118 -0
- package/src/components/searchable-select-view.ts +187 -0
- package/src/components/slider-view.ts +47 -0
- package/src/components/sort-pointer.ts +141 -0
- package/src/components/sort-surface.tsx +231 -0
- package/src/components/sparkline-view.ts +74 -14
- package/src/components/split-pane-view.ts +69 -0
- package/src/components/stat-delta.ts +54 -11
- package/src/components/stepper-view.ts +38 -0
- package/src/components/thunk.ts +9 -0
- package/src/components/timeline-view.ts +29 -0
- package/src/components/tree-node.tsx +133 -0
- package/src/components/tree-view.ts +185 -0
- package/src/components/use-command-palette.ts +153 -0
- package/src/components/virtual-list-anchor.ts +35 -0
- package/src/components/virtual-list-observer.ts +58 -0
- package/src/components/virtual-list-view.ts +145 -0
- package/src/content/ansi.ts +144 -0
- package/src/content/heading-slug.ts +32 -0
- package/src/content/highlight.ts +129 -0
- package/src/content/languages/all.ts +21 -0
- package/src/content/languages/diff.ts +14 -0
- package/src/content/languages/html.ts +16 -0
- package/src/content/languages/json.ts +15 -0
- package/src/content/languages/shell.ts +20 -0
- package/src/content/languages/sql.ts +27 -0
- package/src/content/languages/ts.ts +26 -0
- package/src/content/languages/yaml.ts +18 -0
- package/src/content/line-diff.ts +191 -0
- package/src/content/markdown-inline.ts +368 -0
- package/src/content/markdown-parse.ts +267 -0
- package/src/content/markdown-tree.ts +58 -0
- package/src/content/unified-patch.ts +53 -0
- package/src/drag/pointer-drag.ts +185 -0
- package/src/drag/sort-controller.ts +196 -0
- package/src/drag/sort-view.ts +201 -0
- package/src/errors.ts +1 -1
- package/src/form/field-path.ts +7 -33
- package/src/form/form-binding.ts +5 -8
- package/src/form/form-issue.ts +30 -36
- package/src/form/form-values.ts +3 -2
- package/src/hotkeys-view.ts +150 -0
- package/src/i18n-keys.ts +7 -50
- package/src/index.ts +214 -15
- package/src/list-url.ts +123 -0
- package/src/live-window.ts +23 -0
- package/src/roving.ts +2 -0
- package/src/theme/inert-runtime.ts +4 -0
- package/src/theme/runtime-slot.ts +2 -2
- package/src/theme/solid-adapter.ts +11 -5
- package/src/time-range-url.ts +200 -0
- package/src/tokens/_mixins.scss +1 -1
- package/src/tokens/_touch.scss +30 -1
- package/src/tokens/contrast-pairs.ts +12 -0
- package/src/ui-keys.ts +126 -0
- package/src/use-hotkeys.ts +145 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.strip {
|
|
4
|
+
display: grid;
|
|
5
|
+
gap: t.space(2);
|
|
6
|
+
margin: 0;
|
|
7
|
+
min-inline-size: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.caption {
|
|
11
|
+
@include t.label-caps;
|
|
12
|
+
color: t.role('fg-muted');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.hidden {
|
|
16
|
+
@include t.visually-hidden;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Name above the cells on a phone, beside them once there is room: the cells never scroll the page.
|
|
20
|
+
.row {
|
|
21
|
+
display: grid;
|
|
22
|
+
gap: t.space(1);
|
|
23
|
+
min-inline-size: 0;
|
|
24
|
+
|
|
25
|
+
@include t.respond-to(md) {
|
|
26
|
+
grid-template-columns: t.rem(128px) minmax(0, 1fr);
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: t.space(3);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.name {
|
|
33
|
+
@include t.truncate;
|
|
34
|
+
color: t.role('fg');
|
|
35
|
+
font-size: t.text(sm);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The cells share the row's width: many buckets get thinner, never wider than the screen.
|
|
39
|
+
.cells {
|
|
40
|
+
display: flex;
|
|
41
|
+
gap: t.stroke(thick);
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0;
|
|
44
|
+
list-style: none;
|
|
45
|
+
min-inline-size: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.slot {
|
|
49
|
+
flex: 1 1 0;
|
|
50
|
+
min-inline-size: 0;
|
|
51
|
+
max-inline-size: t.space(6);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.cell {
|
|
55
|
+
position: relative;
|
|
56
|
+
display: block;
|
|
57
|
+
aspect-ratio: 1;
|
|
58
|
+
inline-size: 100%;
|
|
59
|
+
|
|
60
|
+
// The mark is a pseudo-element, so its clip-path shape never clips the link's touch area.
|
|
61
|
+
&::before {
|
|
62
|
+
content: '';
|
|
63
|
+
position: absolute;
|
|
64
|
+
inset: 0;
|
|
65
|
+
background: var(--heat, #{t.role('line', 0.5)});
|
|
66
|
+
clip-path: var(--shape, none);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.linked {
|
|
71
|
+
@include t.focus-ring;
|
|
72
|
+
@include t.touch-target(true);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A tone is a colour AND a shape: the shape alone tells the cells apart in greyscale.
|
|
76
|
+
.cell[data-tone='neutral'] {
|
|
77
|
+
--heat: #{t.role('fg-strong')};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@each $tone in (success, info, accent, warning, danger) {
|
|
81
|
+
.cell[data-tone='#{$tone}'] {
|
|
82
|
+
--heat: #{t.role($tone)};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.cell[data-shape='circle'] {
|
|
87
|
+
--shape: circle(50%);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.cell[data-shape='ring'] {
|
|
91
|
+
--shape: polygon(
|
|
92
|
+
evenodd,
|
|
93
|
+
0 0,
|
|
94
|
+
100% 0,
|
|
95
|
+
100% 100%,
|
|
96
|
+
0 100%,
|
|
97
|
+
0 0,
|
|
98
|
+
25% 25%,
|
|
99
|
+
25% 75%,
|
|
100
|
+
75% 75%,
|
|
101
|
+
75% 25%,
|
|
102
|
+
25% 25%
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.cell[data-shape='triangle'] {
|
|
107
|
+
--shape: polygon(50% 0, 100% 100%, 0 100%);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.cell[data-shape='diamond'] {
|
|
111
|
+
--shape: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.cell[data-shape='cross'] {
|
|
115
|
+
--shape: polygon(
|
|
116
|
+
20% 0,
|
|
117
|
+
50% 30%,
|
|
118
|
+
80% 0,
|
|
119
|
+
100% 20%,
|
|
120
|
+
70% 50%,
|
|
121
|
+
100% 80%,
|
|
122
|
+
80% 100%,
|
|
123
|
+
50% 70%,
|
|
124
|
+
20% 100%,
|
|
125
|
+
0 80%,
|
|
126
|
+
30% 50%,
|
|
127
|
+
0 20%
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.cell[data-shape='dash'] {
|
|
132
|
+
--shape: inset(40% 0);
|
|
133
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Rows of status cells — a check per five minutes, a service per row — as HTML, not a chart: each
|
|
2
|
+
// cell is one element named by its whole reading, shaped by its tone, and a link when it has a
|
|
3
|
+
// page. Arrow keys never: a linked cell is a tab stop like any link, an unlinked one is none.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import styles from './HeatStrip.module.scss';
|
|
8
|
+
import type { HeatCellView, HeatRow } from './heat-strip-view';
|
|
9
|
+
import { heatCells } from './heat-strip-view';
|
|
10
|
+
|
|
11
|
+
export interface HeatStripProps {
|
|
12
|
+
/** The strip's name, already translated: its caption. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** One row per thing watched; cells oldest first. */
|
|
15
|
+
rows: readonly HeatRow[];
|
|
16
|
+
/** Show the caption. Default off — it names the figure for assistive tech either way. */
|
|
17
|
+
showCaption?: boolean | undefined;
|
|
18
|
+
class?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function HeatStrip(props: HeatStripProps): JSX.Element {
|
|
22
|
+
return (
|
|
23
|
+
<figure class={cx(styles['strip'], props.class)}>
|
|
24
|
+
<figcaption class={props.showCaption === true ? styles['caption'] : styles['hidden']}>
|
|
25
|
+
{props.label}
|
|
26
|
+
</figcaption>
|
|
27
|
+
{props.rows.map((row) => (
|
|
28
|
+
<div class={styles['row']}>
|
|
29
|
+
<span class={styles['name']}>{row.label}</span>
|
|
30
|
+
<ol class={styles['cells']} aria-label={row.label}>
|
|
31
|
+
{heatCells(row).map((cell) => (
|
|
32
|
+
<li class={styles['slot']}>{cellNode(cell)}</li>
|
|
33
|
+
))}
|
|
34
|
+
</ol>
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
</figure>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cellNode(cell: HeatCellView): JSX.Element {
|
|
42
|
+
return cell.href === undefined ? (
|
|
43
|
+
// One value as a picture: `img` names it and claims nothing more, and is no tab stop.
|
|
44
|
+
<span
|
|
45
|
+
class={styles['cell']}
|
|
46
|
+
role="img"
|
|
47
|
+
aria-label={cell.name}
|
|
48
|
+
title={cell.name}
|
|
49
|
+
data-tone={cell.tone}
|
|
50
|
+
data-shape={cell.shape}
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<a
|
|
54
|
+
class={cx(styles['cell'], styles['linked'])}
|
|
55
|
+
href={cell.href}
|
|
56
|
+
title={cell.name}
|
|
57
|
+
data-tone={cell.tone}
|
|
58
|
+
data-shape={cell.shape}
|
|
59
|
+
>
|
|
60
|
+
{/* The link's name is its content — the whole reading — read and never seen. */}
|
|
61
|
+
<span class={styles['hidden']}>{cell.name}</span>
|
|
62
|
+
</a>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.json {
|
|
4
|
+
padding: t.space(3);
|
|
5
|
+
border-radius: t.radius(md);
|
|
6
|
+
background: t.role('bg-soft');
|
|
7
|
+
font-family: var(--font-mono);
|
|
8
|
+
font-size: t.text(sm);
|
|
9
|
+
color: t.role('fg');
|
|
10
|
+
text-align: start;
|
|
11
|
+
overflow-wrap: anywhere;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.summary {
|
|
15
|
+
@include t.focus-ring;
|
|
16
|
+
cursor: pointer;
|
|
17
|
+
min-block-size: t.touch(target);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.entries {
|
|
21
|
+
margin: 0;
|
|
22
|
+
padding-inline-start: t.space(4);
|
|
23
|
+
list-style: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.key {
|
|
27
|
+
margin-inline-end: t.space(2);
|
|
28
|
+
color: t.role('info');
|
|
29
|
+
|
|
30
|
+
&::after {
|
|
31
|
+
content: ':';
|
|
32
|
+
color: t.role('fg-muted');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.count,
|
|
37
|
+
.more {
|
|
38
|
+
color: t.role('fg-muted');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.value-string {
|
|
42
|
+
color: t.role('success');
|
|
43
|
+
white-space: pre-wrap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.value-number {
|
|
47
|
+
color: t.role('warning');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.value-boolean,
|
|
51
|
+
.value-null {
|
|
52
|
+
color: t.role('accent-strong');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.value-other,
|
|
56
|
+
.value-deep,
|
|
57
|
+
.value-circular {
|
|
58
|
+
color: t.role('fg-muted');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.branch,
|
|
62
|
+
.leaf {
|
|
63
|
+
display: block;
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Any value as nested native `<details>`: keyboard and no-script for free, keys in the order given,
|
|
2
|
+
// long strings wrapped. A webhook body, an event, a tool call — whatever arrived prints as text.
|
|
3
|
+
|
|
4
|
+
import type { JSX } from 'solid-js';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import styles from './JsonView.module.scss';
|
|
7
|
+
import { type JsonViewNode, jsonViewTree } from './json-view-view';
|
|
8
|
+
|
|
9
|
+
export interface JsonViewProps {
|
|
10
|
+
value: unknown;
|
|
11
|
+
/** Already-translated name of the whole value. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Levels open on first paint; deeper containers start closed. Defaults to 1. */
|
|
14
|
+
open?: number | undefined;
|
|
15
|
+
class?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function key(name: string | null): JSX.Element {
|
|
19
|
+
return name === null ? null : <span class={styles['key']}>{name}</span>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function leafText(tree: JsonViewNode): string {
|
|
23
|
+
if (tree.kind === 'deep') return '…';
|
|
24
|
+
if (tree.kind === 'circular') return '↺';
|
|
25
|
+
if (tree.kind === 'string') return JSON.stringify(tree.value);
|
|
26
|
+
return 'value' in tree ? tree.value : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function node(
|
|
30
|
+
tree: JsonViewNode,
|
|
31
|
+
depth: number,
|
|
32
|
+
name: string | null,
|
|
33
|
+
openDepth: number,
|
|
34
|
+
): JSX.Element {
|
|
35
|
+
if (tree.kind === 'object' || tree.kind === 'array') {
|
|
36
|
+
const [left, right] = tree.kind === 'array' ? ['[', ']'] : ['{', '}'];
|
|
37
|
+
return (
|
|
38
|
+
<details class={styles['branch']} open={depth < openDepth}>
|
|
39
|
+
<summary class={styles['summary']}>
|
|
40
|
+
{key(name)}
|
|
41
|
+
<span class={styles['count']}>{`${left}${tree.size}${right}`}</span>
|
|
42
|
+
</summary>
|
|
43
|
+
<ul class={styles['entries']}>
|
|
44
|
+
{tree.entries.map(([child, value]) => (
|
|
45
|
+
<li>{node(value, depth + 1, child, openDepth)}</li>
|
|
46
|
+
))}
|
|
47
|
+
{tree.more > 0 ? <li class={styles['more']}>{`… +${tree.more}`}</li> : null}
|
|
48
|
+
</ul>
|
|
49
|
+
</details>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const text = leafText(tree);
|
|
53
|
+
return (
|
|
54
|
+
<span class={styles['leaf']}>
|
|
55
|
+
{key(name)}
|
|
56
|
+
<span class={styles[`value-${tree.kind}`]}>{text}</span>
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function JsonView(props: JsonViewProps): JSX.Element {
|
|
62
|
+
return (
|
|
63
|
+
// biome-ignore lint/a11y/useSemanticElements: a value read, not a form's fieldset
|
|
64
|
+
<div class={cx(styles['json'], props.class)} role="group" aria-label={props.label} dir="ltr">
|
|
65
|
+
{node(jsonViewTree(props.value), 0, null, props.open ?? 1)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -5,31 +5,43 @@
|
|
|
5
5
|
|
|
6
6
|
import type { JSX } from 'solid-js';
|
|
7
7
|
import { cx } from '../cx';
|
|
8
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
8
9
|
import { useUi } from '../theme/context';
|
|
9
10
|
import type { ChartHit } from './ChartFrame';
|
|
10
|
-
import { ChartFrame,
|
|
11
|
+
import { ChartFrame, ChartHits, ReferenceLabels, ReferenceRules, seriesSwatch } from './ChartFrame';
|
|
12
|
+
import { checkAt, checkSeries } from './chart-checks';
|
|
11
13
|
import type { ChartSeries } from './chart-frame-view';
|
|
12
14
|
import {
|
|
13
15
|
chartNumberFormat,
|
|
14
16
|
chartTable,
|
|
15
17
|
cssPercent,
|
|
16
18
|
hitEdge,
|
|
19
|
+
hitIds,
|
|
17
20
|
markerPath,
|
|
18
21
|
pointLabel,
|
|
19
22
|
seriesStyle,
|
|
20
23
|
} from './chart-frame-view';
|
|
24
|
+
import type { ChartReference } from './chart-reference-view';
|
|
25
|
+
import { referenceLines, referenceValues } from './chart-reference-view';
|
|
21
26
|
import { categoryLabels, minorTicks, tickDecimals } from './chart-ticks-view';
|
|
22
27
|
import styles from './LineChart.module.scss';
|
|
23
28
|
import type { LineLayout } from './line-chart-view';
|
|
24
|
-
import { LINE_CHART, lineLayout, markedPoints } from './line-chart-view';
|
|
29
|
+
import { LINE_CHART, lineLayout, lineY, markedPoints } from './line-chart-view';
|
|
25
30
|
|
|
26
31
|
export interface LineChartProps {
|
|
27
32
|
/** The chart's name, already translated: the figure's caption and the svg's accessible name. */
|
|
28
33
|
label: string;
|
|
29
34
|
/** The x axis, oldest first, already formatted for display — "17 Sep", "14:00". */
|
|
30
35
|
keys: readonly string[];
|
|
31
|
-
/** One entry per line
|
|
36
|
+
/** One entry per line, exactly one value per key; `null` is a gap, never a zero. */
|
|
32
37
|
series: readonly ChartSeries[];
|
|
38
|
+
/**
|
|
39
|
+
* True x positions, one per key, strictly ascending — `row.at.getTime()` for a time axis.
|
|
40
|
+
* Absent, keys are evenly spaced, which is honest only for regular samples.
|
|
41
|
+
*/
|
|
42
|
+
at?: readonly number[] | undefined;
|
|
43
|
+
/** Dashed rules at a value — a target, an SLO. Each widens the axis so it stays on the plot. */
|
|
44
|
+
references?: readonly ChartReference[] | undefined;
|
|
33
45
|
/** The key column's head in the data table, already translated — "Day". */
|
|
34
46
|
keyLabel?: string | undefined;
|
|
35
47
|
/** Formats every value and tick. Default: `Intl.NumberFormat` in the page's locale. */
|
|
@@ -44,44 +56,56 @@ export interface LineChartProps {
|
|
|
44
56
|
class?: string | undefined;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
/**
|
|
48
|
-
export
|
|
59
|
+
/** An area chart is a line chart that fills — and may stack, each series on the ones before. */
|
|
60
|
+
export interface AreaChartProps extends LineChartProps {
|
|
61
|
+
/** Each series' band sits on the series before it; the axis reads the total. Default off. */
|
|
62
|
+
stacked?: boolean | undefined;
|
|
63
|
+
}
|
|
49
64
|
|
|
50
65
|
/** Up to six value labels: at 390px a seventh crowds the ones beside it. */
|
|
51
66
|
const MAX_TICKS = 6;
|
|
52
67
|
const MARKER_RADIUS = 4;
|
|
53
68
|
|
|
54
69
|
export function LineChart(props: LineChartProps): JSX.Element {
|
|
55
|
-
return xyChart(props, false);
|
|
70
|
+
return xyChart(props, false, () => false);
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
export function AreaChart(props: AreaChartProps): JSX.Element {
|
|
59
|
-
return xyChart(props, true);
|
|
74
|
+
return xyChart(props, true, () => props.stacked === true);
|
|
60
75
|
}
|
|
61
76
|
|
|
62
|
-
function xyChart(props: LineChartProps, filled: boolean): JSX.Element {
|
|
77
|
+
function xyChart(props: LineChartProps, filled: boolean, stacked: () => boolean): JSX.Element {
|
|
63
78
|
const ui = useUi();
|
|
64
|
-
const layout = (): LineLayout =>
|
|
65
|
-
|
|
79
|
+
const layout = (): LineLayout => {
|
|
80
|
+
checkSeries('LineChart', props.keys, props.series);
|
|
81
|
+
if (props.at !== undefined) checkAt('LineChart', props.keys, props.at);
|
|
82
|
+
return lineLayout({
|
|
66
83
|
keys: props.keys,
|
|
67
84
|
series: props.series,
|
|
68
85
|
zero: props.zero !== false,
|
|
69
86
|
maxTicks: MAX_TICKS,
|
|
87
|
+
at: props.at,
|
|
88
|
+
also: referenceValues(props.references),
|
|
89
|
+
stacked: stacked(),
|
|
70
90
|
});
|
|
91
|
+
};
|
|
71
92
|
const format = (value: number): string => (props.format ?? chartNumberFormat(ui.locale))(value);
|
|
72
93
|
const tickFormat = (value: number): string =>
|
|
73
94
|
(props.format ?? chartNumberFormat(ui.locale, tickDecimals(layout().scale.step)))(value);
|
|
74
95
|
const { width, height } = LINE_CHART;
|
|
75
96
|
|
|
76
97
|
const hits = (): readonly ChartHit[] =>
|
|
77
|
-
props.series.flatMap((series, s) =>
|
|
78
|
-
(
|
|
98
|
+
props.series.flatMap((series, s) => {
|
|
99
|
+
const ids = hitIds(s, props.keys);
|
|
100
|
+
return (layout().series[s]?.points ?? []).map((point) => ({
|
|
101
|
+
id: ids[point.index] ?? `${s}:${point.index}`,
|
|
79
102
|
x: cssPercent(point.x, width),
|
|
80
103
|
y: cssPercent(point.y, height),
|
|
81
104
|
label: pointLabel(props.keys[point.index] ?? '', series.label, format(point.value)),
|
|
82
105
|
edge: hitEdge(point.x, width),
|
|
83
|
-
}))
|
|
84
|
-
);
|
|
106
|
+
}));
|
|
107
|
+
});
|
|
108
|
+
const lines = () => referenceLines(props.references, (value) => lineY(layout().scale, value));
|
|
85
109
|
|
|
86
110
|
return (
|
|
87
111
|
<ChartFrame
|
|
@@ -93,7 +117,7 @@ function xyChart(props: LineChartProps, filled: boolean): JSX.Element {
|
|
|
93
117
|
? undefined
|
|
94
118
|
: props.series.map((series, s) => ({
|
|
95
119
|
label: series.label,
|
|
96
|
-
swatch: seriesSwatch(seriesStyle(s)),
|
|
120
|
+
swatch: seriesSwatch(seriesStyle(s), series.tone),
|
|
97
121
|
}))
|
|
98
122
|
}
|
|
99
123
|
table={chartTable({
|
|
@@ -102,6 +126,7 @@ function xyChart(props: LineChartProps, filled: boolean): JSX.Element {
|
|
|
102
126
|
keys: props.keys,
|
|
103
127
|
series: props.series,
|
|
104
128
|
format,
|
|
129
|
+
none: ui.t(UI_KEYS.chartNone),
|
|
105
130
|
})}
|
|
106
131
|
>
|
|
107
132
|
<div class={styles['chart']}>
|
|
@@ -127,12 +152,12 @@ function xyChart(props: LineChartProps, filled: boolean): JSX.Element {
|
|
|
127
152
|
{layout().ticks.map((tick) => (
|
|
128
153
|
<line class={styles['grid']} x1={0} x2={width} y1={tick.y} y2={tick.y} />
|
|
129
154
|
))}
|
|
130
|
-
{props.series.map((
|
|
155
|
+
{props.series.map((series, s) => {
|
|
131
156
|
const style = seriesStyle(s);
|
|
132
157
|
const geometry = layout().series[s];
|
|
133
158
|
if (geometry === undefined) return null;
|
|
134
159
|
return (
|
|
135
|
-
<g data-series={style.slot} data-line={String(s)}>
|
|
160
|
+
<g data-series={style.slot} data-tone={series.tone} data-line={String(s)}>
|
|
136
161
|
{filled && geometry.area !== '' ? (
|
|
137
162
|
<path class={styles['area']} d={geometry.area} data-area="true" />
|
|
138
163
|
) : null}
|
|
@@ -147,8 +172,10 @@ function xyChart(props: LineChartProps, filled: boolean): JSX.Element {
|
|
|
147
172
|
</g>
|
|
148
173
|
);
|
|
149
174
|
})}
|
|
175
|
+
<ReferenceRules lines={lines()} width={width} />
|
|
150
176
|
</svg>
|
|
151
|
-
{
|
|
177
|
+
<ReferenceLabels lines={lines()} height={height} format={format} />
|
|
178
|
+
{props.focusable === true ? <ChartHits hits={hits()} /> : null}
|
|
152
179
|
</div>
|
|
153
180
|
<div class={styles['keys']} aria-hidden="true">
|
|
154
181
|
{categoryLabels(props.keys.length).map((label) => (
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.indicator {
|
|
4
|
+
display: inline-flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: t.space(2);
|
|
7
|
+
color: t.role('fg');
|
|
8
|
+
font-size: t.text(sm);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Static on purpose: no animation, so there is nothing to withdraw under reduced motion.
|
|
12
|
+
.dot {
|
|
13
|
+
flex: none;
|
|
14
|
+
inline-size: t.space(3);
|
|
15
|
+
block-size: t.space(3);
|
|
16
|
+
border-radius: t.radius(full);
|
|
17
|
+
background: currentcolor;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.word {
|
|
21
|
+
white-space: nowrap;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Each state a shape as well as a colour: filled, a ring, two bars, a square.
|
|
25
|
+
[data-live='live'] .dot {
|
|
26
|
+
color: t.role('success');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[data-live='reconnecting'] .dot {
|
|
30
|
+
border: t.stroke(thick) solid t.role('warning');
|
|
31
|
+
background: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
[data-live='paused'] .dot {
|
|
35
|
+
border-radius: 0;
|
|
36
|
+
border-inline: t.stroke(thick) solid t.role('fg-muted');
|
|
37
|
+
background: none;
|
|
38
|
+
inline-size: t.space(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
[data-live='offline'] .dot {
|
|
42
|
+
border-radius: t.radius(sm);
|
|
43
|
+
background: t.role('danger');
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Whether a live view is live: a static dot whose SHAPE is the state, with the word beside it —
|
|
2
|
+
// live, reconnecting, paused, offline. Never blinks: a pulse is motion a reader cannot stop and
|
|
3
|
+
// says nothing the word does not. A change of state is announced once; a tick never is.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { announce } from '../a11y';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
9
|
+
import { useUi } from '../theme/context';
|
|
10
|
+
import { solid } from '../theme/solid-adapter';
|
|
11
|
+
import styles from './LiveIndicator.module.scss';
|
|
12
|
+
|
|
13
|
+
export const LIVE_INDICATOR_STATES = ['live', 'reconnecting', 'paused', 'offline'] as const;
|
|
14
|
+
export type LiveIndicatorState = (typeof LIVE_INDICATOR_STATES)[number];
|
|
15
|
+
|
|
16
|
+
const WORDS = {
|
|
17
|
+
live: UI_KEYS.liveLive,
|
|
18
|
+
reconnecting: UI_KEYS.liveReconnecting,
|
|
19
|
+
paused: UI_KEYS.livePaused,
|
|
20
|
+
offline: UI_KEYS.liveOffline,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export interface LiveIndicatorProps {
|
|
24
|
+
state: LiveIndicatorState;
|
|
25
|
+
class?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function LiveIndicator(props: LiveIndicatorProps): JSX.Element {
|
|
29
|
+
const ui = useUi();
|
|
30
|
+
const rt = solid();
|
|
31
|
+
const word = (): string => ui.t(WORDS[props.state]);
|
|
32
|
+
// The first state is what the page rendered with — on screen already, not news.
|
|
33
|
+
let previous: LiveIndicatorState | undefined;
|
|
34
|
+
rt.createEffect(() => {
|
|
35
|
+
const state = props.state;
|
|
36
|
+
if (previous !== undefined && previous !== state) announce(ui.t(WORDS[state]));
|
|
37
|
+
previous = state;
|
|
38
|
+
});
|
|
39
|
+
return (
|
|
40
|
+
<span class={cx(styles['indicator'], props.class)} data-live={props.state}>
|
|
41
|
+
<span class={styles['dot']} aria-hidden="true" />
|
|
42
|
+
<span class={styles['word']}>{word()}</span>
|
|
43
|
+
</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
@use 'sass:map';
|
|
2
|
+
@use '../tokens' as t;
|
|
3
|
+
|
|
4
|
+
$sm: map.get(t.$breakpoints, 'sm');
|
|
5
|
+
|
|
6
|
+
.log {
|
|
7
|
+
@include t.column(t.space(2));
|
|
8
|
+
@include t.container(log);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.bar {
|
|
12
|
+
@include t.row(t.space(2), center);
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Filtering and follow need script; without it every line is in the document.
|
|
17
|
+
.log:not([data-enhanced='true']) .bar {
|
|
18
|
+
display: none;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.filter,
|
|
22
|
+
.levels,
|
|
23
|
+
.follow {
|
|
24
|
+
@include t.focus-ring;
|
|
25
|
+
min-block-size: t.touch(target);
|
|
26
|
+
border: t.stroke(hairline) solid t.role('line');
|
|
27
|
+
border-radius: t.radius(md);
|
|
28
|
+
background: t.role('surface');
|
|
29
|
+
color: t.role('fg');
|
|
30
|
+
font-size: t.text(md);
|
|
31
|
+
padding-inline: t.space(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.filter {
|
|
35
|
+
flex: 1;
|
|
36
|
+
min-inline-size: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.follow[aria-pressed='true'] {
|
|
40
|
+
border-color: t.role('accent');
|
|
41
|
+
color: t.role('accent');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.capped {
|
|
45
|
+
margin: 0;
|
|
46
|
+
color: t.role('fg-muted');
|
|
47
|
+
font-size: t.text(sm);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The caller sizes the page; the log's own box is a bounded share of the viewport, and
|
|
51
|
+
// VirtualList bounds itself inside it.
|
|
52
|
+
.box {
|
|
53
|
+
block-size: min(70vh, t.rem(640px));
|
|
54
|
+
border: t.stroke(hairline) solid t.role('line');
|
|
55
|
+
border-radius: t.radius(md);
|
|
56
|
+
background: t.role('bg-soft');
|
|
57
|
+
font-family: var(--font-mono);
|
|
58
|
+
font-size: t.text(sm);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Timestamp above the line on a phone; one row of columns from `sm` of the log's own width.
|
|
62
|
+
.line {
|
|
63
|
+
display: grid;
|
|
64
|
+
grid-template-columns: auto 1fr;
|
|
65
|
+
gap: 0 t.space(2);
|
|
66
|
+
padding-block: t.space(1);
|
|
67
|
+
padding-inline: t.space(2);
|
|
68
|
+
text-align: start;
|
|
69
|
+
|
|
70
|
+
&[data-level='warn'] .level {
|
|
71
|
+
color: t.role('warning');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
&[data-level='error'] .level {
|
|
75
|
+
color: t.role('danger');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.time {
|
|
80
|
+
grid-column: 1 / -1;
|
|
81
|
+
color: t.role('fg-muted');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.level {
|
|
85
|
+
color: t.role('fg-muted');
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.text {
|
|
90
|
+
white-space: pre-wrap;
|
|
91
|
+
overflow-wrap: anywhere;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@include t.container-query(log, $sm) {
|
|
95
|
+
.line {
|
|
96
|
+
grid-template-columns: auto auto 1fr;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.time {
|
|
100
|
+
grid-column: auto;
|
|
101
|
+
}
|
|
102
|
+
}
|