@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
@@ -0,0 +1,72 @@
1
+ @use '../tokens' as t;
2
+
3
+ // Shared by `SortableList` and `Board` (`sort-surface.tsx`): one gesture, one sheet.
4
+
5
+ .root {
6
+ position: relative;
7
+ }
8
+
9
+ .list {
10
+ @include t.column(t.space(2));
11
+ list-style: none;
12
+ margin: 0;
13
+ padding: 0;
14
+ min-block-size: t.touch(target);
15
+ }
16
+
17
+ .row {
18
+ @include t.row(t.space(2), center);
19
+ @include t.transition(box-shadow);
20
+ position: relative;
21
+
22
+ &[data-lifted='true'] {
23
+ z-index: t.z(raised);
24
+ box-shadow: t.shadow(lg);
25
+ // The pointer's offset, written per move: input, not animation, so reduced motion keeps it.
26
+ transform: translate(var(--drag-x, 0), var(--drag-y, 0));
27
+ }
28
+ }
29
+
30
+ .body {
31
+ flex: 1;
32
+ min-inline-size: 0;
33
+ }
34
+
35
+ // The handle is the ONLY place a drag starts, so the row body still scrolls a phone's page.
36
+ .handle {
37
+ @include t.touch-target;
38
+ @include t.focus-ring;
39
+ display: inline-grid;
40
+ place-items: center;
41
+ border: 0;
42
+ border-radius: t.radius(md);
43
+ background: transparent;
44
+ color: t.role('fg-muted');
45
+ cursor: grab;
46
+ touch-action: none;
47
+
48
+ &[aria-pressed='true'] {
49
+ background: t.role('bg-soft');
50
+ color: t.role('accent');
51
+ cursor: grabbing;
52
+ }
53
+ }
54
+
55
+ // Without script the handle cannot do anything, so it is not there; the form is.
56
+ .root:not([data-enhanced='true']) .handle {
57
+ display: none;
58
+ }
59
+
60
+ .root[data-enhanced='true'] .fallback {
61
+ display: none;
62
+ }
63
+
64
+ .fallback {
65
+ @include t.row(t.space(1), center);
66
+ flex-wrap: wrap;
67
+
68
+ button,
69
+ select {
70
+ min-block-size: t.touch(target);
71
+ }
72
+ }
@@ -0,0 +1,42 @@
1
+ // A list a person reorders by hand: a handle per row that lifts by pointer, touch or keyboard, and
2
+ // a drop that REQUESTS a move through `onMove` — the caller's action decides. With scripting off
3
+ // each row carries Move up and Move down as real form buttons posting to `action`.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import type { CanDrop, SortMove } from '../drag/sort-view';
7
+ import { type SortFields, sortSurface } from './sort-surface';
8
+
9
+ export interface SortableListProps<T> {
10
+ items: readonly T[];
11
+ /** Unique per item; the row keeps its node under it across live updates. */
12
+ itemKey: (item: T) => string;
13
+ /** The row's look is the caller's: a `Card`, a line of text. */
14
+ renderItem: (item: T) => JSX.Element;
15
+ /** Already-translated name of the list, read with every announcement. */
16
+ label: string;
17
+ /** Called once per drop that moves something. A rejection puts the row back and says so. */
18
+ onMove: (move: SortMove) => unknown;
19
+ canDrop?: CanDrop<T> | undefined;
20
+ /** The action URL the no-script Up and Down buttons post to. Without it, no fallback renders. */
21
+ action?: string | undefined;
22
+ /** The field names those buttons post: `id`, `fromGroup`, `toGroup`, `toIndex` by default. */
23
+ fields?: SortFields | undefined;
24
+ /** The id `onMove` names as `fromGroup` and `toGroup`. Defaults to `list`. */
25
+ group?: string | undefined;
26
+ class?: string | undefined;
27
+ }
28
+
29
+ export function SortableList<T>(props: SortableListProps<T>): JSX.Element {
30
+ return sortSurface<T>({
31
+ groups: () => [{ id: props.group ?? 'list', label: props.label, items: props.items }],
32
+ itemKey: (item) => props.itemKey(item),
33
+ renderItem: (item) => props.renderItem(item),
34
+ onMove: (move) => props.onMove(move),
35
+ canDrop: props.canDrop,
36
+ action: props.action,
37
+ fields: props.fields,
38
+ layout: 'list',
39
+ label: props.label,
40
+ class: props.class,
41
+ });
42
+ }
@@ -1,5 +1,7 @@
1
1
  @use '../tokens' as t;
