@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.
Files changed (168) hide show
  1. package/CATALOG.md +491 -24
  2. package/CLAUDE.md +23 -16
  3. package/README.md +338 -33
  4. package/package.json +16 -5
  5. package/src/components/AppShell.module.scss +23 -7
  6. package/src/components/AppShell.tsx +9 -0
  7. package/src/components/BarChart.module.scss +26 -0
  8. package/src/components/BarChart.tsx +242 -93
  9. package/src/components/Board.module.scss +30 -0
  10. package/src/components/Board.tsx +47 -0
  11. package/src/components/BottomNav.module.scss +49 -0
  12. package/src/components/BottomNav.tsx +57 -0
  13. package/src/components/Calendar.module.scss +118 -0
  14. package/src/components/Calendar.tsx +246 -0
  15. package/src/components/ChartFrame.module.scss +40 -3
  16. package/src/components/ChartFrame.tsx +84 -28
  17. package/src/components/CodeBlock.module.scss +101 -0
  18. package/src/components/CodeBlock.tsx +76 -0
  19. package/src/components/CommandPalette.module.scss +25 -0
  20. package/src/components/CommandPalette.tsx +50 -22
  21. package/src/components/ConfirmDialog.module.scss +36 -0
  22. package/src/components/ConfirmDialog.tsx +188 -0
  23. package/src/components/DataTable.tsx +92 -33
  24. package/src/components/DatePicker.module.scss +37 -0
  25. package/src/components/DatePicker.tsx +134 -0
  26. package/src/components/DateRangePicker.module.scss +54 -0
  27. package/src/components/DateRangePicker.tsx +156 -0
  28. package/src/components/DescriptionList.module.scss +46 -0
  29. package/src/components/DescriptionList.tsx +38 -0
  30. package/src/components/DiffView.module.scss +124 -0
  31. package/src/components/DiffView.tsx +169 -0
  32. package/src/components/DonutChart.tsx +13 -4
  33. package/src/components/Drawer.module.scss +40 -0
  34. package/src/components/Drawer.tsx +56 -0
  35. package/src/components/Gauge.module.scss +8 -0
  36. package/src/components/Gauge.tsx +40 -13
  37. package/src/components/HeatStrip.module.scss +133 -0
  38. package/src/components/HeatStrip.tsx +64 -0
  39. package/src/components/JsonView.module.scss +64 -0
  40. package/src/components/JsonView.tsx +68 -0
  41. package/src/components/LineChart.tsx +45 -18
  42. package/src/components/LiveIndicator.module.scss +44 -0
  43. package/src/components/LiveIndicator.tsx +45 -0
  44. package/src/components/LogViewer.module.scss +102 -0
  45. package/src/components/LogViewer.tsx +123 -0
  46. package/src/components/Markdown.module.scss +87 -0
  47. package/src/components/Markdown.tsx +84 -0
  48. package/src/components/Meter.module.scss +7 -0
  49. package/src/components/Meter.tsx +16 -0
  50. package/src/components/NavList.module.scss +66 -0
  51. package/src/components/NavList.tsx +95 -0
  52. package/src/components/Pagination.module.scss +6 -2
  53. package/src/components/Pagination.tsx +57 -13
  54. package/src/components/Popover.tsx +12 -2
  55. package/src/components/PullToRefresh.module.scss +41 -0
  56. package/src/components/PullToRefresh.tsx +108 -0
  57. package/src/components/Radio.module.scss +51 -0
  58. package/src/components/Radio.tsx +12 -1
  59. package/src/components/SearchableSelect.module.scss +216 -0
  60. package/src/components/SearchableSelect.tsx +331 -0
  61. package/src/components/SearchableSelectChips.tsx +41 -0
  62. package/src/components/SearchableSelectNative.tsx +89 -0
  63. package/src/components/ShortcutSheet.module.scss +33 -0
  64. package/src/components/ShortcutSheet.tsx +67 -0
  65. package/src/components/Slider.module.scss +50 -0
  66. package/src/components/Slider.tsx +68 -0
  67. package/src/components/SortableList.module.scss +72 -0
  68. package/src/components/SortableList.tsx +42 -0
  69. package/src/components/Sparkline.module.scss +10 -2
  70. package/src/components/Sparkline.tsx +27 -10
  71. package/src/components/SplitPane.module.scss +39 -0
  72. package/src/components/SplitPane.tsx +96 -0
  73. package/src/components/StatTile.module.scss +29 -3
  74. package/src/components/StatTile.tsx +75 -16
  75. package/src/components/Stepper.module.scss +78 -0
  76. package/src/components/Stepper.tsx +73 -0
  77. package/src/components/Tabs.module.scss +13 -1
  78. package/src/components/Tabs.tsx +77 -1
  79. package/src/components/TerminalView.module.scss +68 -0
  80. package/src/components/TerminalView.tsx +97 -0
  81. package/src/components/TimeRangePicker.module.scss +85 -0
  82. package/src/components/TimeRangePicker.tsx +91 -0
  83. package/src/components/Timeline.module.scss +74 -0
  84. package/src/components/Timeline.tsx +60 -0
  85. package/src/components/Tree.module.scss +98 -0
  86. package/src/components/Tree.tsx +185 -0
  87. package/src/components/VirtualList.module.scss +60 -0
  88. package/src/components/VirtualList.tsx +251 -0
  89. package/src/components/app-shell-view.ts +4 -1
  90. package/src/components/bar-chart-view.ts +121 -39
  91. package/src/components/board-view.ts +25 -0
  92. package/src/components/bottom-nav-view.ts +18 -0
  93. package/src/components/calendar-view.ts +226 -0
  94. package/src/components/chart-checks.ts +59 -0
  95. package/src/components/chart-frame-view.ts +33 -2
  96. package/src/components/chart-reference-view.ts +41 -0
  97. package/src/components/command-palette-view.ts +47 -6
  98. package/src/components/confirm-dialog-view.ts +47 -0
  99. package/src/components/data-table-rows.ts +93 -0
  100. package/src/components/date-picker-view.ts +38 -0
  101. package/src/components/date-range-view.ts +46 -0
  102. package/src/components/diff-view-view.ts +85 -0
  103. package/src/components/drawer-gesture-view.ts +33 -0
  104. package/src/components/gauge-view.ts +29 -0
  105. package/src/components/heat-strip-view.ts +68 -0
  106. package/src/components/json-view-view.ts +54 -0
  107. package/src/components/line-chart-view.ts +87 -25
  108. package/src/components/log-viewer-view.ts +51 -0
  109. package/src/components/markdown-nodes.tsx +150 -0
  110. package/src/components/nav-list-view.ts +37 -0
  111. package/src/components/pull-refresh-view.ts +93 -0
  112. package/src/components/rank-options.ts +61 -0
  113. package/src/components/searchable-select-keys.ts +118 -0
  114. package/src/components/searchable-select-view.ts +187 -0
  115. package/src/components/slider-view.ts +47 -0
  116. package/src/components/sort-pointer.ts +141 -0
  117. package/src/components/sort-surface.tsx +231 -0
  118. package/src/components/sparkline-view.ts +74 -14
  119. package/src/components/split-pane-view.ts +69 -0
  120. package/src/components/stat-delta.ts +54 -11
  121. package/src/components/stepper-view.ts +38 -0
  122. package/src/components/thunk.ts +9 -0
  123. package/src/components/timeline-view.ts +29 -0
  124. package/src/components/tree-node.tsx +133 -0
  125. package/src/components/tree-view.ts +185 -0
  126. package/src/components/use-command-palette.ts +153 -0
  127. package/src/components/virtual-list-anchor.ts +35 -0
  128. package/src/components/virtual-list-observer.ts +58 -0
  129. package/src/components/virtual-list-view.ts +145 -0
  130. package/src/content/ansi.ts +144 -0
  131. package/src/content/heading-slug.ts +32 -0
  132. package/src/content/highlight.ts +129 -0
  133. package/src/content/languages/all.ts +21 -0
  134. package/src/content/languages/diff.ts +14 -0
  135. package/src/content/languages/html.ts +16 -0
  136. package/src/content/languages/json.ts +15 -0
  137. package/src/content/languages/shell.ts +20 -0
  138. package/src/content/languages/sql.ts +27 -0
  139. package/src/content/languages/ts.ts +26 -0
  140. package/src/content/languages/yaml.ts +18 -0
  141. package/src/content/line-diff.ts +191 -0
  142. package/src/content/markdown-inline.ts +368 -0
  143. package/src/content/markdown-parse.ts +267 -0
  144. package/src/content/markdown-tree.ts +58 -0
  145. package/src/content/unified-patch.ts +53 -0
  146. package/src/drag/pointer-drag.ts +185 -0
  147. package/src/drag/sort-controller.ts +196 -0
  148. package/src/drag/sort-view.ts +201 -0
  149. package/src/errors.ts +1 -1
  150. package/src/form/field-path.ts +7 -33
  151. package/src/form/form-binding.ts +5 -8
  152. package/src/form/form-issue.ts +30 -36
  153. package/src/form/form-values.ts +3 -2
  154. package/src/hotkeys-view.ts +150 -0
  155. package/src/i18n-keys.ts +7 -50
  156. package/src/index.ts +214 -15
  157. package/src/list-url.ts +123 -0
  158. package/src/live-window.ts +23 -0
  159. package/src/roving.ts +2 -0
  160. package/src/theme/inert-runtime.ts +4 -0
  161. package/src/theme/runtime-slot.ts +2 -2
  162. package/src/theme/solid-adapter.ts +11 -5
  163. package/src/time-range-url.ts +200 -0
  164. package/src/tokens/_mixins.scss +1 -1
  165. package/src/tokens/_touch.scss +30 -1
  166. package/src/tokens/contrast-pairs.ts +12 -0
  167. package/src/ui-keys.ts +126 -0
  168. package/src/use-hotkeys.ts +145 -0
