@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,153 @@
|
|
|
1
|
+
// The shell every `CommandPalette` caller used to write: the open flag, the query, the active row,
|
|
2
|
+
// `⌘K` (a row of the page's one hotkey table, so `?` lists it) and focus back to the opener on
|
|
3
|
+
// close. The rules stay in `command-palette-view.ts`; this file only holds the state they act on.
|
|
4
|
+
|
|
5
|
+
import { debounce } from '../debounce';
|
|
6
|
+
import { solid } from '../theme/solid-adapter';
|
|
7
|
+
import { useHotkeys } from '../use-hotkeys';
|
|
8
|
+
import type { CommandPaletteProps } from './CommandPalette';
|
|
9
|
+
import {
|
|
10
|
+
type CommandPaletteItem,
|
|
11
|
+
filterItems,
|
|
12
|
+
keyAction,
|
|
13
|
+
settleActive,
|
|
14
|
+
stepActive,
|
|
15
|
+
} from './command-palette-view';
|
|
16
|
+
|
|
17
|
+
export interface UseCommandPaletteOptions {
|
|
18
|
+
/** Every item, unfiltered; a function when the list changes over the island's life. */
|
|
19
|
+
items: readonly CommandPaletteItem[] | (() => readonly CommandPaletteItem[]);
|
|
20
|
+
/** The chosen item. The palette closes first, so a navigation lands on a closed palette. */
|
|
21
|
+
onRun: (item: CommandPaletteItem) => void;
|
|
22
|
+
/** Already-translated legend of the shortcut in `ShortcutSheet` — usually the palette's title. */
|
|
23
|
+
label: string;
|
|
24
|
+
/** The binding that toggles it; `mod+k` (⌘K / Ctrl K) when omitted. */
|
|
25
|
+
hotkey?: string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Items found for a query, for a list too long to ship: called debounced, and its answer
|
|
28
|
+
* replaces `items` while the query is non-empty. A slower answer to an older query is dropped.
|
|
29
|
+
*/
|
|
30
|
+
onQuery?: ((query: string) => Promise<readonly CommandPaletteItem[]>) | undefined;
|
|
31
|
+
debounceMs?: number | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Everything `CommandPalette` takes except `labels` — spread it: `<CommandPalette {...p.props} />`. */
|
|
35
|
+
export type CommandPaletteState = Omit<CommandPaletteProps, 'labels' | 'class' | 'id'>;
|
|
36
|
+
|
|
37
|
+
export interface CommandPaletteController {
|
|
38
|
+
readonly props: CommandPaletteState;
|
|
39
|
+
readonly show: () => void;
|
|
40
|
+
readonly hide: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useCommandPalette(options: UseCommandPaletteOptions): CommandPaletteController {
|
|
44
|
+
const rt = solid();
|
|
45
|
+
const [open, setOpen] = rt.createSignal(false);
|
|
46
|
+
const [query, setQuery] = rt.createSignal('');
|
|
47
|
+
const [activeId, setActiveId] = rt.createSignal<string | undefined>(undefined);
|
|
48
|
+
const [found, setFound] = rt.createSignal<readonly CommandPaletteItem[]>([]);
|
|
49
|
+
const [loading, setLoading] = rt.createSignal(false);
|
|
50
|
+
let opener: HTMLElement | null = null;
|
|
51
|
+
let asked = 0;
|
|
52
|
+
|
|
53
|
+
const all = (): readonly CommandPaletteItem[] =>
|
|
54
|
+
typeof options.items === 'function' ? options.items() : options.items;
|
|
55
|
+
const visible = (): readonly CommandPaletteItem[] =>
|
|
56
|
+
options.onQuery !== undefined && query().trim() !== '' ? found() : filterItems(all(), query());
|
|
57
|
+
const active = (): string | undefined => settleActive(visible(), activeId());
|
|
58
|
+
|
|
59
|
+
const ask = debounce((value: string) => {
|
|
60
|
+
const onQuery = options.onQuery;
|
|
61
|
+
if (onQuery === undefined) return;
|
|
62
|
+
asked += 1;
|
|
63
|
+
const mine = asked;
|
|
64
|
+
onQuery(value).then(
|
|
65
|
+
(items) => {
|
|
66
|
+
if (mine !== asked) return;
|
|
67
|
+
setFound(items);
|
|
68
|
+
setLoading(false);
|
|
69
|
+
},
|
|
70
|
+
() => {
|
|
71
|
+
if (mine !== asked) return;
|
|
72
|
+
setFound([]);
|
|
73
|
+
setLoading(false);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
}, options.debounceMs);
|
|
77
|
+
rt.onCleanup(() => ask.cancel());
|
|
78
|
+
|
|
79
|
+
const show = (): void => {
|
|
80
|
+
if (open()) return;
|
|
81
|
+
const active = typeof document === 'undefined' ? null : document.activeElement;
|
|
82
|
+
opener = typeof HTMLElement !== 'undefined' && active instanceof HTMLElement ? active : null;
|
|
83
|
+
setQuery('');
|
|
84
|
+
setActiveId(undefined);
|
|
85
|
+
setOpen(true);
|
|
86
|
+
};
|
|
87
|
+
/** Forgets the request waiting or in flight: its answer, when it comes, is stale. */
|
|
88
|
+
const drop = (): void => {
|
|
89
|
+
ask.cancel();
|
|
90
|
+
asked += 1;
|
|
91
|
+
setLoading(false);
|
|
92
|
+
};
|
|
93
|
+
const hide = (): void => {
|
|
94
|
+
if (!open()) return;
|
|
95
|
+
setOpen(false);
|
|
96
|
+
drop();
|
|
97
|
+
const back = opener;
|
|
98
|
+
opener = null;
|
|
99
|
+
back?.focus();
|
|
100
|
+
};
|
|
101
|
+
const run = (id: string | undefined): void => {
|
|
102
|
+
const item = visible().find((candidate) => candidate.id === id);
|
|
103
|
+
if (item === undefined) return;
|
|
104
|
+
hide();
|
|
105
|
+
options.onRun(item);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
useHotkeys([
|
|
109
|
+
{
|
|
110
|
+
keys: [options.hotkey ?? 'mod+k'],
|
|
111
|
+
label: options.label,
|
|
112
|
+
run: () => (open() ? hide() : show()),
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const props: CommandPaletteState = {
|
|
117
|
+
get open() {
|
|
118
|
+
return open();
|
|
119
|
+
},
|
|
120
|
+
get query() {
|
|
121
|
+
return query();
|
|
122
|
+
},
|
|
123
|
+
get items() {
|
|
124
|
+
return visible();
|
|
125
|
+
},
|
|
126
|
+
get activeId() {
|
|
127
|
+
return active();
|
|
128
|
+
},
|
|
129
|
+
get loading() {
|
|
130
|
+
return loading();
|
|
131
|
+
},
|
|
132
|
+
onQueryInput: (value) => {
|
|
133
|
+
setQuery(value);
|
|
134
|
+
if (options.onQuery === undefined) return;
|
|
135
|
+
if (value.trim() === '') return drop();
|
|
136
|
+
setLoading(true);
|
|
137
|
+
ask(value);
|
|
138
|
+
},
|
|
139
|
+
onFilterKeyDown: (event) => {
|
|
140
|
+
const action = keyAction(event.key);
|
|
141
|
+
if (action === null) return;
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
if (action === 'next' || action === 'previous') {
|
|
144
|
+
setActiveId(stepActive(visible(), active(), action === 'next' ? 1 : -1));
|
|
145
|
+
} else if (action === 'run') run(active());
|
|
146
|
+
else hide();
|
|
147
|
+
},
|
|
148
|
+
onHoverItem: (id) => setActiveId(id),
|
|
149
|
+
onRunItem: (id) => run(id),
|
|
150
|
+
onClose: hide,
|
|
151
|
+
};
|
|
152
|
+
return { props, show, hide };
|
|
153
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The reader's place, read off the DOM: the first drawn row still showing at the box's top edge,
|
|
2
|
+
// and how far that edge is into it. The layout's numbers are estimates until a row is measured;
|
|
3
|
+
// the DOM is what the reader sees, so it is what a correction has to hold still.
|
|
4
|
+
|
|
5
|
+
import type { ScrollAnchor } from './virtual-list-view';
|
|
6
|
+
|
|
7
|
+
/** The drawn row the box's top edge falls in; `undefined` with no row drawn. */
|
|
8
|
+
export function readDomAnchor(
|
|
9
|
+
box: HTMLElement,
|
|
10
|
+
rows: Iterable<HTMLElement>,
|
|
11
|
+
): ScrollAnchor | undefined {
|
|
12
|
+
const edge = box.getBoundingClientRect().top;
|
|
13
|
+
let best: { readonly key: string; readonly top: number } | undefined;
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
const rect = row.getBoundingClientRect();
|
|
16
|
+
const key = row.dataset['key'];
|
|
17
|
+
if (key === undefined || rect.bottom <= edge) continue;
|
|
18
|
+
if (best === undefined || rect.top < best.top) best = { key, top: rect.top };
|
|
19
|
+
}
|
|
20
|
+
return best === undefined ? undefined : { key: best.key, within: edge - best.top };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Where row `key` sits now, relative to the box's top edge; `undefined` when it is not drawn. */
|
|
24
|
+
export function domRowOffset(
|
|
25
|
+
box: HTMLElement,
|
|
26
|
+
rows: Iterable<HTMLElement>,
|
|
27
|
+
key: string,
|
|
28
|
+
): number | undefined {
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
if (row.dataset['key'] === key) {
|
|
31
|
+
return row.getBoundingClientRect().top - box.getBoundingClientRect().top;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The one `ResizeObserver` a virtual list holds: its box (the viewport) and every rendered row (by
|
|
2
|
+
// `data-key`), reported as numbers. Its own module so the component stays markup, and so a list
|
|
3
|
+
// that never mounts in a browser — every server render — never constructs one.
|
|
4
|
+
|
|
5
|
+
export interface VirtualListSizes {
|
|
6
|
+
/** The box's block size, when it changed. */
|
|
7
|
+
readonly viewport?: number | undefined;
|
|
8
|
+
/** Rows whose measured block size changed, by key. */
|
|
9
|
+
readonly rows: ReadonlyMap<string, number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface VirtualListObserver {
|
|
13
|
+
observe(element: Element): void;
|
|
14
|
+
unobserve(element: Element): void;
|
|
15
|
+
disconnect(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `undefined` where there is no `ResizeObserver`: the estimate then stands for every row. */
|
|
19
|
+
export function createVirtualListObserver(
|
|
20
|
+
box: Element,
|
|
21
|
+
report: (sizes: VirtualListSizes) => void,
|
|
22
|
+
): VirtualListObserver | undefined {
|
|
23
|
+
if (typeof ResizeObserver === 'undefined') return undefined;
|
|
24
|
+
// Reported a frame later, batched: a report that re-lays the list out from inside the observer's
|
|
25
|
+
// own callback mounts rows it then has to measure in the same frame, which is the platform's
|
|
26
|
+
// "ResizeObserver loop completed with undelivered notifications" — an error event per frame.
|
|
27
|
+
let viewport: number | undefined;
|
|
28
|
+
let rows = new Map<string, number>();
|
|
29
|
+
let frame: number | undefined;
|
|
30
|
+
const flush = (): void => {
|
|
31
|
+
frame = undefined;
|
|
32
|
+
const sizes = { viewport, rows };
|
|
33
|
+
viewport = undefined;
|
|
34
|
+
rows = new Map();
|
|
35
|
+
report(sizes);
|
|
36
|
+
};
|
|
37
|
+
const observer = new ResizeObserver((entries) => {
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.target === box) {
|
|
40
|
+
viewport = (entry.target as HTMLElement).clientHeight;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const key = (entry.target as HTMLElement).dataset['key'];
|
|
44
|
+
const size = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height;
|
|
45
|
+
if (key !== undefined) rows.set(key, size);
|
|
46
|
+
}
|
|
47
|
+
frame ??= requestAnimationFrame(flush);
|
|
48
|
+
});
|
|
49
|
+
observer.observe(box);
|
|
50
|
+
return {
|
|
51
|
+
observe: (element) => observer.observe(element),
|
|
52
|
+
unobserve: (element) => observer.unobserve(element),
|
|
53
|
+
disconnect: () => {
|
|
54
|
+
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
55
|
+
observer.disconnect();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The arithmetic of a virtual list, over numbers only: where each row starts, which rows a scroll
|
|
2
|
+
// position shows, where the reader is anchored, and whether they are at the end. No DOM — the
|
|
3
|
+
// component measures and these decide, so every rule here is tested without a browser.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { UI_ERROR_CODES, UiError } from '../errors';
|
|
7
|
+
|
|
8
|
+
/** Rows rendered before and after the visible ones, so a fast scroll lands on drawn rows. */
|
|
9
|
+
export const VIRTUAL_LIST_OVERSCAN = 4;
|
|
10
|
+
/** The block size of a row nobody has measured yet, in CSS px. */
|
|
11
|
+
export const VIRTUAL_LIST_ESTIMATE = 48;
|
|
12
|
+
/** Within this many px of the end the reader IS at the end... */
|
|
13
|
+
export const AT_END_ENTER_PX = 4;
|
|
14
|
+
/** ...and stays there until scrolled further than this: a measured row cannot unpin them. */
|
|
15
|
+
export const AT_END_LEAVE_PX = 48;
|
|
16
|
+
|
|
17
|
+
export interface VisibleRange {
|
|
18
|
+
readonly start: number;
|
|
19
|
+
/** Exclusive. */
|
|
20
|
+
readonly end: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Where the reader is: the first row on screen, and how far into it the box is scrolled. */
|
|
24
|
+
export interface ScrollAnchor {
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly within: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Two rows under one key cannot both keep a node. The cause names the key. */
|
|
30
|
+
export function assertUniqueKeys(keys: readonly string[]): void {
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
if (seen.has(key)) {
|
|
34
|
+
throw new UiError({
|
|
35
|
+
code: UI_ERROR_CODES.invalidValue,
|
|
36
|
+
cause: `<VirtualList> itemKey answered ${renderCauseValue(key)} for two items, so one row node would have to be both`,
|
|
37
|
+
fix: 'itemKey: (item) => item.id # on <VirtualList>: the primary key, unique within items',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
seen.add(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Each row's start, plus the total as the last entry: `offsets[i]` is row `i`'s top. */
|
|
45
|
+
export function rowOffsets(heights: readonly number[]): readonly number[] {
|
|
46
|
+
const offsets = [0];
|
|
47
|
+
let at = 0;
|
|
48
|
+
for (const height of heights) {
|
|
49
|
+
at += height;
|
|
50
|
+
offsets.push(at);
|
|
51
|
+
}
|
|
52
|
+
return offsets;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The last row whose top is at or above `position`. */
|
|
56
|
+
function rowAt(offsets: readonly number[], position: number): number {
|
|
57
|
+
const rows = offsets.length - 1;
|
|
58
|
+
let low = 0;
|
|
59
|
+
let high = rows - 1;
|
|
60
|
+
while (low < high) {
|
|
61
|
+
const mid = Math.ceil((low + high) / 2);
|
|
62
|
+
if ((offsets[mid] ?? 0) <= position) low = mid;
|
|
63
|
+
else high = mid - 1;
|
|
64
|
+
}
|
|
65
|
+
return Math.max(0, low);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The rows to render. A box with no height (`viewport <= 0`) or one that holds the whole list is
|
|
70
|
+
* not bounding anything, so it renders EVERY row — never nothing, and never a window of a page
|
|
71
|
+
* that scrolls itself.
|
|
72
|
+
*/
|
|
73
|
+
export function visibleRange(
|
|
74
|
+
offsets: readonly number[],
|
|
75
|
+
scrollTop: number,
|
|
76
|
+
viewport: number,
|
|
77
|
+
overscan: number,
|
|
78
|
+
): VisibleRange {
|
|
79
|
+
const rows = offsets.length - 1;
|
|
80
|
+
const total = offsets[rows] ?? 0;
|
|
81
|
+
if (rows <= 0 || viewport <= 0 || viewport >= total) return { start: 0, end: Math.max(0, rows) };
|
|
82
|
+
const top = Math.min(Math.max(0, scrollTop), total);
|
|
83
|
+
const first = rowAt(offsets, top);
|
|
84
|
+
const last = rowAt(offsets, Math.max(top, top + viewport - 1));
|
|
85
|
+
return {
|
|
86
|
+
start: Math.max(0, first - overscan),
|
|
87
|
+
end: Math.min(rows, last + 1 + overscan),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The reader's place, as a key rather than a pixel: pixels move when a row above changes. */
|
|
92
|
+
export function anchorAt(
|
|
93
|
+
keys: readonly string[],
|
|
94
|
+
offsets: readonly number[],
|
|
95
|
+
scrollTop: number,
|
|
96
|
+
): ScrollAnchor | undefined {
|
|
97
|
+
if (keys.length === 0) return undefined;
|
|
98
|
+
const index = rowAt(offsets, Math.max(0, scrollTop));
|
|
99
|
+
const key = keys[index];
|
|
100
|
+
return key === undefined ? undefined : { key, within: scrollTop - (offsets[index] ?? 0) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Where to scroll so the anchored row sits exactly where it sat: rows prepended, or measured
|
|
105
|
+
* taller above it, move it down by their height and the box follows. `undefined` when the row is
|
|
106
|
+
* gone — then there is no place to keep.
|
|
107
|
+
*/
|
|
108
|
+
export function anchoredScrollTop(
|
|
109
|
+
anchor: ScrollAnchor,
|
|
110
|
+
keys: readonly string[],
|
|
111
|
+
offsets: readonly number[],
|
|
112
|
+
): number | undefined {
|
|
113
|
+
const index = keys.indexOf(anchor.key);
|
|
114
|
+
return index === -1 ? undefined : (offsets[index] ?? 0) + anchor.within;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* How far to scroll so the anchored row is back where the reader saw it: `offsetNow` is the row's
|
|
119
|
+
* top relative to the box's edge after the change, and the anchor holds the edge's depth into it
|
|
120
|
+
* before. Positive scrolls down — a row pushed down by a prepend comes back up.
|
|
121
|
+
*/
|
|
122
|
+
export function anchorCorrection(anchor: ScrollAnchor, offsetNow: number): number {
|
|
123
|
+
return offsetNow + anchor.within;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** How far the box's bottom edge is from the list's. */
|
|
127
|
+
export function endDistance(scrollTop: number, viewport: number, total: number): number {
|
|
128
|
+
return Math.max(0, total - scrollTop - viewport);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** At the end, with hysteresis: entered close to it, left only well away from it. */
|
|
132
|
+
export function nextAtEnd(wasAtEnd: boolean, distance: number): boolean {
|
|
133
|
+
return wasAtEnd ? distance <= AT_END_LEAVE_PX : distance <= AT_END_ENTER_PX;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Drops every measured height whose key left the list. A following log mounts and measures each
|
|
138
|
+
* arriving line, so an unpruned map would hold one entry per line ever shown, for the island's life.
|
|
139
|
+
*/
|
|
140
|
+
export function pruneHeights(
|
|
141
|
+
heights: Map<string, number>,
|
|
142
|
+
keep: ReadonlyMap<string, unknown>,
|
|
143
|
+
): void {
|
|
144
|
+
for (const key of heights.keys()) if (!keep.has(key)) heights.delete(key);
|
|
145
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Terminal output to styled runs, line by line. SGR 30–37 and 90–97 map to semantic roles; 256 and
|
|
2
|
+
// truecolor fall to the default foreground — no raw colour ever leaves here. Every other escape
|
|
3
|
+
// (cursor moves, OSC titles, private modes) and every other control byte is dropped, never printed.
|
|
4
|
+
|
|
5
|
+
/** The roles a run's colour can name; `TerminalView.module.scss` styles each. */
|
|
6
|
+
export type AnsiRole =
|
|
7
|
+
| 'muted'
|
|
8
|
+
| 'danger'
|
|
9
|
+
| 'success'
|
|
10
|
+
| 'warning'
|
|
11
|
+
| 'info'
|
|
12
|
+
| 'chart-5'
|
|
13
|
+
| 'chart-1'
|
|
14
|
+
| 'strong';
|
|
15
|
+
|
|
16
|
+
export interface AnsiStyle {
|
|
17
|
+
readonly fg?: AnsiRole | undefined;
|
|
18
|
+
readonly bold?: boolean | undefined;
|
|
19
|
+
readonly dim?: boolean | undefined;
|
|
20
|
+
readonly italic?: boolean | undefined;
|
|
21
|
+
readonly underline?: boolean | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AnsiRun extends AnsiStyle {
|
|
25
|
+
readonly text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AnsiLine = readonly AnsiRun[];
|
|
29
|
+
|
|
30
|
+
/** Black, red, green, yellow, blue, magenta, cyan, white — normal and bright alike. */
|
|
31
|
+
const PALETTE: readonly (AnsiRole | undefined)[] = [
|
|
32
|
+
'muted',
|
|
33
|
+
'danger',
|
|
34
|
+
'success',
|
|
35
|
+
'warning',
|
|
36
|
+
'info',
|
|
37
|
+
'chart-5',
|
|
38
|
+
'chart-1',
|
|
39
|
+
'strong',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const ESC = '\u001b';
|
|
43
|
+
/** CSI: parameters, intermediates, one final byte. */
|
|
44
|
+
const CSI = /\[([0-?]*)[ -/]*([@-~])/y;
|
|
45
|
+
|
|
46
|
+
interface Pen {
|
|
47
|
+
fg?: AnsiRole | undefined;
|
|
48
|
+
bold?: boolean | undefined;
|
|
49
|
+
dim?: boolean | undefined;
|
|
50
|
+
italic?: boolean | undefined;
|
|
51
|
+
underline?: boolean | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One SGR sequence applied to the pen. Unknown codes change nothing. */
|
|
55
|
+
function applySgr(pen: Pen, params: string): void {
|
|
56
|
+
const codes =
|
|
57
|
+
params === '' ? [0] : params.split(/[;:]/).map((part) => (part === '' ? 0 : Number(part)));
|
|
58
|
+
for (let i = 0; i < codes.length; i += 1) {
|
|
59
|
+
const code = codes[i] ?? 0;
|
|
60
|
+
if (code === 0) {
|
|
61
|
+
pen.fg = undefined;
|
|
62
|
+
pen.bold = pen.dim = pen.italic = pen.underline = undefined;
|
|
63
|
+
} else if (code === 1) pen.bold = true;
|
|
64
|
+
else if (code === 2) pen.dim = true;
|
|
65
|
+
else if (code === 3) pen.italic = true;
|
|
66
|
+
else if (code === 4) pen.underline = true;
|
|
67
|
+
else if (code === 22) pen.bold = pen.dim = undefined;
|
|
68
|
+
else if (code === 23) pen.italic = undefined;
|
|
69
|
+
else if (code === 24) pen.underline = undefined;
|
|
70
|
+
else if (code >= 30 && code <= 37) pen.fg = PALETTE[code - 30];
|
|
71
|
+
else if (code >= 90 && code <= 97) pen.fg = PALETTE[code - 90];
|
|
72
|
+
else if (code === 39) pen.fg = undefined;
|
|
73
|
+
else if (code === 38 || code === 48) {
|
|
74
|
+
// 256-colour and truecolor have no semantic role: the default foreground, and skip the args.
|
|
75
|
+
if (code === 38) pen.fg = undefined;
|
|
76
|
+
i += codes[i + 1] === 5 ? 2 : codes[i + 1] === 2 ? 4 : 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isPrintable = (code: number): boolean =>
|
|
82
|
+
code === 9 || (code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f));
|
|
83
|
+
|
|
84
|
+
/** The lines of `text`, each a list of styled runs. Joined, the runs hold no control byte. */
|
|
85
|
+
export function parseAnsi(text: string): AnsiLine[] {
|
|
86
|
+
const lines: AnsiLine[] = [];
|
|
87
|
+
let line: AnsiRun[] = [];
|
|
88
|
+
let pen: Pen = {};
|
|
89
|
+
let buf = '';
|
|
90
|
+
const flush = (): void => {
|
|
91
|
+
if (buf === '') return;
|
|
92
|
+
const run: AnsiRun = { text: buf, ...pen };
|
|
93
|
+
line.push(
|
|
94
|
+
Object.fromEntries(
|
|
95
|
+
Object.entries(run).filter(([, value]) => value !== undefined),
|
|
96
|
+
) as unknown as AnsiRun,
|
|
97
|
+
);
|
|
98
|
+
buf = '';
|
|
99
|
+
};
|
|
100
|
+
for (let i = 0; i < text.length; ) {
|
|
101
|
+
const char = text[i] ?? '';
|
|
102
|
+
if (char === ESC) {
|
|
103
|
+
flush();
|
|
104
|
+
const next = text[i + 1];
|
|
105
|
+
if (next === '[') {
|
|
106
|
+
CSI.lastIndex = i + 1;
|
|
107
|
+
const csi = CSI.exec(text);
|
|
108
|
+
if (csi === null) {
|
|
109
|
+
i += 2;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (csi[2] === 'm') {
|
|
113
|
+
pen = { ...pen };
|
|
114
|
+
applySgr(pen, csi[1] ?? '');
|
|
115
|
+
}
|
|
116
|
+
i += 1 + csi[0].length;
|
|
117
|
+
} else if (next === ']' || next === 'P' || next === '_' || next === '^') {
|
|
118
|
+
// OSC / DCS / APC / PM: a string ended by BEL or ST, swallowed whole.
|
|
119
|
+
let j = i + 2;
|
|
120
|
+
while (
|
|
121
|
+
j < text.length &&
|
|
122
|
+
text[j] !== '\u0007' &&
|
|
123
|
+
!(text[j] === ESC && text[j + 1] === '\\')
|
|
124
|
+
)
|
|
125
|
+
j += 1;
|
|
126
|
+
i = text[j] === '\u0007' ? j + 1 : j + 2;
|
|
127
|
+
} else i += next === undefined ? 1 : 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (char === '\n') {
|
|
131
|
+
flush();
|
|
132
|
+
lines.push(line);
|
|
133
|
+
line = [];
|
|
134
|
+
i += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const code = char.charCodeAt(0);
|
|
138
|
+
if (isPrintable(code)) buf += char;
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
flush();
|
|
142
|
+
if (line.length > 0 || lines.length === 0) lines.push(line);
|
|
143
|
+
return lines;
|
|
144
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Heading anchors as the GitHub wiki writes them, so a `#anchor` link written against the wiki
|
|
2
|
+
// scrolls on a page this package renders: lowercase, punctuation but `-` and `_` dropped, spaces to
|
|
3
|
+
// `-`, and a repeat suffixed `-1`, `-2` in document order.
|
|
4
|
+
|
|
5
|
+
import { plainText } from './markdown-inline';
|
|
6
|
+
import type { MarkdownBlock } from './markdown-tree';
|
|
7
|
+
|
|
8
|
+
/** One heading's text as a slug, before de-duplication. */
|
|
9
|
+
export function headingSlug(text: string): string {
|
|
10
|
+
return text
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, '')
|
|
14
|
+
.replace(/\s/g, '-');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Every heading in the tree — quotes and list items included — to its unique id. */
|
|
18
|
+
export function headingIds(blocks: readonly MarkdownBlock[]): Map<MarkdownBlock, string> {
|
|
19
|
+
const ids = new Map<MarkdownBlock, string>();
|
|
20
|
+
const seen = new Map<string, number>();
|
|
21
|
+
const visit = (block: MarkdownBlock): void => {
|
|
22
|
+
if (block.kind === 'heading') {
|
|
23
|
+
const base = headingSlug(plainText(block.children));
|
|
24
|
+
const count = seen.get(base) ?? 0;
|
|
25
|
+
seen.set(base, count + 1);
|
|
26
|
+
ids.set(block, count === 0 ? base : `${base}-${count}`);
|
|
27
|
+
} else if (block.kind === 'quote') block.children.forEach(visit);
|
|
28
|
+
else if (block.kind === 'list') for (const item of block.items) item.children.forEach(visit);
|
|
29
|
+
};
|
|
30
|
+
blocks.forEach(visit);
|
|
31
|
+
return ids;
|
|
32
|
+
}
|