@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,36 +1,96 @@
1
1
  // The path of a Sparkline, apart from its markup. A sparkline has no axes and no scale to read —
2
- // the shape of the trend is the entire point — so this is one `M`/`L` path through every point,
3
- // scaled into the box. Pure, so two renders draw one line.
4
-
5
- import type { ChartPoint } from './bar-chart-view';
6
- import { maxOf } from './bar-chart-view';
2
+ // the shape of the trend is the entire point — so this is one `M`/`L` path per run of known
3
+ // points, scaled into the box through zero: a loss sits below it. `null` is a gap, never a zero.
7
4
 
8
5
  /** 600 × 72: stretched to a content column, 240 × 48 came out ~220px tall — a chart, not a sparkline. */
9
6
  export const SPARKLINE = { width: 600, height: 72, pad: 4 } as const;
10
7
 
8
+ /** One value of the series; `null` is unknown — a gap in the line, not a zero. */
9
+ export interface SparklinePoint {
10
+ readonly key: string;
11
+ readonly value: number | null;
12
+ }
13
+
11
14
  export interface SparkPoint {
12
15
  readonly x: number;
13
16
  readonly y: number;
14
17
  }
15
18
 
16
- /** Every point's position in the box, oldest first; a single point sits at the start. */
17
- export function sparkPoints(points: readonly ChartPoint[]): readonly SparkPoint[] {
18
- const max = maxOf(points);
19
+ const known = (value: number | null): number | null =>
20
+ value === null || !Number.isFinite(value) ? null : value;
21
+
22
+ /** The known range, always through zero and never empty: an all-zero series sits on the floor. */
23
+ function extentKnown(points: readonly SparklinePoint[]): readonly [number, number] {
24
+ let lo = 0;
25
+ let hi = 0;
26
+ for (const point of points) {
27
+ const value = known(point.value);
28
+ if (value === null) continue;
29
+ lo = Math.min(lo, value);
30
+ hi = Math.max(hi, value);
31
+ }
32
+ return hi === lo ? [lo, lo + 1] : [lo, hi];
33
+ }
34
+
35
+ const innerHeight = SPARKLINE.height - SPARKLINE.pad * 2;
36
+
37
+ function yOf(value: number, [lo, hi]: readonly [number, number]): number {
38
+ return SPARKLINE.pad + ((hi - value) / (hi - lo)) * innerHeight;
39
+ }
40
+
41
+ /** The y of zero: the floor of an all-positive series, a line through a signed one. */
42
+ export function sparkZeroY(points: readonly SparklinePoint[]): number {
43
+ return yOf(0, extentKnown(points));
44
+ }
45
+
46
+ /**
47
+ * Every point's position in the box, oldest first and index for index; `null` where the value is
48
+ * unknown. A single point sits at the start.
49
+ */
50
+ export function sparkPoints(points: readonly SparklinePoint[]): readonly (SparkPoint | null)[] {
51
+ const extent = extentKnown(points);
19
52
  const innerWidth = SPARKLINE.width - SPARKLINE.pad * 2;
20
- const innerHeight = SPARKLINE.height - SPARKLINE.pad * 2;
21
53
  const step = points.length > 1 ? innerWidth / (points.length - 1) : 0;
22
54
  return points.map((point, index) => {
23
- const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
55
+ const value = known(point.value);
56
+ if (value === null) return null;
24
57
  return {
25
58
  x: SPARKLINE.pad + step * index,
26
- y: SPARKLINE.pad + innerHeight - (value / max) * innerHeight,
59
+ y: yOf(value, extent),
27
60
  };
28
61
  });
29
62
  }
30
63
 
31
64
  /** The `d` attribute, one decimal per coordinate — stable across renders. Empty for no points. */
32
- export function sparklinePath(points: readonly ChartPoint[]): string {
33
- return sparkPoints(points)
34
- .map((p, index) => `${index === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
65
+ export function sparklinePath(points: readonly SparklinePoint[]): string {
66
+ let fresh = true;
67
+ const parts: string[] = [];
68
+ for (const p of sparkPoints(points)) {
69
+ if (p === null) {
70
+ fresh = true;
71
+ continue;
72
+ }
73
+ parts.push(`${fresh ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`);
74
+ fresh = false;
75
+ }
76
+ return parts.join(' ');
77
+ }
78
+
79
+ /** Each run of two or more known points closed down to zero — the area's `d`; `''` for none. */
80
+ export function sparkAreaPath(points: readonly SparklinePoint[]): string {
81
+ const zero = sparkZeroY(points).toFixed(1);
82
+ const runs: SparkPoint[][] = [[]];
83
+ for (const p of sparkPoints(points)) {
84
+ if (p === null) runs.push([]);
85
+ else runs.at(-1)?.push(p);
86
+ }
87
+ return runs
88
+ .filter((run) => run.length > 1)
89
+ .map((run) => {
90
+ const top = run.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
91
+ const first = run[0] as SparkPoint;
92
+ const last = run.at(-1) as SparkPoint;
93
+ return `M${first.x.toFixed(1)},${zero} ${top} L${last.x.toFixed(1)},${zero} Z`;
94
+ })
35
95
  .join(' ');
36
96
  }
@@ -0,0 +1,69 @@
1
+ // The pure rules of a two-pane split: bounds checked once, a value clamped into them, the key a
2
+ // separator answers, and a pointer read as a percentage from the inline START — mirrored under
3
+ // `rtl`, where the start pane is on the right.
4
+
5
+ import type { Direction } from '@ultimat3/core';
6
+ import type { AxisSpan } from '../drag/sort-view';
7
+ import { invalidValueError } from '../errors';
8
+
9
+ /** What one arrow press moves the separator by, in percent. */
10
+ export const PANE_KEY_STEP = 5;
11
+
12
+ export interface PaneBounds {
13
+ readonly min: number;
14
+ readonly max: number;
15
+ }
16
+
17
+ const isPercent = (value: number): boolean => Number.isFinite(value) && value >= 0 && value <= 100;
18
+
19
+ /** `min` and `max` as given, refused when either is not a percentage or `min` is over `max`. */
20
+ export function paneBounds(min = 0, max = 100): PaneBounds {
21
+ if (!isPercent(min)) throw invalidValueError('SplitPane', min, 'a min from 0 to 100');
22
+ if (!isPercent(max)) throw invalidValueError('SplitPane', max, 'a max from 0 to 100');
23
+ if (min > max) throw invalidValueError('SplitPane', `min ${min} > max ${max}`, 'min <= max');
24
+ return { min, max };
25
+ }
26
+
27
+ /** A caller's `value` or `defaultValue`: a value outside the bounds is a bug, never clamped away. */
28
+ export function paneValue(value: number, bounds: PaneBounds): number {
29
+ if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
30
+ throw invalidValueError('SplitPane', value, `a value from ${bounds.min} to ${bounds.max}`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ export function clampPane(value: number, bounds: PaneBounds): number {
36
+ return Math.max(bounds.min, Math.min(bounds.max, value));
37
+ }
38
+
39
+ /**
40
+ * The value a key asks for, or null when the key is not the separator's. The inline arrows grow
41
+ * the START pane toward the end of the line, so under `rtl` ArrowLeft grows it.
42
+ */
43
+ export function paneKey(
44
+ key: string,
45
+ value: number,
46
+ bounds: PaneBounds,
47
+ dir: Direction,
48
+ ): number | null {
49
+ const toward = dir === 'rtl' ? -1 : 1;
50
+ if (key === 'ArrowRight') return clampPane(value + PANE_KEY_STEP * toward, bounds);
51
+ if (key === 'ArrowLeft') return clampPane(value - PANE_KEY_STEP * toward, bounds);
52
+ if (key === 'ArrowUp') return clampPane(value + PANE_KEY_STEP, bounds);
53
+ if (key === 'ArrowDown') return clampPane(value - PANE_KEY_STEP, bounds);
54
+ if (key === 'Home') return bounds.min;
55
+ if (key === 'End') return bounds.max;
56
+ return null;
57
+ }
58
+
59
+ /** The start pane's share for a pointer at `x` across the pane box, rounded to a tenth. */
60
+ export function panePercentAt(
61
+ x: number,
62
+ box: AxisSpan,
63
+ bounds: PaneBounds,
64
+ dir: Direction,
65
+ ): number {
66
+ if (box.size <= 0) return bounds.min;
67
+ const fromStart = dir === 'rtl' ? box.start + box.size - x : x - box.start;
68
+ return clampPane(Math.round((fromStart / box.size) * 1000) / 10, bounds);
69
+ }
@@ -1,25 +1,68 @@
1
- // The trend chip on a StatTile, as a pure rule: a percentage against a baseline, or nothing at
2
- // all when the baseline is zero — a percentage of nothing is a number that means nothing.
1
+ // The trend chip on a StatTile, as a pure rule: a percentage against a baseline — or the signed
2
+ // difference, from zero or under a baseline too small for a percentage to mean anything — and
3
+ // whether that direction is good, which is the caller's to say: an error rate rising is not green.
3
4
 
4
5
  export type StatTrend = 'up' | 'down' | 'flat';
5
6
 
7
+ /** Which direction is good news. `none`: neither — the arrow says which way, no colour says how. */
8
+ export type StatGoodWhen = 'up' | 'down' | 'none';
9
+
10
+ /** What colour a chip earns: `good`, `bad`, or `neutral` when no direction was called good. */
11
+ export type StatMood = 'good' | 'bad' | 'neutral';
12
+
6
13
  export interface StatDelta {
7
- /** Already formatted: "+12%", "−4%", "0%". */
14
+ /** Already formatted: "+12%", "−4%", "0%", or a difference: "+5". */
8
15
  readonly text: string;
9
16
  readonly trend: StatTrend;
10
17
  }
11
18
 
19
+ export interface DeltaOptions {
20
+ /**
21
+ * Below this baseline (absolute), the chip is the signed difference, not a percentage: 2 → 5 is
22
+ * "+3", never "+150%". A zero baseline always gives the difference.
23
+ */
24
+ readonly absoluteBelow?: number | undefined;
25
+ /** Formats a difference's magnitude. Default: up to two decimals, no grouping. */
26
+ readonly format?: ((magnitude: number) => string) | undefined;
27
+ }
28
+
29
+ /** U+2212, the real minus: it is the width of `+`, so the chip does not jump between states. */
30
+ const MINUS = '−';
31
+
32
+ const plain = (magnitude: number): string => String(Math.round(magnitude * 100) / 100);
33
+
34
+ function signed(diff: number, text: string): StatDelta {
35
+ if (diff === 0) return { text, trend: 'flat' };
36
+ return diff > 0 ? { text: `+${text}`, trend: 'up' } : { text: `${MINUS}${text}`, trend: 'down' };
37
+ }
38
+
12
39
  /**
13
- * U+2212, the real minus: it is the width of `+`, so the chip does not jump between states.
14
- * Rounded to a whole percent — a tile is a glance, not a ledger.
40
+ * The chip for `current` against `baseline`. A percentage, rounded to a whole percent — a tile is a
41
+ * glance, not a ledger — unless the baseline is zero, negative or under `absoluteBelow`, where a
42
+ * percentage is a number that means nothing and the signed difference is said instead.
15
43
  */
16
- export function deltaOf(current: number, baseline: number): StatDelta | undefined {
17
- if (!Number.isFinite(current) || !Number.isFinite(baseline) || baseline <= 0) return undefined;
44
+ export function deltaOf(
45
+ current: number,
46
+ baseline: number,
47
+ options: DeltaOptions = {},
48
+ ): StatDelta | undefined {
49
+ if (!Number.isFinite(current) || !Number.isFinite(baseline)) return undefined;
50
+ const below = options.absoluteBelow;
51
+ const absolute =
52
+ baseline <= 0 || (below !== undefined && Number.isFinite(below) && Math.abs(baseline) < below);
53
+ if (absolute) {
54
+ const diff = current - baseline;
55
+ const text = (options.format ?? plain)(Math.abs(diff));
56
+ return diff === 0 ? { text: '0', trend: 'flat' } : signed(diff, text);
57
+ }
18
58
  const pct = Math.round(((current - baseline) / baseline) * 100);
19
- if (pct === 0) return { text: '0%', trend: 'flat' };
20
- return pct > 0
21
- ? { text: `+${pct}%`, trend: 'up' }
22
- : { text: `−${Math.abs(pct)}%`, trend: 'down' };
59
+ return pct === 0 ? { text: '0%', trend: 'flat' } : signed(pct, `${Math.abs(pct)}%`);
60
+ }
61
+
62
+ /** Whether a trend is good news, by the caller's word for it. Flat is never coloured. */
63
+ export function deltaMood(trend: StatTrend, goodWhen: StatGoodWhen): StatMood {
64
+ if (trend === 'flat' || goodWhen === 'none') return 'neutral';
65
+ return trend === goodWhen ? 'good' : 'bad';
23
66
  }
24
67
 
25
68
  /** Drawn, not typed: an arrow character inherits the font's metrics and sits off-centre in a pill. */
@@ -0,0 +1,38 @@
1
+ // A stepper's rule: exactly one step is current, and the count the compact form reads ("Step 2 of
2
+ // 4") is that step's POSITION — never a number the caller restates, which drifts the day a step
3
+ // is inserted.
4
+
5
+ import { invalidValueError } from '../errors';
6
+
7
+ export type StepState = 'done' | 'current' | 'upcoming';
8
+
9
+ export interface StepperStep {
10
+ /** Stable — the id `onSelect` answers. */
11
+ readonly id: string;
12
+ /** Already translated. */
13
+ readonly label: string;
14
+ /** A passed step's page; a `done` step with one renders as a link. */
15
+ readonly href?: string | undefined;
16
+ readonly state: StepState;
17
+ }
18
+
19
+ export interface StepperModel {
20
+ /** Zero-based index of the current step. */
21
+ readonly current: number;
22
+ readonly total: number;
23
+ }
24
+
25
+ /** Where the current step is, or `X_UI_INVALID_VALUE` naming the ids when not exactly one is. */
26
+ export function stepperModel(steps: readonly StepperStep[]): StepperModel {
27
+ const current = steps.filter((step) => step.state === 'current');
28
+ if (current.length !== 1) {
29
+ throw invalidValueError(
30
+ 'Stepper',
31
+ current.length === 0
32
+ ? 'no current step'
33
+ : `current steps ${current.map((s) => s.id).join(', ')}`,
34
+ 'exactly one step with state "current"',
35
+ );
36
+ }
37
+ return { current: steps.findIndex((step) => step.state === 'current'), total: steps.length };
38
+ }
@@ -0,0 +1,9 @@
1
+ // A function child, typed. Solid's compiler makes every JSX expression one anyway; written as a
2
+ // function so a test that walks the tree after a tick (render-nodes calls thunks) reads it as it
3
+ // is NOW. Solid's `jsx.d.ts` types no function child (its `jsx-h.d.ts` does), hence the one cast.
4
+
5
+ import type { JSX } from 'solid-js';
6
+
7
+ export function thunk(fn: () => JSX.Element): JSX.Element {
8
+ return fn as unknown as JSX.Element;
9
+ }
@@ -0,0 +1,29 @@
1
+ // A timeline's day headers: consecutive items under one label, in the order given. Pure, so the
2
+ // grouping is testable with no renderer. Labels arrive formatted — the zone a "day" is read in is
3
+ // the caller's by the date rule, and this module never sees an instant.
4
+
5
+ export interface TimelineGroup<T> {
6
+ /** The header above the run; `undefined` when the caller groups nothing. */
7
+ readonly label: string | undefined;
8
+ readonly items: readonly T[];
9
+ }
10
+
11
+ /**
12
+ * Runs of items whose `groupBy` answers the same label. Consecutive, never sorted: a timeline is
13
+ * already in the order it is read, and a label seen again after another is a second header — the
14
+ * honest answer for a list the caller ordered by something other than the day.
15
+ */
16
+ export function timelineGroups<T>(
17
+ items: readonly T[],
18
+ groupBy?: ((item: T) => string) | undefined,
19
+ ): readonly TimelineGroup<T>[] {
20
+ if (groupBy === undefined) return items.length === 0 ? [] : [{ label: undefined, items }];
21
+ const groups: { label: string; items: T[] }[] = [];
22
+ for (const item of items) {
23
+ const label = groupBy(item);
24
+ const last = groups[groups.length - 1];
25
+ if (last !== undefined && last.label === label) last.items.push(item);
26
+ else groups.push({ label, items: [item] });
27
+ }
28
+ return groups;
29
+ }
@@ -0,0 +1,133 @@
1
+ // One node of a `Tree`, in either of its two renderings. Link mode is the platform's: a branch is a
2
+ // `<details>` whose `<summary>` opens it, a leaf is a link — no script at all. Select mode is the
3
+ // APG tree item, which only an island drives; its state arrives as accessors from `Tree`.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { ariaBool } from '../a11y';
7
+ import { UI_KEYS } from '../i18n-keys';
8
+ import { useUi } from '../theme/context';
9
+ import styles from './Tree.module.scss';
10
+ import type { TreeNode } from './tree-view';
11
+
12
+ export interface TreeLinkNodeProps {
13
+ node: TreeNode;
14
+ hrefFor: (node: TreeNode) => string;
15
+ selected: string | undefined;
16
+ open: ReadonlySet<string>;
17
+ }
18
+
19
+ /** Link mode: a `<details>` per branch, a link per leaf, `aria-current` on the selected one alone. */
20
+ export function TreeLinkNode(props: TreeLinkNodeProps): JSX.Element {
21
+ const children = props.node.children ?? [];
22
+ if (children.length === 0) {
23
+ return (
24
+ <li class={styles['item']}>
25
+ <a
26
+ class={styles['link']}
27
+ href={props.hrefFor(props.node)}
28
+ aria-current={props.selected === props.node.id ? 'page' : undefined}
29
+ >
30
+ {props.node.label}
31
+ </a>
32
+ </li>
33
+ );
34
+ }
35
+ return (
36
+ <li class={styles['item']}>
37
+ <details class={styles['branch']} open={props.open.has(props.node.id)}>
38
+ <summary class={styles['summary']}>{props.node.label}</summary>
39
+ <ul class={styles['group']}>
40
+ {children.map((child) => (
41
+ <TreeLinkNode
42
+ node={child}
43
+ hrefFor={props.hrefFor}
44
+ selected={props.selected}
45
+ open={props.open}
46
+ />
47
+ ))}
48
+ </ul>
49
+ </details>
50
+ </li>
51
+ );
52
+ }
53
+
54
+ export interface TreeItemState {
55
+ readonly level: number;
56
+ readonly setsize: number;
57
+ readonly posinset: number;
58
+ readonly branch: boolean;
59
+ }
60
+
61
+ export interface TreeSelectNodeProps {
62
+ node: TreeNode;
63
+ /** Level and position, read from the flattened tree. */
64
+ state: (id: string) => TreeItemState | undefined;
65
+ isOpen: (id: string) => boolean;
66
+ isLoading: (id: string) => boolean;
67
+ /** The node holding the group's one tab stop. */
68
+ isStop: (id: string) => boolean;
69
+ selected: string | undefined;
70
+ /** Loaded children: the node's own, or what `onExpand` answered for it. */
71
+ childrenOf: (node: TreeNode) => readonly TreeNode[];
72
+ onActivate: (id: string) => void;
73
+ }
74
+
75
+ /** Select mode: `role="treeitem"`, roving tab stop, `aria-expanded` only on a branch. */
76
+ export function TreeSelectNode(props: TreeSelectNodeProps): JSX.Element {
77
+ const ui = useUi();
78
+ const id = props.node.id;
79
+ const branch = (): boolean => props.state(id)?.branch === true;
80
+ return (
81
+ // biome-ignore lint/a11y/useKeyWithClickEvents: the tree's one keydown handler sits on `Tree`'s root
82
+ <li
83
+ class={styles['item']}
84
+ role="treeitem"
85
+ data-tree-id={id}
86
+ aria-level={props.state(id)?.level}
87
+ aria-setsize={props.state(id)?.setsize}
88
+ aria-posinset={props.state(id)?.posinset}
89
+ aria-expanded={branch() ? ariaBool(props.isOpen(id)) : undefined}
90
+ aria-selected={ariaBool(props.selected === id)}
91
+ aria-disabled={props.node.disabled === true ? 'true' : undefined}
92
+ aria-busy={ariaBool(props.isLoading(id) ? true : undefined)}
93
+ tabIndex={props.isStop(id) ? 0 : -1}
94
+ onClick={(event: MouseEvent) => {
95
+ // The click lands on the innermost item only: a parent hearing its child's click would
96
+ // toggle itself shut under the reader's pointer.
97
+ event.stopPropagation();
98
+ if (props.node.disabled !== true) props.onActivate(id);
99
+ }}
100
+ >
101
+ <span class={styles['row']}>
102
+ <span
103
+ class={styles['twisty']}
104
+ aria-hidden="true"
105
+ data-open={props.isOpen(id) ? '' : undefined}
106
+ >
107
+ {branch() ? '▸' : ''}
108
+ </span>
109
+ <span class={styles['label']}>{props.node.label}</span>
110
+ {props.isLoading(id) ? (
111
+ <span class={styles['loading']}>{ui.t(UI_KEYS.treeLoading)}</span>
112
+ ) : null}
113
+ </span>
114
+ {branch() && props.isOpen(id) ? (
115
+ // biome-ignore lint/a11y/useSemanticElements: APG nests a tree item's children in a `group`
116
+ <ul class={styles['group']} role="group">
117
+ {props.childrenOf(props.node).map((child) => (
118
+ <TreeSelectNode
119
+ node={child}
120
+ state={props.state}
121
+ isOpen={props.isOpen}
122
+ isLoading={props.isLoading}
123
+ isStop={props.isStop}
124
+ selected={props.selected}
125
+ childrenOf={props.childrenOf}
126
+ onActivate={props.onActivate}
127
+ />
128
+ ))}
129
+ </ul>
130
+ ) : null}
131
+ </li>
132
+ );
133
+ }
@@ -0,0 +1,185 @@
1
+ // A tree's rules over plain data: flatten with level and position, the order arrows walk (a closed
2
+ // subtree is skipped), what a key does — left and right follow `dir` — and type-ahead. Pure, so
3
+ // `Tree` is markup over these and every keyboard rule is tested without a renderer.
4
+
5
+ import { renderCauseValue } from '@ultimat3/core';
6
+ import { UI_ERROR_CODES, UiError } from '../errors';
7
+
8
+ export interface TreeNode {
9
+ id: string;
10
+ /** Already-translated. */
11
+ label: string;
12
+ children?: readonly TreeNode[] | undefined;
13
+ /** Children exist but are not loaded: an island's `onExpand(id)` fetches them. */
14
+ lazy?: boolean | undefined;
15
+ disabled?: boolean | undefined;
16
+ }
17
+
18
+ export interface FlatTreeNode {
19
+ readonly node: TreeNode;
20
+ readonly parent: string | undefined;
21
+ /** 1-based, as `aria-level` wants it. */
22
+ readonly level: number;
23
+ readonly setsize: number;
24
+ readonly posinset: number;
25
+ /** Has children, loaded or not: it can be opened. */
26
+ readonly branch: boolean;
27
+ }
28
+
29
+ function invalidTree(cause: string): UiError {
30
+ return new UiError({
31
+ code: UI_ERROR_CODES.invalidValue,
32
+ cause: `<Tree> ${cause}`,
33
+ fix: 'nodes: rows.map((row) => ({ id: row.id, label: row.name, children: [...] })) # on <Tree>: one id per node, and no node inside its own subtree',
34
+ });
35
+ }
36
+
37
+ /** Depth first, in document order. A repeated id, or a node inside its own subtree, throws. */
38
+ export function flattenTree(nodes: readonly TreeNode[]): readonly FlatTreeNode[] {
39
+ const flat: FlatTreeNode[] = [];
40
+ const seen = new Set<string>();
41
+ const walk = (
42
+ level: readonly TreeNode[],
43
+ parent: string | undefined,
44
+ depth: number,
45
+ ancestors: ReadonlySet<string>,
46
+ ): void => {
47
+ level.forEach((node, index) => {
48
+ if (ancestors.has(node.id)) {
49
+ throw invalidTree(`node ${renderCauseValue(node.id)} is its own ancestor`);
50
+ }
51
+ if (seen.has(node.id)) throw invalidTree(`id ${renderCauseValue(node.id)} repeats`);
52
+ seen.add(node.id);
53
+ const children = node.children ?? [];
54
+ flat.push({
55
+ node,
56
+ parent,
57
+ level: depth,
58
+ setsize: level.length,
59
+ posinset: index + 1,
60
+ branch: children.length > 0 || node.lazy === true,
61
+ });
62
+ walk(children, node.id, depth + 1, new Set([...ancestors, node.id]));
63
+ });
64
+ };
65
+ walk(nodes, undefined, 1, new Set());
66
+ return flat;
67
+ }
68
+
69
+ /** The ids a reader can reach: every node whose ancestors are all open. Disabled ones are skipped. */
70
+ export function visibleTreeIds(
71
+ flat: readonly FlatTreeNode[],
72
+ open: ReadonlySet<string>,
73
+ ): readonly string[] {
74
+ const shown = new Set<string>();
75
+ const ids: string[] = [];
76
+ for (const entry of flat) {
77
+ if (entry.parent !== undefined && !(open.has(entry.parent) && shown.has(entry.parent)))
78
+ continue;
79
+ shown.add(entry.node.id);
80
+ if (entry.node.disabled !== true) ids.push(entry.node.id);
81
+ }
82
+ return ids;
83
+ }
84
+
85
+ export type TreeMove =
86
+ | { readonly kind: 'focus'; readonly id: string }
87
+ | { readonly kind: 'open'; readonly id: string }
88
+ | { readonly kind: 'close'; readonly id: string };
89
+
90
+ /**
91
+ * What a key does from `current`, per the APG tree pattern. Right opens a closed branch and enters
92
+ * an open one; Left closes an open branch and climbs from anything else. Under `dir="rtl"` the
93
+ * two swap, because "inward" points the other way. `undefined` is a key the tree does not take.
94
+ */
95
+ export function treeMove(
96
+ flat: readonly FlatTreeNode[],
97
+ open: ReadonlySet<string>,
98
+ current: string,
99
+ key: string,
100
+ dir: 'ltr' | 'rtl',
101
+ ): TreeMove | undefined {
102
+ const visible = visibleTreeIds(flat, open);
103
+ const index = visible.indexOf(current);
104
+ const entry = flat.find((candidate) => candidate.node.id === current);
105
+ if (entry === undefined) return undefined;
106
+ const focus = (id: string | undefined): TreeMove | undefined =>
107
+ id === undefined || id === current ? undefined : { kind: 'focus', id };
108
+ const inward = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
109
+ const outward = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
110
+ switch (key) {
111
+ case 'ArrowDown':
112
+ return focus(visible[index + 1]);
113
+ case 'ArrowUp':
114
+ return index <= 0 ? undefined : focus(visible[index - 1]);
115
+ case 'Home':
116
+ return focus(visible[0]);
117
+ case 'End':
118
+ return focus(visible[visible.length - 1]);
119
+ case inward:
120
+ if (!entry.branch) return undefined;
121
+ if (!open.has(current)) return { kind: 'open', id: current };
122
+ return focus(
123
+ flat.find((child) => child.parent === current && child.node.disabled !== true)?.node.id,
124
+ );
125
+ case outward:
126
+ if (entry.branch && open.has(current)) return { kind: 'close', id: current };
127
+ return focus(entry.parent);
128
+ default:
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Type-ahead: the next visible node after `current` whose label starts with `typed`, wrapping.
135
+ * Compared in `locale`, never the runtime's default, so a Turkish `i` matches its own capital.
136
+ */
137
+ export function treeTypeahead(
138
+ flat: readonly FlatTreeNode[],
139
+ open: ReadonlySet<string>,
140
+ current: string,
141
+ typed: string,
142
+ locale: string,
143
+ ): string | undefined {
144
+ const needle = typed.toLocaleLowerCase(locale);
145
+ if (needle === '') return undefined;
146
+ const visible = visibleTreeIds(flat, open);
147
+ const labels = new Map(flat.map((entry) => [entry.node.id, entry.node.label]));
148
+ const start = visible.indexOf(current);
149
+ for (let step = 1; step <= visible.length; step += 1) {
150
+ const id = visible[(start + step) % visible.length];
151
+ if (id !== undefined && (labels.get(id) ?? '').toLocaleLowerCase(locale).startsWith(needle)) {
152
+ return id;
153
+ }
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ /** The ids to open so `id` is on screen: every ancestor of it. */
159
+ export function treeAncestors(
160
+ flat: readonly FlatTreeNode[],
161
+ id: string | undefined,
162
+ ): readonly string[] {
163
+ const parents = new Map(flat.map((entry) => [entry.node.id, entry.parent]));
164
+ const ancestors: string[] = [];
165
+ let at = id === undefined ? undefined : parents.get(id);
166
+ while (at !== undefined) {
167
+ ancestors.push(at);
168
+ at = parents.get(at);
169
+ }
170
+ return ancestors;
171
+ }
172
+
173
+ /** The tree with each lazily fetched node's children in place. Unchanged nodes stay the same objects. */
174
+ export function withLoadedChildren(
175
+ nodes: readonly TreeNode[],
176
+ loaded: ReadonlyMap<string, readonly TreeNode[]>,
177
+ ): readonly TreeNode[] {
178
+ if (loaded.size === 0) return nodes;
179
+ return nodes.map((node) => {
180
+ const fetched = loaded.get(node.id);
181
+ const children = fetched ?? node.children;
182
+ if (children === undefined) return node;
183
+ return { ...node, children: withLoadedChildren(children, loaded) };
184
+ });
185
+ }