@@ -1,6 +1,7 @@
1
1
  // The geometry of a LineChart / AreaChart, apart from its markup: the nice value scale over every
2
- // series, each point's position, the line through each unbroken run and the area under it. Pure,
3
- // so a server render and an island draw one chart; `null` is a gap, never a dive to zero.
2
+ // series, each point's position — at its true x when the caller gives one — the line through each
3
+ // unbroken run and the area under it, or over the series below when stacked. Pure, so a server
4
+ // render and an island draw one chart; `null` is a gap, never a dive to zero.
4
5
 
5
6
  import type { ChartSeries } from './chart-frame-view';
6
7
  import { finiteOrNull } from './chart-frame-view';
@@ -54,6 +55,15 @@ export interface LineLayoutInput {
54
55
  /** Start the axis at zero (the honest default for counts) or fit it to the data. */
55
56
  readonly zero: boolean;
56
57
  readonly maxTicks: number;
58
+ /**
59
+ * True x positions, one per key, ascending — epoch ms for a time axis. Absent, keys are evenly
60
+ * spaced: six hours and one minute get one width, which is right only for regular samples.
61
+ */
62
+ readonly at?: readonly number[] | undefined;
63
+ /** Values the axis must reach — a reference line's. */
64
+ readonly also?: readonly number[] | undefined;
65
+ /** Each series sits on the ones before it; a gap counts as 0 underneath and draws nothing. */
66
+ readonly stacked?: boolean | undefined;
57
67
  }