2
2
 
3
+ @include t.tone-classes;
4
+
3
5
  .sparkline {
4
6
  display: block;
5
7
  inline-size: 100%;
@@ -7,7 +9,7 @@
7
9
  }
8
10
 
9
11
  .line {
10
- stroke: t.role('accent');
12
+ stroke: var(--tone);
11
13
  stroke-width: 2;
12
14
  // The box scales with its column; the line must not (it rendered ~7px thick at full width).
13
15
  vector-effect: non-scaling-stroke;
@@ -15,6 +17,12 @@
15
17
  stroke-linecap: round;
16
18
  }
17
19
 
20
+ .area {
21
+ fill: var(--tone);
22
+ fill-opacity: 0.16;
23
+ stroke: none;
24
+ }
25
+
18
26
  .last {
19
- fill: t.role('accent');
27
+ fill: var(--tone);
20
28
  }
@@ -1,32 +1,49 @@
1
1
  // One series as a single line with no axes — the trend beside a figure, in a table cell, on a
2
- // detail page. Static SVG for `BarChart`'s reason: the framework's own docs use a sparkline
3
- // pulling in a chart library as the cautionary example, and this file IS that sparkline written
4
- // the way the docs say to. The path comes from `sparkline-view.ts`.
2
+ // StatTile. Static SVG for `BarChart`'s reason: the framework's own docs use a sparkline pulling
3
+ // in a chart library as the cautionary example, and this file IS that sparkline written the way
4
+ // the docs say to. Signed through zero; the path and the area come from `sparkline-view.ts`.
5
5
 
6
6
  import type { JSX } from 'solid-js';
7
7
  import { cx } from '../cx';
8
- import type { ChartPoint } from './bar-chart-view';
9
8
  import styles from './Sparkline.module.scss';
10
- import { SPARKLINE, sparklinePath, sparkPoints } from './sparkline-view';
9
+ import {
10
+ SPARKLINE,
11
+ type SparklinePoint,
12
+ sparkAreaPath,
13
+ sparklinePath,
14
+ sparkPoints,
15
+ } from './sparkline-view';
16
+ import type { Tone } from './variants';
11
17
 
12
18
  export interface SparklineProps {
13
19
  /** The accessible name for the whole chart, already translated. */
14
20
  label: string;
15
- /** Oldest first. Every point comes from exactly this array — the caller zero-fills gaps. */
16
- points: readonly ChartPoint[];
21
+ /** Oldest first. `value: null` is unknown — a gap in the line, never a zero. */
22
+ points: readonly SparklinePoint[];
23
+ /** The line's colour role, in place of `accent`. */
24
+ tone?: Tone | undefined;
25
+ /** Fill under the line, down to zero — above it for a gain, below it for a loss. */
26
+ area?: boolean | undefined;
17
27
  class?: string | undefined;
18
28
  }
19
29
 
