@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
|
@@ -2,24 +2,65 @@
|
|
|
2
2
|
// roving selection goes on an arrow key, and what a key means. Pure, so the caller's island and
|
|
3
3
|
// a page test decide the same thing — and so the component owns no rule a caller could get wrong.
|
|
4
4
|
|
|
5
|
+
import { rankOptions } from './rank-options';
|
|
6
|
+
|
|
5
7
|
export interface CommandPaletteItem {
|
|
6
8
|
/** Stable across a re-filter — what roving selection and `onRunItem` both key off. */
|
|
7
9
|
readonly id: string;
|
|
8
10
|
readonly label: string;
|
|
9
11
|
/** Secondary text — a target URL, a slug, a key chord. Empty renders nothing. */
|
|
10
12
|
readonly hint: string;
|
|
13
|
+
/** Already-translated heading the item sits under; items of one group render together. */
|
|
14
|
+
readonly group?: string | undefined;
|
|
15
|
+
/** Words a reader may type that the label never shows — matched, never rendered. */
|
|
16
|
+
readonly keywords?: readonly string[] | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A run of items under one heading; `group` is `undefined` for the ungrouped run. */
|
|
20
|
+
export interface CommandPaletteGroup {
|
|
21
|
+
readonly group: string | undefined;
|
|
22
|
+
readonly items: readonly CommandPaletteItem[];
|
|
11
23
|
}
|
|
12
24
|
|
|
13
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* `rankOptions` over label, hint and keywords — the ranker `SearchableSelect` uses, so `cafe`
|
|
27
|
+
* finds `Café` — then regrouped: a group keeps the place its first item had in the caller's list,
|
|
28
|
+
* and the rank decides the order inside it. An empty query keeps everything, in order.
|
|
29
|
+
*/
|
|
14
30
|
export function filterItems(
|
|
15
31
|
items: readonly CommandPaletteItem[],
|
|
16
32
|
query: string,
|
|
17
33
|
): readonly CommandPaletteItem[] {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
const ranked = rankOptions(
|
|
35
|
+
items.map((item) => ({
|
|
36
|
+
label: item.label,
|
|
37
|
+
description: item.hint,
|
|
38
|
+
keywords: item.keywords,
|
|
39
|
+
item,
|
|
40
|
+
})),
|
|
41
|
+
query,
|
|
42
|
+
items.length,
|
|
43
|
+
).matches.map((entry) => entry.item);
|
|
44
|
+
const order = new Map<string | undefined, number>();
|
|
45
|
+
for (const item of items) if (!order.has(item.group)) order.set(item.group, order.size);
|
|
46
|
+
return ranked
|
|
47
|
+
.map((item, index) => ({ item, index }))
|
|
48
|
+
.sort(
|
|
49
|
+
(a, b) =>
|
|
50
|
+
(order.get(a.item.group) ?? 0) - (order.get(b.item.group) ?? 0) || a.index - b.index,
|
|
51
|
+
)
|
|
52
|
+
.map((entry) => entry.item);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Consecutive items sharing a `group`, as the palette renders them under one heading each. */
|
|
56
|
+
export function groupItems(items: readonly CommandPaletteItem[]): readonly CommandPaletteGroup[] {
|
|
57
|
+
const out: { group: string | undefined; items: CommandPaletteItem[] }[] = [];
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
const last = out[out.length - 1];
|
|
60
|
+
if (last !== undefined && last.group === item.group) last.items.push(item);
|
|
61
|
+
else out.push({ group: item.group, items: [item] });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
23
64
|
}
|
|
24
65
|
|
|
25
66
|
/** The active id after a step, wrapping at both ends; the first item when nothing was active. */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A destructive confirmation's rules, apart from its markup: idle → busy → idle or refused, a
|
|
2
|
+
// second submit while busy refused, and a confirm label that names the act. Pure, so the island
|
|
3
|
+
// path and its test decide the same thing.
|
|
4
|
+
|
|
5
|
+
import { invalidValueError } from '../errors';
|
|
6
|
+
|
|
7
|
+
export type ConfirmState =
|
|
8
|
+
| { readonly kind: 'idle' }
|
|
9
|
+
| { readonly kind: 'busy' }
|
|
10
|
+
/** The act was refused: the dialog stays open with `message` in its form summary. */
|
|
11
|
+
| { readonly kind: 'refused'; readonly message: string };
|
|
12
|
+
|
|
13
|
+
export type ConfirmEvent =
|
|
14
|
+
| { readonly type: 'submit' }
|
|
15
|
+
/** `onConfirm` settled: `undefined` is done, a string is the already-translated refusal. */
|
|
16
|
+
| { readonly type: 'settled'; readonly refusal: string | undefined }
|
|
17
|
+
/** The dialog closed, by Cancel or Esc: the next opening starts clean. */
|
|
18
|
+
| { readonly type: 'closed' };
|
|
19
|
+
|
|
20
|
+
export const CONFIRM_IDLE: ConfirmState = { kind: 'idle' };
|
|
21
|
+
|
|
22
|
+
/** The next state, or `null` for an event the current state refuses — a submit while busy. */
|
|
23
|
+
export function confirmStep(state: ConfirmState, event: ConfirmEvent): ConfirmState | null {
|
|
24
|
+
switch (event.type) {
|
|
25
|
+
case 'submit':
|
|
26
|
+
return state.kind === 'busy' ? null : { kind: 'busy' };
|
|
27
|
+
case 'settled':
|
|
28
|
+
if (state.kind !== 'busy') return null;
|
|
29
|
+
return event.refusal === undefined
|
|
30
|
+
? CONFIRM_IDLE
|
|
31
|
+
: { kind: 'refused', message: event.refusal };
|
|
32
|
+
case 'closed':
|
|
33
|
+
return state.kind === 'busy' ? null : CONFIRM_IDLE;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A confirm button says what it does — "Delete post", never "OK" — so there is no default and an
|
|
39
|
+
* empty one is `X_UI_INVALID_VALUE`: the label is the last thing read before an act that cannot
|
|
40
|
+
* be taken back.
|
|
41
|
+
*/
|
|
42
|
+
export function confirmLabelOf(label: string): string {
|
|
43
|
+
if (label.trim() === '') {
|
|
44
|
+
throw invalidValueError('ConfirmDialog confirmLabel', label, 'a verb naming the act');
|
|
45
|
+
}
|
|
46
|
+
return label;
|
|
47
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// DataTable's keyed rows: one cell per `rowKey`, so a live update leaves an unchanged row's DOM —
|
|
2
|
+
// its focus, its open "more" disclosure — exactly where it was. Pure over an injected runtime, so
|
|
3
|
+
// the rule is proven under real Solid without a renderer; `solid-js` stays a type-only import.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { UI_ERROR_CODES, UiError } from '../errors';
|
|
7
|
+
import type { Accessor, Setter } from '../theme/solid-adapter';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The slice of Solid the rule needs. `createRoot` is the load-bearing one: a row built inside the
|
|
11
|
+
* body's computation is OWNED by it, and Solid disposes what a computation owns each time it
|
|
12
|
+
* re-runs — so a kept node would keep its markup and lose every binding inside it on the next tick.
|
|
13
|
+
*/
|
|
14
|
+
export interface KeyedRowsRuntime {
|
|
15
|
+
createSignal<T>(value: T): [Accessor<T>, Setter<T>];
|
|
16
|
+
createRoot<T>(fn: (dispose: () => void) => T): T;
|
|
17
|
+
onCleanup(fn: () => void): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Cell<Row, Node> {
|
|
21
|
+
/** The object last seen under this key, held plainly: comparing it must not subscribe the body. */
|
|
22
|
+
row: Row;
|
|
23
|
+
readonly set: Setter<Row>;
|
|
24
|
+
readonly node: Node;
|
|
25
|
+
readonly dispose: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Two rows under one key cannot both keep a node. */
|
|
29
|
+
function duplicateKeyError(key: string): UiError {
|
|
30
|
+
return new UiError({
|
|
31
|
+
code: UI_ERROR_CODES.invalidValue,
|
|
32
|
+
cause: `<DataTable> rowKey answered ${renderCauseValue(key)} for two rows, so one node would have to be both — rowKey must be unique per row`,
|
|
33
|
+
fix: 'rowKey: (row) => row.id # on <DataTable>: the row primary key, unique within rows',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Call once, in the component body: the returned map is what the body runs on every update. The
|
|
39
|
+
* same object under a key returns the same node and runs nothing; a new object under a kept key
|
|
40
|
+
* returns the same node and sets its signal, so only the cells reading it redraw; a vanished key
|
|
41
|
+
* disposes its row. Disposing the owner disposes every row.
|
|
42
|
+
*/
|
|
43
|
+
export function keyedRows<Row, Node>(
|
|
44
|
+
runtime: KeyedRowsRuntime,
|
|
45
|
+
keyOf: (row: Row) => string,
|
|
46
|
+
build: (row: Accessor<Row>, key: string) => Node,
|
|
47
|
+
): (rows: readonly Row[]) => Node[] {
|
|
48
|
+
const cells = new Map<string, Cell<Row, Node>>();
|
|
49
|
+
runtime.onCleanup(() => {
|
|
50
|
+
for (const cell of cells.values()) cell.dispose();
|
|
51
|
+
cells.clear();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const cellFor = (row: Row, key: string): Cell<Row, Node> => {
|
|
55
|
+
const kept = cells.get(key);
|
|
56
|
+
if (kept !== undefined) {
|
|
57
|
+
if (kept.row !== row) {
|
|
58
|
+
kept.row = row;
|
|
59
|
+
kept.set(row);
|
|
60
|
+
}
|
|
61
|
+
return kept;
|
|
62
|
+
}
|
|
63
|
+
const made = runtime.createRoot((dispose) => {
|
|
64
|
+
const [current, set] = runtime.createSignal(row);
|
|
65
|
+
try {
|
|
66
|
+
return { row, set, node: build(current, key), dispose };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Never cached, so never disposed by a later run: what the build made so far goes now.
|
|
69
|
+
dispose();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
cells.set(key, made);
|
|
74
|
+
return made;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (rows) => {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const nodes = rows.map((row) => {
|
|
80
|
+
const key = keyOf(row);
|
|
81
|
+
if (seen.has(key)) throw duplicateKeyError(key);
|
|
82
|
+
seen.add(key);
|
|
83
|
+
return cellFor(row, key).node;
|
|
84
|
+
});
|
|
85
|
+
for (const [key, cell] of cells) {
|
|
86
|
+
if (!seen.has(key)) {
|
|
87
|
+
cell.dispose();
|
|
88
|
+
cells.delete(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return nodes;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// When a date input gets its calendar, and what opens it. Pure, so the progressive-enhancement
|
|
2
|
+
// rule is tested without a browser: the native input always renders and is always the value.
|
|
3
|
+
|
|
4
|
+
import type { PlainDate } from '@ultimat3/time';
|
|
5
|
+
import { isPlainDate } from '@ultimat3/time';
|
|
6
|
+
|
|
7
|
+
/** The media query a calendar popover waits for. A coarse pointer keeps the platform's picker. */
|
|
8
|
+
export const FINE_POINTER_QUERY = '(pointer: fine)';
|
|
9
|
+
|
|
10
|
+
/** The calendar is an extra for a fine pointer with a script; a phone's native picker wins. */
|
|
11
|
+
export function enhancesWithCalendar(
|
|
12
|
+
matchMedia: ((query: string) => { matches: boolean }) | undefined,
|
|
13
|
+
): boolean {
|
|
14
|
+
return matchMedia?.(FINE_POINTER_QUERY).matches === true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** F4 and Alt+ArrowDown open a date control's picker on every desktop platform. */
|
|
18
|
+
export function opensPicker(event: { readonly key: string; readonly altKey?: boolean }): boolean {
|
|
19
|
+
return event.key === 'F4' || (event.key === 'ArrowDown' && event.altKey === true);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the input holds, as a date: `undefined` for empty or a value the browser half-typed. */
|
|
23
|
+
export function inputDate(raw: string): PlainDate | undefined {
|
|
24
|
+
return isPlainDate(raw) ? raw : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write a picked day into the native input the way typing would, so a form, a `Field` and any
|
|
29
|
+
* `onInput`/`onChange` listener see one path whether the day came from the keyboard or the grid.
|
|
30
|
+
*/
|
|
31
|
+
export function writeInputDate(
|
|
32
|
+
input: { value: string; dispatchEvent(event: Event): boolean },
|
|
33
|
+
date: PlainDate,
|
|
34
|
+
): void {
|
|
35
|
+
input.value = date;
|
|
36
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
37
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A date range's rules: which end a pick sets, a backwards pair swapped, and where a day sits in
|
|
2
|
+
// the span (`aria-selected` covers it all). Pure over `PlainDate`, so `DateRangePicker` is wiring.
|
|
3
|
+
|
|
4
|
+
import { comparePlainDates, type PlainDate } from '@ultimat3/time';
|
|
5
|
+
|
|
6
|
+
export interface DateRange {
|
|
7
|
+
readonly from?: PlainDate | undefined;
|
|
8
|
+
readonly to?: PlainDate | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SpanPosition = 'start' | 'end' | 'inside';
|
|
12
|
+
|
|
13
|
+
/** Ends in order: a `to` before its `from` is the same span read the other way. */
|
|
14
|
+
export function orderedRange(range: DateRange): DateRange {
|
|
15
|
+
const { from, to } = range;
|
|
16
|
+
if (from !== undefined && to !== undefined && comparePlainDates(to, from) < 0) {
|
|
17
|
+
return { from: to, to: from };
|
|
18
|
+
}
|
|
19
|
+
return range;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The first pick starts a range, the second completes it — either side of the first, swapped into
|
|
24
|
+
* order. A pick after a complete range starts the next one.
|
|
25
|
+
*/
|
|
26
|
+
export function pickRange(
|
|
27
|
+
range: DateRange,
|
|
28
|
+
picked: PlainDate,
|
|
29
|
+
): { readonly range: DateRange; readonly complete: boolean } {
|
|
30
|
+
if (range.from === undefined || range.to !== undefined) {
|
|
31
|
+
return { range: { from: picked, to: undefined }, complete: false };
|
|
32
|
+
}
|
|
33
|
+
return { range: orderedRange({ from: range.from, to: picked }), complete: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Where `date` sits in the span, `undefined` outside it. One end alone is its own start. */
|
|
37
|
+
export function spanPosition(date: PlainDate, range: DateRange): SpanPosition | undefined {
|
|
38
|
+
const { from, to } = orderedRange(range);
|
|
39
|
+
if (from === undefined) return undefined;
|
|
40
|
+
if (date === from) return 'start';
|
|
41
|
+
if (to === undefined) return undefined;
|
|
42
|
+
if (date === to) return 'end';
|
|
43
|
+
return comparePlainDates(date, from) > 0 && comparePlainDates(date, to) < 0
|
|
44
|
+
? 'inside'
|
|
45
|
+
: undefined;
|
|
46
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The pure half of `DiffView`: rows from either input, folded at `maxLines`; the side-by-side
|
|
2
|
+
// pairing; a row's text cut into marked and unmarked segments; and where each change run starts.
|
|
3
|
+
|
|
4
|
+
import { finiteCount } from '@ultimat3/core';
|
|
5
|
+
import { type DiffRow, foldRows, lineDiff } from '../content/line-diff';
|
|
6
|
+
import { parseUnifiedPatch } from '../content/unified-patch';
|
|
7
|
+
|
|
8
|
+
export const DIFF_VIEW_MAX_LINES = 2_000;
|
|
9
|
+
|
|
10
|
+
export interface DiffInput {
|
|
11
|
+
readonly old?: string | undefined;
|
|
12
|
+
readonly new?: string | undefined;
|
|
13
|
+
readonly patch?: string | undefined;
|
|
14
|
+
readonly maxLines?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function diffViewRows(input: DiffInput): readonly DiffRow[] {
|
|
18
|
+
const max = finiteCount('DiffView', 'maxLines', input.maxLines ?? DIFF_VIEW_MAX_LINES, 1);
|
|
19
|
+
const rows =
|
|
20
|
+
input.patch === undefined
|
|
21
|
+
? lineDiff(input.old ?? '', input.new ?? '')
|
|
22
|
+
: parseUnifiedPatch(input.patch);
|
|
23
|
+
return foldRows(rows, max);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SidePair =
|
|
27
|
+
| { readonly kind: 'pair'; readonly left: DiffRow | null; readonly right: DiffRow | null }
|
|
28
|
+
| { readonly kind: 'whole'; readonly row: DiffRow };
|
|
29
|
+
|
|
30
|
+
/** Context on both sides; a removed run beside the added run after it; headers and folds across. */
|
|
31
|
+
export function sidePairs(rows: readonly DiffRow[]): SidePair[] {
|
|
32
|
+
const out: SidePair[] = [];
|
|
33
|
+
for (let r = 0; r < rows.length; ) {
|
|
34
|
+
const row = rows[r];
|
|
35
|
+
if (row === undefined) break;
|
|
36
|
+
if (row.kind === 'context') {
|
|
37
|
+
out.push({ kind: 'pair', left: row, right: row });
|
|
38
|
+
r += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (row.kind !== 'del' && row.kind !== 'add') {
|
|
42
|
+
out.push({ kind: 'whole', row });
|
|
43
|
+
r += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
let dels = r;
|
|
47
|
+
while (rows[dels]?.kind === 'del') dels += 1;
|
|
48
|
+
let adds = dels;
|
|
49
|
+
while (rows[adds]?.kind === 'add') adds += 1;
|
|
50
|
+
const span = Math.max(dels - r, adds - dels);
|
|
51
|
+
for (let p = 0; p < span; p += 1) {
|
|
52
|
+
out.push({
|
|
53
|
+
kind: 'pair',
|
|
54
|
+
left: p < dels - r ? (rows[r + p] ?? null) : null,
|
|
55
|
+
right: p < adds - dels ? (rows[dels + p] ?? null) : null,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
r = adds;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `text` cut at the marks: `[segment, marked]` in order, covering the text exactly. */
|
|
64
|
+
export function markedSegments(
|
|
65
|
+
text: string,
|
|
66
|
+
marks: readonly (readonly [number, number])[] | undefined,
|
|
67
|
+
): readonly (readonly [string, boolean])[] {
|
|
68
|
+
if (marks === undefined || marks.length === 0) return [[text, false]];
|
|
69
|
+
const out: [string, boolean][] = [];
|
|
70
|
+
let at = 0;
|
|
71
|
+
for (const [from, to] of marks) {
|
|
72
|
+
if (from > at) out.push([text.slice(at, from), false]);
|
|
73
|
+
if (to > from) out.push([text.slice(from, to), true]);
|
|
74
|
+
at = Math.max(at, to);
|
|
75
|
+
}
|
|
76
|
+
if (at < text.length) out.push([text.slice(at), false]);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True for a changed row whose previous row is not a change: where "Next change" stops. */
|
|
81
|
+
export function startsChange(rows: readonly DiffRow[], index: number): boolean {
|
|
82
|
+
const kind = rows[index]?.kind;
|
|
83
|
+
const before = rows[index - 1]?.kind;
|
|
84
|
+
return (kind === 'add' || kind === 'del') && before !== 'add' && before !== 'del';
|
|
85
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A bottom sheet's drag, as numbers: how far the panel follows the finger, and where it settles
|
|
2
|
+
// on release — `half`, `full`, or closed. Pure, so the browser test and the unit test agree on the
|
|
3
|
+
// thresholds, and the component only moves a custom property.
|
|
4
|
+
|
|
5
|
+
export type DrawerSnap = 'half' | 'full';
|
|
6
|
+
export type DrawerRelease = DrawerSnap | 'close';
|
|
7
|
+
|
|
8
|
+
/** Past this share of the sheet's height, a release moves to the next snap. */
|
|
9
|
+
export const SNAP_SHARE = 0.2;
|
|
10
|
+
/** Past this share downwards, a release closes the sheet. */
|
|
11
|
+
export const CLOSE_SHARE = 0.5;
|
|
12
|
+
/** An upward drag past the top snap follows at a third: resistance, not a hard stop. */
|
|
13
|
+
export const OVER_DRAG_RESISTANCE = 3;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The panel's offset for a finger `delta` px below where it went down (negative is up). Down
|
|
17
|
+
* follows 1:1; up follows at a third, and not at all from `full`, where there is no further snap.
|
|
18
|
+
*/
|
|
19
|
+
export function dragOffset(delta: number, from: DrawerSnap): number {
|
|
20
|
+
if (!Number.isFinite(delta)) return 0;
|
|
21
|
+
if (delta >= 0) return delta;
|
|
22
|
+
return from === 'full' ? 0 : delta / OVER_DRAG_RESISTANCE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Where a sheet `height` px tall, dragged `delta` px from `from`, settles on release. */
|
|
26
|
+
export function releaseSnap(delta: number, height: number, from: DrawerSnap): DrawerRelease {
|
|
27
|
+
if (!Number.isFinite(delta) || !(height > 0)) return from;
|
|
28
|
+
const share = delta / height;
|
|
29
|
+
if (share > CLOSE_SHARE) return 'close';
|
|
30
|
+
if (from === 'full' && share > SNAP_SHARE) return 'half';
|
|
31
|
+
if (from === 'half' && share < -SNAP_SHARE) return 'full';
|
|
32
|
+
return from;
|
|
33
|
+
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// a value fills. `Gauge` takes that share from `meterShare` — one rule for "value of max", so a
|
|
3
3
|
// Gauge and a Meter beside it can never disagree about what 7 of 4 draws.
|
|
4
4
|
|
|
5
|
+
import { svgNumber } from './chart-frame-view';
|
|
5
6
|
import { annularSector } from './donut-chart-view';
|
|
7
|
+
import type { Tone } from './variants';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* 200 wide, cropped to 172 tall: the dial's open quarter is at the bottom, and the box ends just
|
|
@@ -41,3 +43,30 @@ export function gaugeArcs(share: number): GaugeArcs {
|
|
|
41
43
|
fill: annularSector(centre, centre, outer, inner, from, end),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
46
|
+
|
|
47
|
+
/** A target's tick: a radial stroke across the ring at `share`, a little proud of both edges. */
|
|
48
|
+
export function gaugeTick(share: number): string {
|
|
49
|
+
const { centre, outer, inner } = GAUGE;
|
|
50
|
+
const angle = gaugeFillEnd(share);
|
|
51
|
+
const at = (r: number): string =>
|
|
52
|
+
`${svgNumber(centre + Math.sin(angle) * r)},${svgNumber(centre - Math.cos(angle) * r)}`;
|
|
53
|
+
return `M${at(inner - 4)} L${at(outer + 4)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GaugeThreshold {
|
|
57
|
+
/** In the value's own unit: the tone applies from this value up. Ascending. */
|
|
58
|
+
readonly at: number;
|
|
59
|
+
readonly tone: Tone;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The tone of the highest threshold `value` has reached, or `fallback` below them all. */
|
|
63
|
+
export function thresholdTone(
|
|
64
|
+
value: number | null,
|
|
65
|
+
thresholds: readonly GaugeThreshold[] | undefined,
|
|
66
|
+
fallback: Tone,
|
|
67
|
+
): Tone {
|
|
68
|
+
if (value === null || !Number.isFinite(value)) return fallback;
|
|
69
|
+
let tone = fallback;
|
|
70
|
+
for (const threshold of thresholds ?? []) if (value >= threshold.at) tone = threshold.tone;
|
|
71
|
+
return tone;
|
|
72
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The rules of a HeatStrip, apart from its markup: each cell's accessible name (the whole reading,
|
|
2
|
+
// never a colour), its shape per tone — so a cell reads in greyscale and to a colour-blind eye —
|
|
3
|
+
// and the refusal of two cells under one key in a row, which would make two nodes one.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { UI_ERROR_CODES, UiError } from '../errors';
|
|
7
|
+
import type { Tone } from './variants';
|
|
8
|
+
|
|
9
|
+
export interface HeatCell {
|
|
10
|
+
/** Unique within its row: a time bucket, a check id. */
|
|
11
|
+
readonly key: string;
|
|
12
|
+
/** Already translated, the whole reading: "12:00 — 3 failed of 40". It IS the cell's name. */
|
|
13
|
+
readonly label: string;
|
|
14
|
+
/** What the cell means; absent is no data. */
|
|
15
|
+
readonly tone?: Tone | undefined;
|
|
16
|
+
/** The page behind the cell: a linked cell is a link, an unlinked one no tab stop. */
|
|
17
|
+
readonly href?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HeatRow {
|
|
21
|
+
/** Already translated: the row's name — a service, a check. */
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly cells: readonly HeatCell[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A cell's mark: one shape per tone, so meaning never rides on colour alone. */
|
|
27
|
+
export type HeatShape = 'square' | 'circle' | 'triangle' | 'diamond' | 'cross' | 'ring' | 'dash';
|
|
28
|
+
|
|
29
|
+
export const HEAT_SHAPES: ReadonlyMap<Tone | 'none', HeatShape> = new Map([
|
|
30
|
+
['success', 'square'],
|
|
31
|
+
['info', 'circle'],
|
|
32
|
+
['accent', 'ring'],
|
|
33
|
+
['warning', 'triangle'],
|
|
34
|
+
['danger', 'cross'],
|
|
35
|
+
['neutral', 'diamond'],
|
|
36
|
+
['none', 'dash'],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export interface HeatCellView {
|
|
40
|
+
readonly key: string;
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly tone: Tone | 'none';
|
|
43
|
+
readonly shape: HeatShape;
|
|
44
|
+
readonly href: string | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A row's cells as drawn, each named `row, reading` so a cell out of context still says whose. */
|
|
48
|
+
export function heatCells(row: HeatRow): readonly HeatCellView[] {
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
return row.cells.map((cell) => {
|
|
51
|
+
if (seen.has(cell.key)) {
|
|
52
|
+
throw new UiError({
|
|
53
|
+
code: UI_ERROR_CODES.invalidValue,
|
|
54
|
+
cause: `<HeatStrip> row ${renderCauseValue(row.label)} has two cells with key ${renderCauseValue(cell.key)}`,
|
|
55
|
+
fix: 'cells: buckets.map((b) => ({ key: b.at.toISOString(), label, tone })) # one cell per key in a row',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
seen.add(cell.key);
|
|
59
|
+
const tone = cell.tone ?? 'none';
|
|
60
|
+
return {
|
|
61
|
+
key: cell.key,
|
|
62
|
+
name: `${row.label}, ${cell.label}`,
|
|
63
|
+
tone,
|
|
64
|
+
shape: HEAT_SHAPES.get(tone) ?? 'dash',
|
|
65
|
+
href: cell.href,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Any value to a tree `JsonView` draws as nested `<details>`: keys in the order given, a depth cap,
|
|
2
|
+
// a cycle named rather than followed, and what JSON cannot hold (a bigint, a function) as text.
|
|
3
|
+
// Never throws on content: a webhook body is whatever the sender sent.
|
|
4
|
+
|
|
5
|
+
export const JSON_VIEW_MAX_DEPTH = 12;
|
|
6
|
+
/** Entries past this many in one object or array fold into a count. */
|
|
7
|
+
export const JSON_VIEW_MAX_ENTRIES = 500;
|
|
8
|
+
|
|
9
|
+
export type JsonViewNode =
|
|
10
|
+
| { readonly kind: 'string'; readonly value: string }
|
|
11
|
+
| { readonly kind: 'number' | 'boolean' | 'null' | 'other'; readonly value: string }
|
|
12
|
+
| {
|
|
13
|
+
readonly kind: 'object' | 'array';
|
|
14
|
+
readonly entries: readonly (readonly [string, JsonViewNode])[];
|
|
15
|
+
/** Entries left out past `JSON_VIEW_MAX_ENTRIES`. */
|
|
16
|
+
readonly more: number;
|
|
17
|
+
readonly size: number;
|
|
18
|
+
}
|
|
19
|
+
| { readonly kind: 'deep' | 'circular' };
|
|
20
|
+
|
|
21
|
+
export function jsonViewTree(
|
|
22
|
+
value: unknown,
|
|
23
|
+
depth = 0,
|
|
24
|
+
seen: Set<object> = new Set(),
|
|
25
|
+
): JsonViewNode {
|
|
26
|
+
if (value === null || value === undefined) return { kind: 'null', value: 'null' };
|
|
27
|
+
if (typeof value === 'string') return { kind: 'string', value };
|
|
28
|
+
if (typeof value === 'number')
|
|
29
|
+
return { kind: 'number', value: Number.isFinite(value) ? String(value) : 'null' };
|
|
30
|
+
if (typeof value === 'boolean') return { kind: 'boolean', value: String(value) };
|
|
31
|
+
if (typeof value !== 'object') return { kind: 'other', value: String(value) };
|
|
32
|
+
if (value instanceof Date)
|
|
33
|
+
return {
|
|
34
|
+
kind: 'string',
|
|
35
|
+
value: Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(),
|
|
36
|
+
};
|
|
37
|
+
if (seen.has(value)) return { kind: 'circular' };
|
|
38
|
+
if (depth >= JSON_VIEW_MAX_DEPTH) return { kind: 'deep' };
|
|
39
|
+
seen.add(value);
|
|
40
|
+
const array = Array.isArray(value);
|
|
41
|
+
const pairs: [string, unknown][] = array
|
|
42
|
+
? (value as readonly unknown[]).map((item, index) => [String(index), item])
|
|
43
|
+
: Object.keys(value).map((key) => [key, (value as Record<string, unknown>)[key]]);
|
|
44
|
+
const entries = pairs
|
|
45
|
+
.slice(0, JSON_VIEW_MAX_ENTRIES)
|
|
46
|
+
.map(([key, item]) => [key, jsonViewTree(item, depth + 1, seen)] as const);
|
|
47
|
+
seen.delete(value);
|
|
48
|
+
return {
|
|
49
|
+
kind: array ? 'array' : 'object',
|
|
50
|
+
entries,
|
|
51
|
+
more: Math.max(0, pairs.length - JSON_VIEW_MAX_ENTRIES),
|
|
52
|
+
size: pairs.length,
|
|
53
|
+
};
|
|
54
|
+
}
|