58
68
 
59
69
  const round = (value: number): number => Math.round(value * 10) / 10;
@@ -63,27 +73,64 @@ const round = (value: number): number => Math.round(value * 10) / 10;
63
73
  * more thing a caller forgets, and the data already says it. One fraction anywhere turns it off.
64
74
  */
65
75
  function allIntegers(input: LineLayoutInput): boolean {
66
- return input.series.every((series) =>
67
- input.keys.every((_, i) => {
68
- const value = finiteOrNull(series.values[i]);
69
- return value === null || Number.isInteger(value);
70
- }),
76
+ return (
77
+ input.series.every((series) =>
78
+ input.keys.every((_, i) => {
79
+ const value = finiteOrNull(series.values[i]);
80
+ return value === null || Number.isInteger(value);
81
+ }),
82
+ ) && (input.also ?? []).every((value) => Number.isInteger(value))
71
83
  );
72
84
  }
73
85
 
74
- function extent(input: LineLayoutInput): readonly [number, number] {
86
+ /** `[series][key]`: what each drawn value sits on — 0 unstacked, the sum below it when stacked. */
87
+ function basesOf(input: LineLayoutInput): readonly (readonly number[])[] {
88
+ const running = input.keys.map(() => 0);
89
+ return input.series.map((one) => {
90
+ const base = [...running];
91
+ if (input.stacked === true) {
92
+ input.keys.forEach((_, i) => {
93
+ running[i] = (running[i] ?? 0) + (finiteOrNull(one.values[i]) ?? 0);
94
+ });
95
+ }
96
+ return base;
97
+ });
98
+ }
99
+
100
+ function extent(
101
+ input: LineLayoutInput,
102
+ bases: readonly (readonly number[])[],
103
+ ): readonly [number, number] {
75
104
  let lo = Number.POSITIVE_INFINITY;
76
105
  let hi = Number.NEGATIVE_INFINITY;
77
- for (const series of input.series) {
106
+ const seen = (value: number): void => {
107
+ lo = Math.min(lo, value);
108
+ hi = Math.max(hi, value);
109
+ };
110
+ input.series.forEach((series, s) => {
78
111
  for (let i = 0; i < input.keys.length; i += 1) {
79
112
  const value = finiteOrNull(series.values[i]);
80
- if (value === null) continue;
81
- lo = Math.min(lo, value);
82
- hi = Math.max(hi, value);
113
+ if (value !== null) seen(value + (bases[s]?.[i] ?? 0));
83
114
  }
84
- }
115
+ });
116
+ for (const value of input.also ?? []) if (Number.isFinite(value)) seen(value);
85
117
  if (lo > hi) return [0, 1];
86
- return input.zero ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi];
118
+ return input.zero || input.stacked === true ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi];
119
+ }
120
+
121
+ /** Each key's x: its share of the `at` span when given, its index's share otherwise. */
122
+ function xsOf(input: LineLayoutInput): readonly number[] {
123
+ const { width, pad } = LINE_CHART;
124
+ const count = input.keys.length;
125
+ if (count <= 1) return input.keys.map(() => width / 2);
126
+ const at = input.at;
127
+ if (at !== undefined && at.length === count) {
128
+ const first = at[0] as number;
129
+ const span = (at[count - 1] as number) - first;
130
+ if (span > 0)
131
+ return at.map((value) => round(pad + ((value - first) / span) * (width - pad * 2)));
132
+ }
133
+ return input.keys.map((_, index) => round(pad + (index * (width - pad * 2)) / (count - 1)));
87
134
  }
88
135
 