20
30
  export function Sparkline(props: SparklineProps): JSX.Element {
21
- const last = (): ChartPoint | undefined => props.points.at(-1);
22
- const lastPoint = () => sparkPoints(props.points).at(-1);
31
+ // The newest point is marked only when it is known: an unknown "now" has no place to sit.
32
+ const last = (): SparklinePoint | undefined => {
33
+ const newest = props.points.at(-1);
34
+ return newest?.value === null ? undefined : newest;
35
+ };
36
+ const lastPoint = () => sparkPoints(props.points).at(-1) ?? undefined;
23
37
  return (
24
38
  <svg
25
39
  role="img"
26
40
  aria-label={props.label}
27
41
  viewBox={`0 0 ${SPARKLINE.width} ${SPARKLINE.height}`}
28
- class={cx(styles['sparkline'], props.class)}
42
+ class={cx(styles['sparkline'], styles[`tone-${props.tone ?? 'accent'}`], props.class)}
29
43
  >
44
+ {props.area === true ? (
45
+ <path class={styles['area']} d={sparkAreaPath(props.points)} data-area="true" />
46
+ ) : null}
30
47
  <path class={styles['line']} d={sparklinePath(props.points)} fill="none" />
31
48
  {last() === undefined ? null : (
32
49
  <circle class={styles['last']} cx={lastPoint()?.x} cy={lastPoint()?.y} r={2}>
@@ -0,0 +1,39 @@
1
+ @use '../tokens' as t;
2
+
3
+ // One column on a phone; from `md` a grid whose first track is `--split`, written by the
4
+ // component (scripting off: the `defaultValue` it rendered with).
5
+ .split {
6
+ display: grid;
7
+ grid-template-columns: minmax(0, 1fr);
8
+ gap: t.space(4);
9
+
10
+ @include t.respond-to(md) {
11
+ grid-template-columns: minmax(0, var(--split, 50%)) auto minmax(0, 1fr);
12
+ gap: 0;
13
+ }
14
+ }
15
+
16
+ .pane {
17
+ min-inline-size: 0;
18
+ overflow: auto;
19
+ }
20
+
21
+ .separator {
22
+ display: none;
23
+
24
+ @include t.respond-to(md) {
25
+ @include t.focus-ring;
26
+ display: block;
27
+ inline-size: t.space(2);
28
+ margin-inline: t.space(1);
29
+ border-radius: t.radius(pill);
30
+ background: t.role('line');
31
+ cursor: col-resize;
32
+ touch-action: none;
33
+
34
+ &:hover,
35
+ &:focus-visible {
36
+ background: t.role('accent');
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,96 @@
1
+ // Two panes and a `role="separator"` between them, its value a percentage of the start pane. Arrows
2
+ // step it, Home and End go to the bounds, a pointer drags it, Esc mid-drag restores. Below `md` the
3
+ // panes stack and the separator is gone: no gesture on a phone. Storage is the caller's.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { bindPointerDrag } from '../drag/pointer-drag';
8
+ import { UI_KEYS } from '../i18n-keys';
9
+ import { useUi } from '../theme/context';
10
+ import { solid } from '../theme/solid-adapter';
11
+ import styles from './SplitPane.module.scss';
12
+ import { paneBounds, paneKey, panePercentAt, paneValue } from './split-pane-view';
13
+
14
+ export interface SplitPaneProps {
15
+ start: JSX.Element;
16
+ end: JSX.Element;
17
+ /** Controlled: the start pane's share in percent. Pair it with `onResize`. */
18
+ value?: number | undefined;
19
+ /** Where the split sits before any input, and with scripting off. */
20
+ defaultValue: number;
21
+ min?: number | undefined;
22
+ max?: number | undefined;
23
+ /** Each change, in percent. Persisting it is the caller's: storage is never ambient. */
24
+ onResize?: ((percent: number) => void) | undefined;
25
+ /** Already-translated name of the separator; defaults to `ui.pane.resize`. */
26
+ label?: string | undefined;
27
+ class?: string | undefined;
28
+ }
29
+
30
+ export function SplitPane(props: SplitPaneProps): JSX.Element {
31
+ const ui = useUi();
32
+ const rt = solid();
33
+ const bounds = (): ReturnType<typeof paneBounds> => paneBounds(props.min, props.max);
34
+ const [own, setOwn] = rt.createSignal(paneValue(props.defaultValue, bounds()));
35
+ const value = (): number =>
36
+ props.value === undefined ? own() : paneValue(props.value, bounds());
37
+ const set = (next: number): void => {
38
+ if (next === value()) return;
39
+ setOwn(next);
40
+ props.onResize?.(next);
41
+ };
42
+ let root: HTMLDivElement | undefined;
43
+
44
+ const bind = (separator: HTMLDivElement): void => {
45
+ if (typeof document === 'undefined' || typeof separator.setPointerCapture !== 'function')
46
+ return;
47
+ let before = value();
48
+ const at = (x: number): number => {
49
+ const rect = root?.getBoundingClientRect();
50
+ return rect === undefined
51
+ ? value()
52
+ : panePercentAt(x, { start: rect.left, size: rect.width }, bounds(), ui.dir);
53
+ };
54
+ rt.onCleanup(
55
+ bindPointerDrag(separator, {
56
+ start: () => {
57
+ before = value();
58
+ },
59
+ move: (point) => set(at(point.x)),
60
+ drop: (point) => set(at(point.x)),
61
+ cancel: () => set(before),
62
+ }),
63
+ );
64
+ };
65
+
66
+ return (
67
+ <div
68
+ class={cx(styles['split'], props.class)}
69
+ style={{ '--split': `${value()}%` }}
70
+ ref={(el: HTMLDivElement) => {
71
+ root = el;
72
+ }}
73
+ >
74
+ <div class={styles['pane']}>{props.start}</div>
75
+ {/* biome-ignore lint/a11y/useSemanticElements: a FOCUSABLE separator with a value; <hr> takes neither */}
76
+ <div
77
+ class={styles['separator']}
78
+ role="separator"
79
+ tabindex="0"
80
+ aria-orientation="vertical"
81
+ aria-label={props.label ?? ui.t(UI_KEYS.paneResize)}
82
+ aria-valuenow={value()}
83
+ aria-valuemin={bounds().min}
84
+ aria-valuemax={bounds().max}
85
+ ref={bind}
86
+ onKeyDown={(event: KeyboardEvent) => {
87
+ const next = paneKey(event.key, value(), bounds(), ui.dir);
88
+ if (next === null) return;
89
+ event.preventDefault();
90
+ set(next);
91
+ }}
92
+ />
93
+ <div class={styles['pane']}>{props.end}</div>
94
+ </div>
95
+ );
96
+ }
@@ -64,21 +64,47 @@
64
64
  stroke-linejoin: round;
65
65
  }
66
66
 
67
- .trend-up {
67
+ // The chip's colour is what the caller said a direction MEANS (`goodWhen`), never the direction:
68
+ // an error rate rising is `bad`. The arrow carries the direction in every mood.
69
+ .mood-good {
68
70
  background: t.role('success-soft');
69
71
  color: t.role('success');
70
72
  }
71
73
 
72
- .trend-down {
74
+ .mood-bad {
73
75
  background: t.role('danger-soft');
74
76
  color: t.role('danger');
75
77
  }
76
78
 
77
- .trend-flat {
79
+ .mood-neutral {
78
80
  background: t.role('surface-raised');
79
81
  color: t.role('fg-muted');
80
82
  }
81
83
 
84
+ .of {
85
+ color: t.role('fg-muted');
86
+ font-size: t.text(sm);
87
+ font-weight: t.weight(normal);
88
+ }
89
+
90
+ .trend {
91
+ block-size: t.space(8);
92
+ }
93
+
94
+ // A whole-tile link: the tile's own hover, a focus ring, and no underline on the figure.
95
+ .linked {
96
+ @include t.focus-ring;
97
+ color: inherit;
98
+ text-decoration: none;
99
+ }
100
+
101
+ // A status edge on the inline start, from the tone's custom property.
102
+ @include t.tone-classes;
103
+
104
+ .toned {
105
+ border-inline-start: t.stroke(thick) solid var(--tone);
106
+ }
107
+
82
108
  .hint {
83
109
  margin: 0;
84
110
  color: t.role('fg-muted');
@@ -1,16 +1,20 @@
1
- // One labelled figure with an optional trend chip and a line of context — the tile a dashboard's
2
- // top row is made of. The figure arrives formatted: number formatting is the caller's
3
- // (`Intl.NumberFormat` against the page locale), so the tile never guesses a locale.
1
+ // One labelled figure with an optional trend chip, a sparkline and a line of context — the tile a
2
+ // dashboard's top row is made of. The figure arrives formatted (the caller's `Intl.NumberFormat`),
3
+ // and whether up is good is the caller's one prop (`goodWhen`): the arrow always shows the way,
4
+ // the colour only what the caller said it means.
4
5
  //
5
6
  // Intrinsic tags only: a runtime-chosen root (`const Tag = props.as ?? 'div'`) is called as a
6
- // component by the island build, so a tile built on `Card` hydrated differently from how it was
7
- // served. A `<div>` renders identically on both sides.
7
+ // component by the island build, so a linked tile is a second literal `<a>`, never a variable tag.
8
8
 
9
9
  import type { JSX } from 'solid-js';
10
10
  import { cx } from '../cx';
11
+ import { Skeleton } from './Skeleton';
12
+ import { Sparkline } from './Sparkline';
11
13
  import styles from './StatTile.module.scss';
12
- import type { StatDelta } from './stat-delta';
13
- import { DELTA_ARROW_PATH } from './stat-delta';
14
+ import type { SparklinePoint } from './sparkline-view';
15
+ import type { StatDelta, StatGoodWhen } from './stat-delta';
16
+ import { DELTA_ARROW_PATH, deltaMood } from './stat-delta';
17
+ import type { Tone } from './variants';
14
18
 
15
19
  export interface StatTileProps {
16
20
  /** The tile's caption, already translated — "Total links", "Clicks today". */
@@ -21,21 +25,55 @@ export interface StatTileProps {
21
25
  stat: string;
22
26
  /** The trend chip; `deltaOf()` builds one from a current and a baseline. */
23
27
  delta?: StatDelta | undefined;
28
+ /** Which direction of `delta` is good news. Default `'up'`; `'none'` colours neither. */
29
+ goodWhen?: StatGoodWhen | undefined;
30
+ /** The denominator, formatted — "of 40" reads after the figure: "12 of 40". */
31
+ of?: string | undefined;
32
+ /** The recent series behind the figure, oldest first; `null` is a gap. */
33
+ trend?: readonly SparklinePoint[] | undefined;
34
+ /** A status edge on the tile — `danger` for a figure past its limit. */
35
+ tone?: Tone | undefined;
36
+ /** The page behind the figure: the whole tile becomes one link. */
37
+ href?: string | undefined;
38
+ /** The figure is on its way: a skeleton in its box, so the row does not shift when it lands. */
39
+ loading?: boolean | undefined;
24
40
  /** One short line of context under the figure: "vs yesterday", "849 clicks". */
25
41
  hint?: string | undefined;
26
42
  class?: string | undefined;
27
43
  }
28
44
 
29
45
  export function StatTile(props: StatTileProps): JSX.Element {
30
- return (
31
- <div class={cx(styles['tile'], props.class)}>
46
+ const classes = (): string =>
47
+ cx(
48
+ styles['tile'],
49
+ props.tone !== undefined && [styles['toned'], styles[`tone-${props.tone}`]],
50
+ props.href !== undefined && styles['linked'],
51
+ props.class,
52
+ );
53
+ const body = (): JSX.Element => (
54
+ <>
32
55
  <p class={styles['label']}>{props.label}</p>
33
- <div class={styles['figure']}>
34
- <p class={styles['value']} data-stat={props.stat}>
35
- {props.value}
36
- </p>
37
- {props.delta === undefined ? null : (
38
- <span class={cx(styles['delta'], styles[`trend-${props.delta.trend}`])}>
56
+ <div class={styles['figure']} aria-busy={props.loading === true ? 'true' : undefined}>
57
+ {props.loading === true ? (
58
+ <span class={styles['value']}>
59
+ <Skeleton width="6ch" height="1em" />
60
+ </span>
61
+ ) : (
62
+ <p class={styles['value']} data-stat={props.stat}>
63
+ {props.value}
64
+ </p>
65
+ )}
66
+ {/* Beside the figure, not in it: `data-stat` reads the number alone. */}
67
+ {props.of === undefined ? null : <span class={styles['of']}>{props.of}</span>}
68
+ {props.delta === undefined || props.loading === true ? null : (
69
+ <span
70
+ class={cx(
71
+ styles['delta'],
72
+ styles[`mood-${deltaMood(props.delta.trend, props.goodWhen ?? 'up')}`],
73
+ )}
74
+ data-trend={props.delta.trend}
75
+ data-mood={deltaMood(props.delta.trend, props.goodWhen ?? 'up')}
76
+ >
39
77
  <svg viewBox="0 0 10 10" aria-hidden="true" class={styles['arrow']}>
40
78
  <path d={DELTA_ARROW_PATH[props.delta.trend]} />
41
79
  </svg>
@@ -43,7 +81,28 @@ export function StatTile(props: StatTileProps): JSX.Element {
43
81
  </span>
44
82
  )}
45
83
  </div>
84
+ {props.trend === undefined ? null : (
85
+ <Sparkline
86
+ label={props.label}
87
+ points={props.trend}
88
+ tone={props.tone}
89
+ class={styles['trend']}
90
+ />
91
+ )}
46
92
  {props.hint === undefined ? null : <p class={styles['hint']}>{props.hint}</p>}
47
- </div>
93
+ </>
94
+ );
95
+ // The branch inside the JSX: in an island the body runs once, and a branch decided there would
96
+ // keep the first answer for the island's life.
97
+ return (
98
+ <>
99
+ {props.href === undefined ? (
100
+ <div class={classes()}>{body()}</div>
101
+ ) : (
102
+ <a class={classes()} href={props.href}>
103
+ {body()}
104
+ </a>
105
+ )}
106
+ </>
48
107
  );
49
108
  }
@@ -0,0 +1,78 @@
1
+ @use '../tokens' as t;
2
+
3
+ .stepper {
4
+ min-inline-size: 0;
5
+ }
6
+
7
+ // The phone's form: one line. The list stays in the accessibility tree, visually hidden, so the
8
+ // reader still hears every step; the line is the sighted summary and is hidden from the tree.
9
+ .compact {
10
+ display: none;
11
+ color: t.role('fg');
12
+ font-weight: t.weight(medium);
13
+
14
+ @include t.respond-down(sm) {
15
+ display: block;
16
+ }
17
+ }
18
+
19
+ .list {
20
+ display: flex;
21
+ gap: t.space(4);
22
+ margin: 0;
23
+ padding: 0;
24
+ list-style: none;
25
+
26
+ @include t.respond-down(sm) {
27
+ @include t.visually-hidden;
28
+ }
29
+ }
30
+
31
+ .item {
32
+ display: flex;
33
+ min-inline-size: 0;
34
+ }
35
+
36
+ .step {
37
+ @include t.focus-ring;
38
+ display: inline-flex;
39
+ align-items: center;
40
+ gap: t.space(2);
41
+ border: 0;
42
+ background: none;
43
+ color: t.role('fg-muted');
44
+ font: inherit;
45
+ text-decoration: none;
46
+
47
+ &[aria-current='step'] {
48
+ color: t.role('fg-strong');
49
+ font-weight: t.weight(semibold);
50
+ }
51
+ }
52
+
53
+ a.step,
54
+ button.step {
55
+ color: t.role('accent');
56
+ cursor: pointer;
57
+ }
58
+
59
+ .marker {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ inline-size: t.space(6);
64
+ block-size: t.space(6);
65
+ border: 1px solid currentcolor;
66
+ border-radius: t.radius(full);
67
+ font-size: t.text(xs);
68
+ }
69
+
70
+ .item[data-state='current'] .marker {
71
+ border-color: t.role('accent');
72
+ background: t.role('accent');
73
+ color: t.role('accent-fg');
74
+ }
75
+
76
+ .label {
77
+ @include t.truncate;
78
+ }
@@ -0,0 +1,73 @@
1
+ // Progress through a multi-step flow: an `<ol>`, the current step `aria-current="step"`, a passed
2
+ // step a link (its `href`) or a button (`onSelect`) and an upcoming one plain text. Exactly one
3
+ // current, by `stepperModel`. Below `sm` the list folds into one line, "Step {n} of {total}: Details".
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { UI_KEYS } from '../i18n-keys';
8
+ import { useUi } from '../theme/context';
9
+ import { linkTarget } from './link-target';
10
+ import styles from './Stepper.module.scss';
11
+ import { type StepperStep, stepperModel } from './stepper-view';
12
+
13
+ export interface StepperProps {
14
+ steps: readonly StepperStep[];
15
+ /** Already-translated name of the list — "Checkout progress". */
16
+ label: string;
17
+ /** A passed step with no `href` becomes a button that calls this. */
18
+ onSelect?: ((id: string) => void) | undefined;
19
+ class?: string | undefined;
20
+ }
21
+
22
+ export function Stepper(props: StepperProps): JSX.Element {
23
+ const ui = useUi();
24
+ const model = stepperModel(props.steps);
25
+ const current = props.steps[model.current];
26
+
27
+ const body = (step: StepperStep, index: number): JSX.Element => {
28
+ const marker = <span class={styles['marker']}>{index + 1}</span>;
29
+ const text = <span class={styles['label']}>{step.label}</span>;
30
+ if (step.state === 'done' && step.href !== undefined) {
31
+ return (
32
+ <a class={styles['step']} href={linkTarget(step.href).href}>
33
+ {marker}
34
+ {text}
35
+ </a>
36
+ );
37
+ }
38
+ const onSelect = props.onSelect;
39
+ if (step.state === 'done' && onSelect !== undefined) {
40
+ return (
41
+ <button type="button" class={styles['step']} onClick={() => onSelect(step.id)}>
42
+ {marker}
43
+ {text}
44
+ </button>
45
+ );
46
+ }
47
+ return (
48
+ <span class={styles['step']} aria-current={step.state === 'current' ? 'step' : undefined}>
49
+ {marker}
50
+ {text}
51
+ </span>
52
+ );
53
+ };
54
+
55
+ return (
56
+ <div class={cx(styles['stepper'], props.class)}>
57
+ <p class={styles['compact']} aria-hidden="true">
58
+ {ui.t(UI_KEYS.step, {
59
+ current: model.current + 1,
60
+ total: model.total,
61
+ label: current?.label ?? '',
62
+ })}
63
+ </p>
64
+ <ol class={styles['list']} aria-label={props.label}>
65
+ {props.steps.map((step, index) => (
66
+ <li class={styles['item']} data-state={step.state}>
67
+ {body(step, index)}
68
+ </li>
69
+ ))}
70
+ </ol>
71
+ </div>
72
+ );
73
+ }