@ultimat3/ui 29.4.0 → 30.1.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,38 @@
|
|
|
1
|
+
// Terms and their details: `<dl>` with one `<div>` per group, so a pair stays together when the
|
|
2
|
+
// list stacks. A `Table` of two columns is the wrong landmark for facts about one thing.
|
|
3
|
+
// Server-only: no signal, no effect, 0 B of script on a static route.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import { cx } from '../cx';
|
|
7
|
+
import styles from './DescriptionList.module.scss';
|
|
8
|
+
|
|
9
|
+
export interface DescriptionItem {
|
|
10
|
+
/** Already-translated term. */
|
|
11
|
+
term: string;
|
|
12
|
+
detail: JSX.Element;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DescriptionListProps {
|
|
16
|
+
items: readonly DescriptionItem[];
|
|
17
|
+
/**
|
|
18
|
+
* `columns` puts each term beside its detail and stacks them when the list's OWN width falls
|
|
19
|
+
* below `sm` (a container query, never the viewport). `stacked` always stacks.
|
|
20
|
+
*/
|
|
21
|
+
layout?: 'columns' | 'stacked' | undefined;
|
|
22
|
+
class?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function DescriptionList(props: DescriptionListProps): JSX.Element {
|
|
26
|
+
return (
|
|
27
|
+
<div class={cx(styles['wrap'], props.class)}>
|
|
28
|
+
<dl class={cx(styles['list'], styles[`layout-${props.layout ?? 'columns'}`])}>
|
|
29
|
+
{props.items.map((item) => (
|
|
30
|
+
<div class={styles['group']}>
|
|
31
|
+
<dt class={styles['term']}>{item.term}</dt>
|
|
32
|
+
<dd class={styles['detail']}>{item.detail}</dd>
|
|
33
|
+
</div>
|
|
34
|
+
))}
|
|
35
|
+
</dl>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
@use 'sass:map';
|
|
2
|
+
@use '../tokens' as t;
|
|
3
|
+
|
|
4
|
+
$md: map.get(t.$breakpoints, 'md');
|
|
5
|
+
|
|
6
|
+
// Unified below `md` of the CONTAINER, side by side at and above it. Hue is never alone: the
|
|
7
|
+
// `+`/`-` gutter and a spoken word carry the same fact, so the diff reads with colour off.
|
|
8
|
+
.diff {
|
|
9
|
+
@include t.container(diff);
|
|
10
|
+
border: t.stroke(hairline) solid t.role('line');
|
|
11
|
+
border-radius: t.radius(md);
|
|
12
|
+
background: t.role('bg-soft');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.bar {
|
|
16
|
+
@include t.row(t.space(1), center, flex-end);
|
|
17
|
+
padding-block: t.space(1);
|
|
18
|
+
padding-inline: t.space(2);
|
|
19
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.diff:not([data-enhanced='true']) .bar {
|
|
23
|
+
display: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.next {
|
|
27
|
+
@include t.focus-ring;
|
|
28
|
+
border: 0;
|
|
29
|
+
background: transparent;
|
|
30
|
+
color: t.role('accent');
|
|
31
|
+
font-size: t.text(sm);
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
min-block-size: t.touch(target);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.scroll {
|
|
37
|
+
@include t.focus-ring;
|
|
38
|
+
overflow-x: auto;
|
|
39
|
+
text-align: start;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.unified,
|
|
43
|
+
.split {
|
|
44
|
+
inline-size: 100%;
|
|
45
|
+
border-collapse: collapse;
|
|
46
|
+
font-family: var(--font-mono);
|
|
47
|
+
font-size: t.text(sm);
|
|
48
|
+
color: t.role('fg');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.split {
|
|
52
|
+
display: none;
|
|
53
|
+
table-layout: fixed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@include t.container-query(diff, $md) {
|
|
57
|
+
.unified {
|
|
58
|
+
display: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.split {
|
|
62
|
+
display: table;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.row td {
|
|
67
|
+
vertical-align: top;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.num {
|
|
71
|
+
inline-size: t.space(10);
|
|
72
|
+
padding-inline: t.space(2);
|
|
73
|
+
color: t.role('fg-muted');
|
|
74
|
+
text-align: end;
|
|
75
|
+
user-select: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.sign {
|
|
79
|
+
inline-size: t.space(4);
|
|
80
|
+
text-align: center;
|
|
81
|
+
user-select: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.text {
|
|
85
|
+
padding-inline-end: t.space(2);
|
|
86
|
+
white-space: pre-wrap;
|
|
87
|
+
overflow-wrap: anywhere;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.add {
|
|
91
|
+
background: t.role('success-soft');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.del {
|
|
95
|
+
background: t.role('danger-soft');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.context {
|
|
99
|
+
background: transparent;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A changed word: weight and an underline as well as the tint, so it survives colour off.
|
|
103
|
+
.word {
|
|
104
|
+
background: transparent;
|
|
105
|
+
color: inherit;
|
|
106
|
+
font-weight: t.weight(bold);
|
|
107
|
+
text-decoration: underline;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.spoken {
|
|
111
|
+
@include t.visually-hidden;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.hunk,
|
|
115
|
+
.file,
|
|
116
|
+
.fold {
|
|
117
|
+
background: t.role('surface');
|
|
118
|
+
color: t.role('fg-muted');
|
|
119
|
+
|
|
120
|
+
td {
|
|
121
|
+
padding-block: t.space(1);
|
|
122
|
+
padding-inline: t.space(2);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// A diff a person can read with colour off: `+` and `-` gutters, a spoken word per changed row,
|
|
2
|
+
// word marks inside a changed pair, line numbers on each side. Unified below `md`, side by side
|
|
3
|
+
// from it, by `@container`. Two texts or a unified patch — one parser to rows.
|
|
4
|
+
|
|
5
|
+
import type { JSX } from 'solid-js';
|
|
6
|
+
import type { DiffRow } from '../content/line-diff';
|
|
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 './DiffView.module.scss';
|
|
12
|
+
import {
|
|
13
|
+
diffViewRows,
|
|
14
|
+
markedSegments,
|
|
15
|
+
type SidePair,
|
|
16
|
+
sidePairs,
|
|
17
|
+
startsChange,
|
|
18
|
+
} from './diff-view-view';
|
|
19
|
+
|
|
20
|
+
interface DiffViewBase {
|
|
21
|
+
/** Already-translated name of the scroll region; defaults to `ui.diff.region`. */
|
|
22
|
+
label?: string | undefined;
|
|
23
|
+
/** Rows past this fold into one line saying how many. Defaults to 2,000. */
|
|
24
|
+
maxLines?: number | undefined;
|
|
25
|
+
class?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DiffViewTextProps extends DiffViewBase {
|
|
29
|
+
old: string;
|
|
30
|
+
new: string;
|
|
31
|
+
patch?: undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DiffViewPatchProps extends DiffViewBase {
|
|
35
|
+
/** A unified patch, as `git diff` writes it. */
|
|
36
|
+
patch: string;
|
|
37
|
+
old?: undefined;
|
|
38
|
+
new?: undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DiffViewProps = DiffViewTextProps | DiffViewPatchProps;
|
|
42
|
+
|
|
43
|
+
const sign = (row: DiffRow): string => (row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' ');
|
|
44
|
+
|
|
45
|
+
const isChange = (row: DiffRow | null): boolean => row?.kind === 'add' || row?.kind === 'del';
|
|
46
|
+
|
|
47
|
+
const pairChanges = (pair: SidePair | undefined): boolean =>
|
|
48
|
+
pair?.kind === 'pair' && (isChange(pair.left) || isChange(pair.right));
|
|
49
|
+
const changeStarts = (pairs: readonly SidePair[], index: number): boolean =>
|
|
50
|
+
pairChanges(pairs[index]) && !pairChanges(pairs[index - 1]);
|
|
51
|
+
|
|
52
|
+
export function DiffView(props: DiffViewProps): JSX.Element {
|
|
53
|
+
const ui = useUi();
|
|
54
|
+
const rt = solid();
|
|
55
|
+
const [enhanced, setEnhanced] = rt.createSignal(false);
|
|
56
|
+
rt.createEffect(() => setEnhanced(true));
|
|
57
|
+
const rows = rt.createMemo(() =>
|
|
58
|
+
diffViewRows({ old: props.old, new: props.new, patch: props.patch, maxLines: props.maxLines }),
|
|
59
|
+
);
|
|
60
|
+
let box: HTMLDivElement | undefined;
|
|
61
|
+
let stop = -1;
|
|
62
|
+
|
|
63
|
+
const spoken = (row: DiffRow): JSX.Element =>
|
|
64
|
+
row.kind === 'add' || row.kind === 'del' ? (
|
|
65
|
+
<span class={styles['spoken']}>
|
|
66
|
+
{ui.t(row.kind === 'add' ? UI_KEYS.diffAdded : UI_KEYS.diffRemoved)}
|
|
67
|
+
</span>
|
|
68
|
+
) : null;
|
|
69
|
+
|
|
70
|
+
const text = (row: DiffRow): JSX.Element =>
|
|
71
|
+
'text' in row
|
|
72
|
+
? markedSegments(row.text, 'marks' in row ? row.marks : undefined).map(([segment, marked]) =>
|
|
73
|
+
marked ? <mark class={styles['word']}>{segment}</mark> : segment,
|
|
74
|
+
)
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
const whole = (row: DiffRow, span: number): JSX.Element => (
|
|
78
|
+
<tr class={styles[row.kind]}>
|
|
79
|
+
<td colspan={span}>
|
|
80
|
+
{row.kind === 'fold'
|
|
81
|
+
? ui.t(UI_KEYS.diffFolded, { count: row.count })
|
|
82
|
+
: 'text' in row
|
|
83
|
+
? row.text
|
|
84
|
+
: null}
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const half = (row: DiffRow | null, side: 'old' | 'new'): JSX.Element[] => [
|
|
90
|
+
<td class={styles['num']}>
|
|
91
|
+
{row !== null && (row.kind === 'add' || row.kind === 'del' || row.kind === 'context')
|
|
92
|
+
? row[side]
|
|
93
|
+
: null}
|
|
94
|
+
</td>,
|
|
95
|
+
<td class={styles['sign']} aria-hidden="true">
|
|
96
|
+
{row === null ? '' : sign(row)}
|
|
97
|
+
</td>,
|
|
98
|
+
<td class={cx(styles['text'], row !== null && styles[row.kind])}>
|
|
99
|
+
{row === null ? null : spoken(row)}
|
|
100
|
+
{row === null ? null : text(row)}
|
|
101
|
+
</td>,
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const next = (): void => {
|
|
105
|
+
const table = [...(box?.querySelectorAll<HTMLElement>('table') ?? [])].find(
|
|
106
|
+
(el) => el.offsetParent !== null,
|
|
107
|
+
);
|
|
108
|
+
const starts = [...(table?.querySelectorAll<HTMLElement>('[data-change="start"]') ?? [])];
|
|
109
|
+
if (starts.length === 0) return;
|
|
110
|
+
stop = (stop + 1) % starts.length;
|
|
111
|
+
starts[stop]?.scrollIntoView({ block: 'center' });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div class={cx(styles['diff'], props.class)} data-enhanced={enhanced() ? 'true' : undefined}>
|
|
116
|
+
<div class={styles['bar']}>
|
|
117
|
+
<button type="button" class={styles['next']} onClick={next}>
|
|
118
|
+
{ui.t(UI_KEYS.diffNext)}
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
{/* biome-ignore lint/a11y/useSemanticElements: the scroll box holds two tables, one shown per width */}
|
|
122
|
+
<div
|
|
123
|
+
class={styles['scroll']}
|
|
124
|
+
tabindex="0"
|
|
125
|
+
role="region"
|
|
126
|
+
aria-label={props.label ?? ui.t(UI_KEYS.diffRegion)}
|
|
127
|
+
dir="ltr"
|
|
128
|
+
ref={(el: HTMLDivElement) => {
|
|
129
|
+
box = el;
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<table class={styles['unified']}>
|
|
133
|
+
<tbody>
|
|
134
|
+
{rows().map((row, index) =>
|
|
135
|
+
row.kind === 'add' || row.kind === 'del' || row.kind === 'context' ? (
|
|
136
|
+
<tr
|
|
137
|
+
class={styles['row']}
|
|
138
|
+
data-change={startsChange(rows(), index) ? 'start' : undefined}
|
|
139
|
+
>
|
|
140
|
+
<td class={styles['num']}>{row.old}</td>
|
|
141
|
+
{half(row, 'new')}
|
|
142
|
+
</tr>
|
|
143
|
+
) : (
|
|
144
|
+
whole(row, 4)
|
|
145
|
+
),
|
|
146
|
+
)}
|
|
147
|
+
</tbody>
|
|
148
|
+
</table>
|
|
149
|
+
<table class={styles['split']}>
|
|
150
|
+
<tbody>
|
|
151
|
+
{sidePairs(rows()).map((pair, index, pairs) =>
|
|
152
|
+
pair.kind === 'whole' ? (
|
|
153
|
+
whole(pair.row, 6)
|
|
154
|
+
) : (
|
|
155
|
+
<tr
|
|
156
|
+
class={styles['row']}
|
|
157
|
+
data-change={changeStarts(pairs, index) ? 'start' : undefined}
|
|
158
|
+
>
|
|
159
|
+
{half(pair.left, 'old')}
|
|
160
|
+
{half(pair.right, 'new')}
|
|
161
|
+
</tr>
|
|
162
|
+
),
|
|
163
|
+
)}
|
|
164
|
+
</tbody>
|
|
165
|
+
</table>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// the ring reads in greyscale. SVG geometry from `donut-chart-view.ts`; text is HTML.
|
|
5
5
|
|
|
6
6
|
import type { JSX } from 'solid-js';
|
|
7
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
7
8
|
import { useUi } from '../theme/context';
|
|
8
9
|
import type { ChartHit } from './ChartFrame';
|
|
9
|
-
import { ChartFrame,
|
|
10
|
+
import { ChartFrame, ChartHits } from './ChartFrame';
|
|
10
11
|
import type { MarkerShape } from './chart-frame-view';
|
|
11
12
|
import {
|
|
12
13
|
chartNumberFormat,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
chartTable,
|
|
15
16
|
cssPercent,
|
|
16
17
|
hitEdge,
|
|
18
|
+
hitIds,
|
|
17
19
|
markerPath,
|
|
18
20
|
pointLabel,
|
|
19
21
|
seriesStyle,
|
|
@@ -62,13 +64,18 @@ export function DonutChart(props: DonutChartProps): JSX.Element {
|
|
|
62
64
|
const total = (): number => props.segments.reduce((sum, s) => sum + finitePositive(s.value), 0);
|
|
63
65
|
const { size, centre, outer, inner, marker } = DONUT;
|
|
64
66
|
|
|
65
|
-
const hits = (): readonly ChartHit[] =>
|
|
66
|
-
|
|
67
|
+
const hits = (): readonly ChartHit[] => {
|
|
68
|
+
const ids = hitIds(
|
|
69
|
+
0,
|
|
70
|
+
props.segments.map((segment) => segment.label),
|
|
71
|
+
);
|
|
72
|
+
return props.segments.flatMap((segment, i) => {
|
|
67
73
|
const anchor = arcs()[i]?.anchor;
|
|
68
74
|
if (anchor === null || anchor === undefined) return [];
|
|
69
75
|
const share = arcs()[i]?.share ?? 0;
|
|
70
76
|
return [
|
|
71
77
|
{
|
|
78
|
+
id: ids[i] ?? String(i),
|
|
72
79
|
x: cssPercent(anchor.x, size),
|
|
73
80
|
y: cssPercent(anchor.y, size),
|
|
74
81
|
label: pointLabel(
|
|
@@ -80,6 +87,7 @@ export function DonutChart(props: DonutChartProps): JSX.Element {
|
|
|
80
87
|
},
|
|
81
88
|
];
|
|
82
89
|
});
|
|
90
|
+
};
|
|
83
91
|
|
|
84
92
|
return (
|
|
85
93
|
<ChartFrame
|
|
@@ -100,6 +108,7 @@ export function DonutChart(props: DonutChartProps): JSX.Element {
|
|
|
100
108
|
{ label: props.valueLabel ?? props.label, values: props.segments.map((s) => s.value) },
|
|
101
109
|
],
|
|
102
110
|
format,
|
|
111
|
+
none: ui.t(UI_KEYS.chartNone),
|
|
103
112
|
})}
|
|
104
113
|
>
|
|
105
114
|
<div class={styles['donut']}>
|
|
@@ -137,7 +146,7 @@ export function DonutChart(props: DonutChartProps): JSX.Element {
|
|
|
137
146
|
<span class={styles['caption']}>{props.centreLabel}</span>
|
|
138
147
|
)}
|
|
139
148
|
</p>
|
|
140
|
-
{props.focusable === true ?
|
|
149
|
+
{props.focusable === true ? <ChartHits hits={hits()} /> : null}
|
|
141
150
|
</div>
|
|
142
151
|
</ChartFrame>
|
|
143
152
|
);
|
|
@@ -51,6 +51,46 @@
|
|
|
51
51
|
border-start-end-radius: t.radius(lg);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// The bottom sheet with a grabber: snapped to half or nearly all of the DYNAMIC viewport, so the
|
|
55
|
+
// browser's own bars never push it off-screen, and clear of the home indicator.
|
|
56
|
+
.side-block-end[data-snap] {
|
|
57
|
+
@include t.transition(translate, block-size);
|
|
58
|
+
block-size: 50dvh;
|
|
59
|
+
max-block-size: calc(100dvh - #{t.safe-area-inset(block-start)} - #{t.space(4)});
|
|
60
|
+
translate: 0 var(--drawer-drag, 0);
|
|
61
|
+
|
|
62
|
+
&[data-snap='full'] {
|
|
63
|
+
block-size: calc(100dvh - #{t.safe-area-inset(block-start)} - #{t.space(4)});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Following the finger: no easing between moves.
|
|
67
|
+
&[data-dragging] {
|
|
68
|
+
transition: none;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.body,
|
|
72
|
+
.footer {
|
|
73
|
+
@include t.safe-area-padding(block-end, t.space(4));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.grabber {
|
|
78
|
+
display: flex;
|
|
79
|
+
justify-content: center;
|
|
80
|
+
padding-block: t.space(2);
|
|
81
|
+
cursor: grab;
|
|
82
|
+
// This box alone: the body keeps its own scroll.
|
|
83
|
+
touch-action: none;
|
|
84
|
+
|
|
85
|
+
&::before {
|
|
86
|
+
content: '';
|
|
87
|
+
inline-size: t.space(10);
|
|
88
|
+
block-size: t.space(1);
|
|
89
|
+
border-radius: t.radius(pill);
|
|
90
|
+
background: t.role('fg-muted');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
.panel {
|
|
55
95
|
display: flex;
|
|
56
96
|
flex-direction: column;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Edge-anchored panel, also a native <dialog> so it inherits the top layer and
|
|
2
2
|
// inert background. `side` is logical (`inline-start`/`inline-end`), so a drawer
|
|
3
3
|
// pinned to the start edge lands on the right in an RTL locale automatically.
|
|
4
|
+
//
|
|
5
|
+
// `side="block-end"` is the bottom sheet: with `grabber`, a drag from the grabber (only — the body
|
|
6
|
+
// keeps its scroll) moves it between `half` and `full` or closes it (`drawer-gesture-view.ts`).
|
|
4
7
|
|
|
5
8
|
import type { JSX } from 'solid-js';
|
|
6
9
|
import { useId } from '../a11y';
|
|
@@ -9,6 +12,7 @@ import { UI_KEYS } from '../i18n-keys';
|
|
|
9
12
|
import { useUi } from '../theme/context';
|
|
10
13
|
import { solid } from '../theme/solid-adapter';
|
|
11
14
|
import styles from './Drawer.module.scss';
|
|
15
|
+
import { type DrawerSnap, dragOffset, releaseSnap } from './drawer-gesture-view';
|
|
12
16
|
import { IconButton } from './IconButton';
|
|
13
17
|
|
|
14
18
|
export type DrawerSide = 'inline-start' | 'inline-end' | 'block-start' | 'block-end';
|
|
@@ -22,6 +26,13 @@ export interface DrawerProps {
|
|
|
22
26
|
side?: DrawerSide | undefined;
|
|
23
27
|
size?: string | undefined;
|
|
24
28
|
footer?: JSX.Element | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* `block-end` only: a handle to drag the sheet by. The drag has no keyboard form, so the close
|
|
31
|
+
* button stays; Esc closes as ever.
|
|
32
|
+
*/
|
|
33
|
+
grabber?: boolean | undefined;
|
|
34
|
+
/** The height a `grabber` sheet opens at: `half` (the default) or `full` of the viewport. */
|
|
35
|
+
snap?: DrawerSnap | undefined;
|
|
25
36
|
class?: string | undefined;
|
|
26
37
|
}
|
|
27
38
|
|
|
@@ -33,6 +44,22 @@ export function Drawer(props: DrawerProps): JSX.Element {
|
|
|
33
44
|
// Where the press STARTED — the `Dialog` rule: a drag-select begun in the panel and released
|
|
34
45
|
// over the backdrop fires `click` on the <dialog> itself, and closed the drawer mid-selection.
|
|
35
46
|
let pressedBackdrop = false;
|
|
47
|
+
const sheet = (): boolean => props.grabber === true && props.side === 'block-end';
|
|
48
|
+
const [snap, setSnap] = rt.createSignal<DrawerSnap>(props.snap ?? 'half');
|
|
49
|
+
let drag: { readonly pointer: number; readonly y: number; readonly height: number } | undefined;
|
|
50
|
+
|
|
51
|
+
const endDrag = (event: PointerEvent, release: boolean): void => {
|
|
52
|
+
if (drag === undefined || event.pointerId !== drag.pointer || element === undefined) return;
|
|
53
|
+
const delta = event.clientY - drag.y;
|
|
54
|
+
const height = drag.height;
|
|
55
|
+
drag = undefined;
|
|
56
|
+
element.style.removeProperty('--drawer-drag');
|
|
57
|
+
element.removeAttribute('data-dragging');
|
|
58
|
+
if (!release) return;
|
|
59
|
+
const next = releaseSnap(delta, height, snap());
|
|
60
|
+
if (next === 'close') props.onClose();
|
|
61
|
+
else setSnap(next);
|
|
62
|
+
};
|
|
36
63
|
|
|
37
64
|
rt.createEffect(() => {
|
|
38
65
|
const dialog = element;
|
|
@@ -51,6 +78,7 @@ export function Drawer(props: DrawerProps): JSX.Element {
|
|
|
51
78
|
}}
|
|
52
79
|
class={cx(styles['drawer'], styles[`side-${props.side ?? 'inline-end'}`], props.class)}
|
|
53
80
|
style={{ '--drawer-size': props.size ?? '22rem' }}
|
|
81
|
+
data-snap={sheet() ? snap() : undefined}
|
|
54
82
|
aria-labelledby={titleId}
|
|
55
83
|
onCancel={(event) => {
|
|
56
84
|
event.preventDefault();
|
|
@@ -66,6 +94,34 @@ export function Drawer(props: DrawerProps): JSX.Element {
|
|
|
66
94
|
}}
|
|
67
95
|
>
|
|
68
96
|
<div class={styles['panel']}>
|
|
97
|
+
{sheet() ? (
|
|
98
|
+
// Pointer events here and nowhere else, `touch-action: none` on this box only: a drag
|
|
99
|
+
// that began in the body would steal its scroll.
|
|
100
|
+
<div
|
|
101
|
+
class={styles['grabber']}
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
onPointerDown={(event) => {
|
|
104
|
+
if (element === undefined) return;
|
|
105
|
+
drag = {
|
|
106
|
+
pointer: event.pointerId,
|
|
107
|
+
y: event.clientY,
|
|
108
|
+
height: element.getBoundingClientRect().height,
|
|
109
|
+
};
|
|
110
|
+
// A touch is captured by its target already; a mouse that leaves the grabber is not.
|
|
111
|
+
if (event.pointerType === 'mouse') {
|
|
112
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
113
|
+
}
|
|
114
|
+
element.setAttribute('data-dragging', '');
|
|
115
|
+
}}
|
|
116
|
+
onPointerMove={(event) => {
|
|
117
|
+
if (drag === undefined || event.pointerId !== drag.pointer) return;
|
|
118
|
+
const offset = dragOffset(event.clientY - drag.y, snap());
|
|
119
|
+
element?.style.setProperty('--drawer-drag', `${offset}px`);
|
|
120
|
+
}}
|
|
121
|
+
onPointerUp={(event) => endDrag(event, true)}
|
|
122
|
+
onPointerCancel={(event) => endDrag(event, false)}
|
|
123
|
+
/>
|
|
124
|
+
) : null}
|
|
69
125
|
<header class={styles['header']}>
|
|
70
126
|
<h2 class={styles['title']} id={titleId}>
|
|
71
127
|
{props.title}
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
fill: var(--tone);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// The target: a stroke of the strongest text colour across the ring, in every tone.
|
|
37
|
+
.target {
|
|
38
|
+
stroke: t.role('fg-strong');
|
|
39
|
+
stroke-width: 2;
|
|
40
|
+
stroke-linecap: round;
|
|
41
|
+
vector-effect: non-scaling-stroke;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
// HTML over the dial's centre, so the figure keeps its type step at every gauge size. The ring's
|
|
37
45
|
// centre is not the box's — the open quarter is cropped — so `--centre` carries where it is.
|
|
38
46
|
.readout {
|
package/src/components/Gauge.tsx
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
// One value against its maximum as a 270° dial — CPU, a quota, a queue's fill — with the reading
|
|
2
|
-
// in its centre and its label under it.
|
|
3
|
-
//
|
|
2
|
+
// in its centre and its label under it. `null` is unknown: the track alone and `ui.chart.none`,
|
|
3
|
+
// never an empty dial that reads as zero. Thresholds pick the tone; a target is a tick.
|
|
4
4
|
|
|
5
5
|
import type { JSX } from 'solid-js';
|
|
6
6
|
import { cx } from '../cx';
|
|
7
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
7
8
|
import { useUi } from '../theme/context';
|
|
8
9
|
import { ChartFrame } from './ChartFrame';
|
|
10
|
+
import { checkThresholds } from './chart-checks';
|
|
9
11
|
import { chartPercentFormat, cssPercent } from './chart-frame-view';
|
|
10
12
|
import styles from './Gauge.module.scss';
|
|
11
|
-
import {
|
|
13
|
+
import type { GaugeThreshold } from './gauge-view';
|
|
14
|
+
import { GAUGE, gaugeArcs, gaugeTick, thresholdTone } from './gauge-view';
|
|
12
15
|
import { meterAria, meterShare } from './meter-view';
|
|
13
16
|
import type { Tone } from './variants';
|
|
14
17
|
|
|
15
18
|
export interface GaugeProps {
|
|
16
19
|
/** What is measured, already translated: shown under the dial, and the meter's name. */
|
|
17
20
|
label: string;
|
|
18
|
-
|
|
21
|
+
/** `null` is unknown: the track alone, and the readout says so. */
|
|
22
|
+
value: number | null;
|
|
19
23
|
max: number;
|
|
20
|
-
/** The fill's colour role. Default `accent
|
|
24
|
+
/** The fill's colour role below every threshold. Default `accent`. */
|
|
21
25
|
tone?: Tone | undefined;
|
|
26
|
+
/** Ascending by `at` (the value's unit): the highest one reached sets the fill's tone. */
|
|
27
|
+
thresholds?: readonly GaugeThreshold[] | undefined;
|
|
28
|
+
/** A tick across the ring at this value — the goal the fill is read against. */
|
|
29
|
+
target?: number | undefined;
|
|
22
30
|
/** The readout, from the value and max. Default: the share as a percentage in the page's locale. */
|
|
23
31
|
format?: ((value: number, max: number) => string) | undefined;
|
|
24
32
|
/** Lands on the root `<figure>`. */
|
|
@@ -27,15 +35,28 @@ export interface GaugeProps {
|
|
|
27
35
|
|
|
28
36
|
export function Gauge(props: GaugeProps): JSX.Element {
|
|
29
37
|
const ui = useUi();
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
const known = (): number | null =>
|
|
39
|
+
props.value !== null && Number.isFinite(props.value) ? props.value : null;
|
|
40
|
+
const share = (): number => meterShare(known() ?? 0, props.max);
|
|
41
|
+
const readout = (): string => {
|
|
42
|
+
const value = known();
|
|
43
|
+
if (value === null) return ui.t(UI_KEYS.chartNone);
|
|
44
|
+
return props.format === undefined
|
|
33
45
|
? chartPercentFormat(ui.locale)(share())
|
|
34
|
-
: props.format(
|
|
46
|
+
: props.format(value, props.max);
|
|
47
|
+
};
|
|
35
48
|
const arcs = () => gaugeArcs(share());
|
|
49
|
+
const tone = (): Tone => {
|
|
50
|
+
checkThresholds('Gauge', props.thresholds ?? []);
|
|
51
|
+
return thresholdTone(known(), props.thresholds, props.tone ?? 'accent');
|
|
52
|
+
};
|
|
53
|
+
const target = (): number | null =>
|
|
54
|
+
props.target !== undefined && Number.isFinite(props.target) && props.max > 0
|
|
55
|
+
? meterShare(props.target, props.max)
|
|
56
|
+
: null;
|
|
36
57
|
return (
|
|
37
58
|
<ChartFrame label={props.label} showCaption class={cx(styles['gauge'], props.class)}>
|
|
38
|
-
<div class={cx(styles['dial'], styles[`tone-${
|
|
59
|
+
<div class={cx(styles['dial'], styles[`tone-${tone()}`])} data-tone={tone()}>
|
|
39
60
|
{/* A native <meter> paints through per-browser pseudo-elements, so it cannot be drawn
|
|
40
61
|
from tokens; the role goes on the svg that draws it, as in `Meter`. */}
|
|
41
62
|
{/* biome-ignore lint/a11y/useSemanticElements: <meter> cannot be styled from tokens */}
|
|
@@ -45,12 +66,18 @@ export function Gauge(props: GaugeProps): JSX.Element {
|
|
|
45
66
|
role="meter"
|
|
46
67
|
aria-label={props.label}
|
|
47
68
|
aria-valuemin={0}
|
|
48
|
-
aria-valuemax={meterAria(
|
|
49
|
-
|
|
69
|
+
aria-valuemax={meterAria(known() ?? 0, props.max).max}
|
|
70
|
+
// `meter` requires a now; for an unknown value the text below is what is spoken.
|
|
71
|
+
aria-valuenow={meterAria(known() ?? 0, props.max).now}
|
|
50
72
|
aria-valuetext={readout()}
|
|
51
73
|
>
|
|
52
74
|
<path class={styles['track']} d={arcs().track} />
|
|
53
|
-
{arcs().fill === '' ? null :
|
|
75
|
+
{arcs().fill === '' || known() === null ? null : (
|
|
76
|
+
<path class={styles['fill']} d={arcs().fill} />
|
|
77
|
+
)}
|
|
78
|
+
{target() === null ? null : (
|
|
79
|
+
<path class={styles['target']} d={gaugeTick(target() ?? 0)} data-target="true" />
|
|
80
|
+
)}
|
|
54
81
|
</svg>
|
|
55
82
|
{/* aria-hidden: the meter already speaks this as its value text. */}
|
|
56
83
|
<span
|