89
136
  function runsOf(points: readonly LinePoint[]): LinePoint[][] {
@@ -99,26 +146,34 @@ function runsOf(points: readonly LinePoint[]): LinePoint[][] {
99
146
 
100
147
  const at = (point: LinePoint): string => `${point.x},${point.y}`;
101
148
 
102
- export function lineLayout(input: LineLayoutInput): LineLayout {
103
- const [lo, hi] = extent(input);
104
- const scale = niceTicks(lo, hi, input.maxTicks, { integer: allIntegers(input) });
105
- const { width, height, pad } = LINE_CHART;
149
+ /** The y of `value` on `scale` — every point, tick and reference line reads this one rule. */
150
+ export function lineY(scale: NiceScale, value: number): number {
151
+ const { height, pad } = LINE_CHART;
106
152
  // Halved on both sides, which leaves the ratio exact (a power-of-two scale commutes with
107
153
  // rounding): `max - min` over the full float64 range overflows to Infinity, and the bottom
108
154
  // tick's `Infinity / Infinity` drew `y="NaN"`.
109
155
  const half = scale.max / 2 - scale.min / 2;
110
- const yOf = (value: number): number =>
111
- round(pad + ((scale.max / 2 - value / 2) / half) * (height - pad * 2));
156
+ return round(pad + ((scale.max / 2 - value / 2) / half) * (height - pad * 2));
157
+ }
158
+
159
+ export function lineLayout(input: LineLayoutInput): LineLayout {
160
+ const bases = basesOf(input);
161
+ const [lo, hi] = extent(input, bases);
162
+ const scale = niceTicks(lo, hi, input.maxTicks, { integer: allIntegers(input) });
163
+ const { width } = LINE_CHART;
164
+ const yOf = (value: number): number => lineY(scale, value);
112
165
  const count = input.keys.length;
113
- const xOf = (index: number): number =>
114
- count <= 1 ? width / 2 : round(pad + (index * (width - pad * 2)) / (count - 1));
166
+ const xs = xsOf(input);
167
+ const xOf = (index: number): number => xs[index] ?? width / 2;
115
168
  const baselineY = yOf(Math.min(scale.max, Math.max(scale.min, 0)));
116
169
 
117
- const series = input.series.map((one): LineSeriesGeometry => {
170
+ const series = input.series.map((one, s): LineSeriesGeometry => {
118
171
  const points: LinePoint[] = [];
172
+ const base = bases[s] ?? [];
119
173
  for (let index = 0; index < count; index += 1) {
120
174
  const value = finiteOrNull(one.values[index]);
121
- if (value !== null) points.push({ index, x: xOf(index), y: yOf(value), value });
175
+ if (value === null) continue;
176
+ points.push({ index, x: xOf(index), y: yOf(value + (base[index] ?? 0)), value });
122
177
  }
123
178
  const runs = runsOf(points);
124
179
  const line = runs
@@ -130,7 +185,14 @@ export function lineLayout(input: LineLayoutInput): LineLayout {
130
185
  const first = run[0] as LinePoint;
131
186
  const last = run.at(-1) as LinePoint;
132
187
  const top = run.map((point) => `L${at(point)}`).join(' ');
133
- return `M${first.x},${baselineY} ${top} L${last.x},${baselineY} Z`;
188
+ if (input.stacked !== true)
189
+ return `M${first.x},${baselineY} ${top} L${last.x},${baselineY} Z`;
190
+ // Stacked: closed back along the series below, not down to zero — a band, not a hill.
191
+ const under = [...run]
192
+ .reverse()
193
+ .map((point) => `L${point.x},${yOf(base[point.index] ?? 0)}`)
194
+ .join(' ');
195
+ return `M${first.x},${yOf(base[first.index] ?? 0)} ${top} ${under} Z`;
134
196
  })
135
197
  .join(' ');
136
198
  return { points, line, area };
@@ -0,0 +1,51 @@
1
+ // What a log viewer shows: the lines at or above a level, matching a filter, and only the newest
2
+ // `cap` of them — a live log trims from the top as it grows, so a long run never holds every line.
3
+
4
+ import { finiteCount } from '@ultimat3/core';
5
+
6
+ export const LOG_VIEWER_LEVELS = ['debug', 'info', 'warn', 'error'] as const;
7
+ export type LogViewerLevel = (typeof LOG_VIEWER_LEVELS)[number];
8
+
9
+ /** The most lines a viewer keeps by default; older ones are dropped and counted. */
10
+ export const LOG_VIEWER_CAP = 10_000;
11
+
12
+ export interface LogLine {
13
+ /** Unique and stable: the row keeps its node under it as lines arrive. */
14
+ readonly id: string;
15
+ readonly text: string;
16
+ readonly level?: LogViewerLevel | undefined;
17
+ readonly at?: Date | string | number | undefined;
18
+ /** A line that names something elsewhere links to it. */
19
+ readonly href?: string | undefined;
20
+ }
21
+
22
+ export interface LogWindow {
23
+ readonly lines: readonly LogLine[];
24
+ /** Matching lines left out above the window by the cap. */
25
+ readonly dropped: number;
26
+ }
27
+
28
+ /**
29
+ * `minimum` keeps that level and every level above it; a line with no level passes any minimum.
30
+ * `query` matches case-insensitively in `locale`. The newest `cap` matches are kept.
31
+ */
32
+ export function logWindow(
33
+ lines: readonly LogLine[],
34
+ options: {
35
+ readonly minimum?: LogViewerLevel | undefined;
36
+ readonly query?: string | undefined;
37
+ readonly cap?: number | undefined;
38
+ readonly locale: string;
39
+ },
40
+ ): LogWindow {
41
+ const cap = finiteCount('LogViewer', 'cap', options.cap ?? LOG_VIEWER_CAP, 1);
42
+ const floor = options.minimum === undefined ? 0 : LOG_VIEWER_LEVELS.indexOf(options.minimum);
43
+ const needle = (options.query ?? '').trim().toLocaleLowerCase(options.locale);
44
+ const matching = lines.filter(
45
+ (line) =>
46
+ (line.level === undefined || LOG_VIEWER_LEVELS.indexOf(line.level) >= floor) &&
47
+ (needle === '' || line.text.toLocaleLowerCase(options.locale).includes(needle)),
48
+ );
49
+ const dropped = Math.max(0, matching.length - cap);
50
+ return { lines: dropped === 0 ? matching : matching.slice(dropped), dropped };
51
+ }
@@ -0,0 +1,150 @@
1
+ // Markdown tree → elements. Every string is a JSX text node and every element is written out here,
2
+ // so a body cannot name a tag or an attribute. Fences go through `CodeBlock`: one path for code.
3
+
4
+ import type { JSX } from 'solid-js';
5
+ import type { Language } from '../content/highlight';
6
+ import type { MarkdownBlock, MarkdownInline } from '../content/markdown-tree';
7
+ import { invalidValueError } from '../errors';
8
+ import { CodeBlock } from './CodeBlock';
9
+ import type { HeadingLevel } from './heading-level';
10
+ import { headingTag } from './heading-level';
11
+ import { headingNode } from './heading-node';
12
+ import { Image } from './Image';
13
+ import { Link } from './Link';
14
+ import styles from './Markdown.module.scss';
15
+
16
+ /** What `resolveImage` answers for a source it vouches for: a box reserved, so no reflow. */
17
+ export interface ResolvedImage {
18
+ readonly src: string;
19
+ readonly width: number;
20
+ readonly height: number;
21
+ }
22
+
23
+ export interface MarkdownRender {
24
+ readonly baseLevel: number;
25
+ readonly resolveImage?: ((src: string) => ResolvedImage | null) | undefined;
26
+ readonly languages?: readonly Language[] | undefined;
27
+ /** A heading's anchor id, when the caller asked for them (`headingIds`). */
28
+ readonly ids?: ReadonlyMap<MarkdownBlock, string> | undefined;
29
+ }
30
+
31
+ const dimension = (value: unknown): value is number =>
32
+ typeof value === 'number' && Number.isFinite(value) && value > 0;
33
+
34
+ /** A remote image from model text is a tracking pixel: its alt, as a link, unless vouched for. */
35
+ function image(
36
+ node: { readonly src: string; readonly alt: string },
37
+ render: MarkdownRender,
38
+ ): JSX.Element {
39
+ const resolved = render.resolveImage?.(node.src) ?? null;
40
+ if (resolved === null) {
41
+ return <Link href={node.src}>{node.alt === '' ? node.src : node.alt}</Link>;
42
+ }
43
+ if (
44
+ typeof resolved.src !== 'string' ||
45
+ !dimension(resolved.width) ||
46
+ !dimension(resolved.height)
47
+ ) {
48
+ throw invalidValueError(
49
+ 'Markdown resolveImage',
50
+ resolved,
51
+ '{ src: string, width: number, height: number } or null',
52
+ );
53
+ }
54
+ return (
55
+ <Image src={resolved.src} alt={node.alt} width={resolved.width} height={resolved.height} />
56
+ );
57
+ }
58
+
59
+ export function inlineNodes(
60
+ nodes: readonly MarkdownInline[],
61
+ render: MarkdownRender,
62
+ ): JSX.Element[] {
63
+ return nodes.map((node) => inlineNode(node, render));
64
+ }
65
+
66
+ function inlineNode(node: MarkdownInline, render: MarkdownRender): JSX.Element {
67
+ switch (node.kind) {
68
+ case 'text':
69
+ return node.text;
70
+ case 'code':
71
+ return <code class={styles['code']}>{node.text}</code>;
72
+ case 'em':
73
+ return <em>{inlineNodes(node.children, render)}</em>;
74
+ case 'strong':
75
+ return <strong>{inlineNodes(node.children, render)}</strong>;
76
+ case 'del':
77
+ return <del>{inlineNodes(node.children, render)}</del>;
78
+ case 'link':
79
+ return <Link href={node.href}>{inlineNodes(node.children, render)}</Link>;
80
+ case 'image':
81
+ return image(node, render);
82
+ case 'break':
83
+ return <br />;
84
+ }
85
+ }
86
+
87
+ const level = (render: MarkdownRender, depth: number): HeadingLevel =>
88
+ Math.min(6, render.baseLevel + depth - 1) as HeadingLevel;
89
+
90
+ export function blockNode(block: MarkdownBlock, render: MarkdownRender): JSX.Element {
91
+ switch (block.kind) {
92
+ case 'heading':
93
+ return headingNode(
94
+ headingTag(level(render, block.level)),
95
+ { id: render.ids?.get(block), class: styles['heading'] },
96
+ () => inlineNodes(block.children, render),
97
+ );
98
+ case 'paragraph':
99
+ return <p>{inlineNodes(block.children, render)}</p>;
100
+ case 'code':
101
+ return <CodeBlock code={block.text} language={block.info} languages={render.languages} />;
102
+ case 'quote':
103
+ return (
104
+ <blockquote class={styles['quote']}>
105
+ {block.children.map((child) => blockNode(child, render))}
106
+ </blockquote>
107
+ );
108
+ case 'list': {
109
+ const items = block.items.map((item) => (
110
+ <li class={item.checked === null ? undefined : styles['task']}>
111
+ {item.checked === null ? null : <input type="checkbox" checked={item.checked} disabled />}
112
+ {/* A tight item's one paragraph is its text, not a <p>. */}
113
+ {item.children.length === 1 && item.children[0]?.kind === 'paragraph'
114
+ ? inlineNodes(item.children[0].children, render)
115
+ : item.children.map((child) => blockNode(child, render))}
116
+ </li>
117
+ ));
118
+ return block.ordered ? <ol start={block.start}>{items}</ol> : <ul>{items}</ul>;
119
+ }
120
+ case 'table':
121
+ return (
122
+ <div class={styles['table']}>
123
+ <table>
124
+ <thead>
125
+ <tr>
126
+ {block.head.map((cell, c) => (
127
+ <th scope="col" data-align={block.align[c] ?? undefined}>
128
+ {inlineNodes(cell, render)}
129
+ </th>
130
+ ))}
131
+ </tr>
132
+ </thead>
133
+ <tbody>
134
+ {block.rows.map((row) => (
135
+ <tr>
136
+ {row.map((cell, c) => (
137
+ <td data-align={block.align[c] ?? undefined}>{inlineNodes(cell, render)}</td>
138
+ ))}
139
+ </tr>
140
+ ))}
141
+ </tbody>
142
+ </table>
143
+ </div>
144
+ );
145
+ case 'rule':
146
+ return <hr />;
147
+ case 'text':
148
+ return <p class={styles['rest']}>{block.text}</p>;
149
+ }
150
+ }
@@ -0,0 +1,37 @@
1
+ // Which link in a set is the current page — the one rule `NavList` and `BottomNav` share. The
2
+ // longest href that is the current path or a whole-segment prefix of it wins, so `/runs/7` marks
3
+ // `/runs` and not `/`; exactly one item wins or none does, decided by position on a tie.
4
+
5
+ /** The path of an href, without its query or fragment and without a trailing slash. */
6
+ function pathOf(href: string): string {
7
+ const cut = href.search(/[?#]/);
8
+ const path = cut === -1 ? href : href.slice(0, cut);
9
+ return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
10
+ }
11
+
12
+ /** `prefix` is `path` itself or an ancestor of it on a `/` boundary. */
13
+ function covers(prefix: string, path: string): boolean {
14
+ if (prefix === path || prefix === '/') return true;
15
+ return path.startsWith(`${prefix}/`);
16
+ }
17
+
18
+ /**
19
+ * The index of the current item, or `-1` when no href covers `currentHref`. Two equal hrefs are a
20
+ * tie the FIRST wins — one `aria-current` per list, never two.
21
+ */
22
+ export function currentOf(
23
+ items: readonly { readonly href: string }[],
24
+ currentHref: string,
25
+ ): number {
26
+ const path = pathOf(currentHref);
27
+ let best = -1;
28
+ let bestLength = -1;
29
+ items.forEach((item, index) => {
30
+ const candidate = pathOf(item.href);
31
+ if (covers(candidate, path) && candidate.length > bestLength) {
32
+ best = index;
33
+ bestLength = candidate.length;
34
+ }
35
+ });
36
+ return best;
37
+ }
@@ -0,0 +1,93 @@
1
+ // Pull-to-refresh as numbers and states: one resistance curve, a threshold that arms the release,
2
+ // and a refresh that runs once however many pulls arrive while it does. No DOM and no clock, so
3
+ // the component only forwards touches and the tests drive the same machine.
4
+
5
+ /** The pulled distance, in px after resistance, at which a release refreshes. */
6
+ export const PULL_THRESHOLD = 64;
7
+ /** The distance the curve approaches and never reaches: the indicator's travel. */
8
+ export const PULL_MAX = 112;
9
+
10
+ /** The one curve: 1:1 at first, flattening towards `PULL_MAX`, so a long pull feels elastic. */
11
+ export function pullDistance(raw: number): number {
12
+ if (!(raw > 0) || !Number.isFinite(raw)) return 0;
13
+ return PULL_MAX * (1 - Math.exp(-raw / PULL_MAX));
14
+ }
15
+
16
+ export type PullState =
17
+ | { readonly kind: 'idle' }
18
+ | { readonly kind: 'pulling'; readonly distance: number; readonly armed: boolean }
19
+ | { readonly kind: 'refreshing' };
20
+
21
+ export type PullEvent =
22
+ /** A touch went down: only at the very top of the scroll, only a finger. */
23
+ | { readonly type: 'start'; readonly atTop: boolean; readonly touch: boolean }
24
+ /** The finger is `dy` px below where it went down. */
25
+ | { readonly type: 'move'; readonly dy: number }
26
+ | { readonly type: 'end' }
27
+ /** `onRefresh` settled, resolved or rejected alike. */
28
+ | { readonly type: 'settled' };
29
+
30
+ export const PULL_IDLE: PullState = { kind: 'idle' };
31
+
32
+ export function pullStep(state: PullState, event: PullEvent): PullState {
33
+ switch (event.type) {
34
+ case 'start':
35
+ // A second pull during a refresh does nothing; a pull mid-list is the page's scroll.
36
+ if (state.kind === 'refreshing' || !event.atTop || !event.touch) return state;
37
+ return { kind: 'pulling', distance: 0, armed: false };
38
+ case 'move': {
39
+ if (state.kind !== 'pulling') return state;
40
+ const distance = pullDistance(event.dy);
41
+ return { kind: 'pulling', distance, armed: distance >= PULL_THRESHOLD };
42
+ }
43
+ case 'end':
44
+ if (state.kind !== 'pulling') return state;
45
+ return state.armed ? { kind: 'refreshing' } : PULL_IDLE;
46
+ case 'settled':
47
+ return state.kind === 'refreshing' ? PULL_IDLE : state;
48
+ }
49
+ }
50
+
51
+ export interface PullController {
52
+ readonly state: () => PullState;
53
+ readonly send: (event: PullEvent) => void;
54
+ }
55
+
56
+ /**
57
+ * The machine with its one side effect: entering `refreshing` calls `onRefresh` once, and its
58
+ * settling — a rejection or a synchronous throw included — returns to idle. Either failure goes
59
+ * to `onError`, never swallowed: the caller's toolbar button and its error state show it.
60
+ */
61
+ export function pullController(
62
+ onRefresh: () => Promise<void>,
63
+ onChange: (state: PullState) => void,
64
+ onError: (error: unknown) => void,
65
+ ): PullController {
66
+ let state: PullState = PULL_IDLE;
67
+ const set = (next: PullState): void => {
68
+ if (Object.is(next, state)) return;
69
+ state = next;
70
+ onChange(state);
71
+ };
72
+ const send = (event: PullEvent): void => {
73
+ const before = state;
74
+ set(pullStep(state, event));
75
+ if (before.kind === 'refreshing' || state.kind !== 'refreshing') return;
76
+ // A refresh that throws before it returns a promise fails like one that rejects after, instead
77
+ // of escaping `send` and pinning the state at `refreshing` with every later pull ignored.
78
+ let refresh: Promise<void>;
79
+ try {
80
+ refresh = onRefresh();
81
+ } catch (error: unknown) {
82
+ refresh = Promise.reject(error);
83
+ }
84
+ refresh.then(
85
+ () => set(pullStep(state, { type: 'settled' })),
86
+ (error: unknown) => {
87
+ set(pullStep(state, { type: 'settled' }));
88
+ onError(error);
89
+ },
90
+ );
91
+ };
92
+ return { state: () => state, send };
93
+ }
@@ -0,0 +1,61 @@
1
+ // Which of a long, closed list of options a query names, and in what order — a leaf over
2
+ // `normalizeQuery`, so `SearchableSelect` and anything else ranking labels (`CommandPalette`) fold
3
+ // case and accents by the one rule `Combobox` already uses, and retain no listbox to get it.
4
+
5
+ import { finiteCount } from '@ultimat3/core';
6
+ import { COMBOBOX_LIMIT, normalizeQuery } from './combobox-filter';
7
+
8
+ /** The text a ranker reads: the visible label, its second line, and words the label never shows. */
9
+ export interface RankableOption {
10
+ readonly label: string;
11
+ readonly description?: string | undefined;
12
+ /** Synonyms a reader may type — "GMT" for `Europe/London`. Never rendered. */
13
+ readonly keywords?: readonly string[] | undefined;
14
+ }
15
+
16
+ export interface RankedOptions<T> {
17
+ /** The first `limit` matches, best first; a rank keeps the caller's order. */
18
+ readonly matches: readonly T[];
19
+ /** Every match before the cap — "first 25 of 140" is spoken from this. */
20
+ readonly total: number;
21
+ }
22
+
23
+ const WORD_BREAK = /[\s/_\-.,()]+/u;
24
+
25
+ /**
26
+ * 0: the label starts with the query. 1: any word of label, description or keywords does.
27
+ * 2: the query sits inside one of them. -1: no match. A label prefix outranks a word prefix
28
+ * because it is the row a reader typing the start of a name is looking at.
29
+ */
30
+ export function optionRank(option: RankableOption, needle: string): number {
31
+ const label = normalizeQuery(option.label);
32
+ if (needle === '' || label.startsWith(needle)) return 0;
33
+ const texts = [label, ...[option.description, ...(option.keywords ?? [])].map(normal)];
34
+ if (texts.some((text) => text.split(WORD_BREAK).some((word) => word.startsWith(needle)))) {
35
+ return 1;
36
+ }
37
+ return texts.some((text) => text.includes(needle)) ? 2 : -1;
38
+ }
39
+
40
+ const normal = (text: string | undefined): string =>
41
+ text === undefined ? '' : normalizeQuery(text);
42
+
43
+ /**
44
+ * `options` narrowed to `query` and ordered by `optionRank`, stable within a rank. An empty query
45
+ * is every option in the caller's order. `limit` counts rows, so it is held to a whole number.
46
+ */
47
+ export function rankOptions<T extends RankableOption>(
48
+ options: readonly T[],
49
+ query: string,
50
+ limit: number = COMBOBOX_LIMIT,
51
+ ): RankedOptions<T> {
52
+ const cap = finiteCount('rankOptions', 'limit', limit, 0);
53
+ const needle = normalizeQuery(query);
54
+ const buckets: T[][] = [[], [], []];
55
+ for (const option of options) {
56
+ const rank = optionRank(option, needle);
57
+ if (rank >= 0) buckets[rank]?.push(option);
58
+ }
59
+ const all = buckets.flat();
60
+ return { matches: all.slice(0, cap), total: all.length };
61
+ }
@@ -0,0 +1,118 @@
1
+ // What a SearchableSelect's field does on a key, a keystroke, a click and a blur — over accessors
2
+ // and setters it is handed, so the whole controller runs in a test with no DOM and no renderer.
3
+ // Focus never leaves the field: the active row is `aria-activedescendant`, never a focus move.
4
+
5
+ import type { SearchableView } from './searchable-select-view';
6
+ import { dropLast, firstActive, pickValue, stepActive } from './searchable-select-view';
7
+
8
+ export interface SearchableKeysState {
9
+ view(): SearchableView;
10
+ chosen(): readonly string[];
11
+ multiple(): boolean;
12
+ open(): boolean;
13
+ active(): number;
14
+ query(): string | null;
15
+ setOpen(open: boolean): void;
16
+ setActive(index: number): void;
17
+ setQuery(query: string | null): void;
18
+ commit(values: readonly string[]): void;
19
+ }
20
+
21
+ /** The slice of a `KeyboardEvent` the controller reads — a test builds one as a literal. */
22
+ export interface SearchableKey {
23
+ readonly key: string;
24
+ readonly altKey?: boolean;
25
+ preventDefault(): void;
26
+ }
27
+
28
+ export interface SearchableKeys {
29
+ onKeyDown(event: SearchableKey): void;
30
+ typed(text: string): void;
31
+ pickRow(index: number): void;
32
+ toggle(): void;
33
+ /** Blur and Tab: the list closes, nothing is picked, typed text is dropped. */
34
+ close(): void;
35
+ }
36
+
37
+ const LIST_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp']);
38
+
39
+ export function searchableKeys(state: SearchableKeysState): SearchableKeys {
40
+ const chosenRow = (): number => {
41
+ const rows = state.view().rows;
42
+ const index = rows.findIndex((row) => state.chosen().includes(row.option.value));
43
+ return index >= 0 && rows[index]?.option.disabled !== true ? index : firstActive(rows);
44
+ };
45
+ const openList = (): void => {
46
+ state.setOpen(true);
47
+ state.setActive(chosenRow());
48
+ };
49
+ const close = (): void => {
50
+ state.setOpen(false);
51
+ state.setActive(-1);
52
+ state.setQuery(null);
53
+ };
54
+ const pickRow = (index: number): void => {
55
+ const row = state.view().rows[index];
56
+ if (row === undefined || row.option.disabled === true) return;
57
+ state.commit(pickValue(state.chosen(), row.option.value, state.multiple()));
58
+ if (state.multiple()) {
59
+ // Clearing the query redraws the list, so the old INDEX names another option: find the
60
+ // picked one again by value, or the second Enter ticks a row the user never aimed at.
61
+ state.setQuery(null);
62
+ const rows = state.view().rows;
63
+ const again = rows.findIndex((r) => r.option.value === row.option.value);
64
+ state.setActive(again >= 0 ? again : firstActive(rows));
65
+ } else close();
66
+ };
67
+
68
+ const onKeyDown = (event: SearchableKey): void => {
69
+ const { key } = event;
70
+ if (!state.open()) {
71
+ // Home/End/PageUp/PageDown move the caret in a closed field; only the arrows open it.
72
+ if (key === 'ArrowDown' || key === 'ArrowUp') {
73
+ event.preventDefault();
74
+ openList();
75
+ } else if (key === 'Escape' && state.query() !== null) {
76
+ event.preventDefault();
77
+ close();
78
+ } else if (key === 'Backspace') removeLast();
79
+ return;
80
+ }
81
+ if (LIST_KEYS.has(key)) {
82
+ event.preventDefault();
83
+ if (key === 'ArrowUp' && event.altKey === true) {
84
+ close();
85
+ return;
86
+ }
87
+ const next = stepActive(state.view().rows, state.active(), key);
88
+ if (next !== undefined) state.setActive(next);
89
+ return;
90
+ }
91
+ if (key === 'Enter') {
92
+ // Open, Enter is a pick and never the form's submit.
93
+ event.preventDefault();
94
+ pickRow(state.active());
95
+ } else if (key === 'Escape') {
96
+ event.preventDefault();
97
+ close();
98
+ } else if (key === 'Tab') close();
99
+ else if (key === 'Backspace') removeLast();
100
+ };
101
+
102
+ const removeLast = (): void => {
103
+ if (!state.multiple() || (state.query() ?? '') !== '' || state.chosen().length === 0) return;
104
+ state.commit(dropLast(state.chosen()));
105
+ };
106
+
107
+ return {
108
+ onKeyDown,
109
+ typed: (text) => {
110
+ state.setQuery(text);
111
+ state.setOpen(true);
112
+ state.setActive(firstActive(state.view().rows));
113
+ },
114
+ pickRow,
115
+ toggle: () => (state.open() ? close() : openList()),
116
+ close,
117
+ };
118
+ }