@ultimat3/ui 29.3.2 → 30.0.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,118 @@
1
+ @use '../tokens' as t;
2
+
3
+ .calendar {
4
+ display: inline-grid;
5
+ gap: t.space(2);
6
+ max-inline-size: 100%;
7
+ }
8
+
9
+ .header {
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: space-between;
13
+ gap: t.space(2);
14
+ }
15
+
16
+ .title {
17
+ flex: 1;
18
+ margin: 0;
19
+ color: t.role('fg-strong');
20
+ font-weight: t.weight(semibold);
21
+ text-align: center;
22
+ }
23
+
24
+ .nav {
25
+ @include t.touch-target;
26
+ display: inline-grid;
27
+ place-items: center;
28
+ border: 0;
29
+ border-radius: t.radius(md);
30
+ color: t.role('fg');
31
+ background: transparent;
32
+ cursor: pointer;
33
+
34
+ &:hover {
35
+ background: t.role('bg-soft');
36
+ }
37
+
38
+ &:focus-visible {
39
+ outline: t.stroke(thick) solid t.role('accent');
40
+ outline-offset: calc(-1 * #{t.stroke(thick)});
41
+ }
42
+ }
43
+
44
+ // The chevrons point the way the month moves, which is the other way under `dir="rtl"`.
45
+ :global([dir='rtl']) .nav {
46
+ transform: scaleX(-1);
47
+ }
48
+
49
+ .grid {
50
+ border-collapse: collapse;
51
+ table-layout: fixed;
52
+ }
53
+
54
+ .weekday {
55
+ padding-block-end: t.space(1);
56
+ color: t.role('fg-muted');
57
+ font-size: t.text(xs);
58
+ font-weight: t.weight(medium);
59
+ text-align: center;
60
+ }
61
+
62
+ .cell {
63
+ padding: 0;
64
+ text-align: center;
65
+ }
66
+
67
+ // 44 px at 320 px wide: seven of them fit, and each is a finger's target.
68
+ .day,
69
+ .outside {
70
+ display: inline-grid;
71
+ place-items: center;
72
+ inline-size: t.touch(target);
73
+ block-size: t.touch(target);
74
+ border: 0;
75
+ border-radius: t.radius(md);
76
+ font-size: t.text(sm);
77
+ font-variant-numeric: tabular-nums;
78
+ }
79
+
80
+ .day {
81
+ color: t.role('fg');
82
+ background: transparent;
83
+ text-decoration: none;
84
+ cursor: pointer;
85
+
86
+ &:hover {
87
+ background: t.role('bg-soft');
88
+ }
89
+
90
+ &:focus-visible {
91
+ outline: t.stroke(thick) solid t.role('accent');
92
+ outline-offset: calc(-1 * #{t.stroke(thick)});
93
+ }
94
+
95
+ &:disabled,
96
+ &[aria-disabled='true'] {
97
+ color: t.role('fg-muted');
98
+ text-decoration: line-through;
99
+ background: transparent;
100
+ cursor: not-allowed;
101
+ }
102
+ }
103
+
104
+ .outside {
105
+ color: t.role('fg-muted');
106
+ opacity: 0.5;
107
+ }
108
+
109
+ .cell[aria-selected='true'] .day,
110
+ .day[aria-current='date'] {
111
+ color: t.role('accent-fg');
112
+ background: t.role('accent');
113
+ }
114
+
115
+ .cell[data-span='inside'] .day {
116
+ color: t.role('accent-strong');
117
+ background: t.role('accent', 0.12);
118
+ }
@@ -0,0 +1,246 @@
1
+ // A month you can pick a day from. Server-rendered and correct with no effect run: in link mode
2
+ // every day is a link (`hrefFor`), in select mode an APG grid with one tab stop. Inline on a
3
+ // booking page, and what `DatePicker`/`DateRangePicker` open. Rules: `calendar-view.ts`.
4
+
5
+ import { addPlainMonths, now, type PlainDate, plainDateIn } from '@ultimat3/time';
6
+ import type { JSX } from 'solid-js';
7
+ import { useId } from '../a11y';
8
+ import { cx } from '../cx';
9
+ import { UI_KEYS } from '../i18n-keys';
10
+ import { iconChevronLeft } from '../icons/glyphs/chevron-left';
11
+ import { iconChevronRight } from '../icons/glyphs/chevron-right';
12
+ import { useUi } from '../theme/context';
13
+ import { solid } from '../theme/solid-adapter';
14
+ import styles from './Calendar.module.scss';
15
+ import {
16
+ assertBounds,
17
+ type CalendarDay,
18
+ dayLabel,
19
+ firstOfMonth,
20
+ monthGrid,
21
+ monthLabel,
22
+ optionalDateProp as optionalDate,
23
+ rovingDay,
24
+ stepDay,
25
+ type Weekday,
26
+ weekdayLabels,
27
+ weekStartOf,
28
+ } from './calendar-view';
29
+ import { type DateRange, spanPosition } from './date-range-view';
30
+ import { Icon } from './Icon';
31
+
32
+ interface CalendarBaseProps {
33
+ /** The chosen day, `YYYY-MM-DD`. */
34
+ value?: string | undefined;
35
+ /** A span to mark selected — what `DateRangePicker` passes. */
36
+ range?: DateRange | undefined;
37
+ /** The month shown first; defaults to `value`'s, then `min`'s, then today's in `useUi().timeZone`. */
38
+ month?: string | undefined;
39
+ min?: string | undefined;
40
+ max?: string | undefined;
41
+ isDisabled?: ((date: PlainDate) => boolean) | undefined;
42
+ /** Overrides the locale's week start (`Intl.Locale#getWeekInfo`). 1 is Monday, 7 Sunday. */
43
+ weekStart?: Weekday | undefined;
44
+ class?: string | undefined;
45
+ }
46
+
47
+ /** Link mode: every enabled day is a link. Works with scripting off. */
48
+ export interface CalendarLinkProps extends CalendarBaseProps {
49
+ hrefFor: (date: PlainDate) => string;
50
+ /** The previous and next month's links; omitted, the month does not change. */
51
+ monthHrefFor?: ((month: PlainDate) => string) | undefined;
52
+ onSelect?: undefined;
53
+ }
54
+
55
+ /** Select mode: `role="grid"`, one tab stop, arrows and PageUp/PageDown. An island's. */
56
+ export interface CalendarSelectProps extends CalendarBaseProps {
57
+ onSelect: (date: PlainDate) => void;
58
+ hrefFor?: undefined;
59
+ monthHrefFor?: undefined;
60
+ }
61
+
62
+ export type CalendarProps = CalendarLinkProps | CalendarSelectProps;
63
+
64
+ export function Calendar(props: CalendarProps): JSX.Element {
65
+ const rt = solid();
66
+ const ui = useUi();
67
+ const titleId = useId('calendar');
68
+ const bounds = () => {
69
+ const resolved = {
70
+ min: optionalDate('Calendar min', props.min),
71
+ max: optionalDate('Calendar max', props.max),
72
+ isDisabled: props.isDisabled,
73
+ };
74
+ assertBounds('Calendar', resolved);
75
+ return resolved;
76
+ };
77
+ const value = (): PlainDate | undefined => optionalDate('Calendar value', props.value);
78
+ // With nothing to open on, the month of today in the context's zone — an explicit IANA zone,
79
+ // the request's or the provider's, never the device's.
80
+ const start =
81
+ optionalDate('Calendar month', props.month) ??
82
+ value() ??
83
+ bounds().min ??
84
+ plainDateIn(now(), ui.timeZone);
85
+ const [month, setMonth] = rt.createSignal<PlainDate>(firstOfMonth(start));
86
+ const [focused, setFocused] = rt.createSignal<PlainDate | undefined>(undefined);
87
+ const weekStart = (): Weekday => weekStartOf(ui.locale, props.weekStart);
88
+ const grid = (): readonly (readonly CalendarDay[])[] => monthGrid(month(), weekStart(), bounds());
89
+ const stop = (): PlainDate | undefined =>
90
+ rovingDay(grid(), [focused(), value(), props.range?.from]);
91
+ let table: HTMLTableElement | undefined;
92
+
93
+ const selected = (date: PlainDate): boolean =>
94
+ date === value() ||
95
+ (props.range !== undefined && spanPosition(date, props.range) !== undefined);
96
+
97
+ const moveTo = (date: PlainDate): void => {
98
+ if (firstOfMonth(date) !== month()) setMonth(firstOfMonth(date));
99
+ setFocused(date);
100
+ const buttons = table === undefined ? [] : [...table.querySelectorAll<HTMLElement>('button')];
101
+ buttons.find((button) => button.dataset['date'] === date)?.focus();
102
+ };
103
+
104
+ const onKeyDown = (event: KeyboardEvent): void => {
105
+ const from = (event.target as HTMLElement | null)?.dataset?.['date'];
106
+ if (from === undefined || props.onSelect === undefined) return;
107
+ const next = stepDay(from as PlainDate, event.key, {
108
+ ...bounds(),
109
+ dir: ui.dir,
110
+ weekStart: weekStart(),
111
+ shiftKey: event.shiftKey,
112
+ });
113
+ if (next === undefined) return;
114
+ event.preventDefault();
115
+ moveTo(next);
116
+ };
117
+
118
+ const cell = (day: CalendarDay): JSX.Element => {
119
+ if (!day.inMonth) {
120
+ return (
121
+ <span class={styles['outside']} aria-hidden="true">
122
+ {day.day}
123
+ </span>
124
+ );
125
+ }
126
+ const onSelect = props.onSelect;
127
+ if (onSelect !== undefined) {
128
+ return (
129
+ <button
130
+ type="button"
131
+ class={styles['day']}
132
+ data-date={day.date}
133
+ aria-label={dayLabel(day.date, ui.locale)}
134
+ tabIndex={stop() === day.date ? 0 : -1}
135
+ disabled={day.disabled}
136
+ onClick={() => {
137
+ setFocused(day.date);
138
+ onSelect(day.date);
139
+ }}
140
+ >
141
+ {day.day}
142
+ </button>
143
+ );
144
+ }
145
+ const hrefFor = props.hrefFor;
146
+ if (day.disabled || hrefFor === undefined) {
147
+ return (
148
+ <span class={styles['day']} aria-disabled="true">
149
+ {day.day}
150
+ </span>
151
+ );
152
+ }
153
+ return (
154
+ <a
155
+ class={styles['day']}
156
+ href={hrefFor(day.date)}
157
+ aria-label={dayLabel(day.date, ui.locale)}
158
+ aria-current={selected(day.date) ? 'date' : undefined}
159
+ >
160
+ {day.day}
161
+ </a>
162
+ );
163
+ };
164
+
165
+ /** A month control: a link in link mode (when the caller gave months a URL), a button otherwise. */
166
+ const monthControl = (step: -1 | 1): JSX.Element => {
167
+ const target = (): PlainDate => addPlainMonths(month(), step);
168
+ const label = ui.t(step === 1 ? UI_KEYS.dateNextMonth : UI_KEYS.datePreviousMonth);
169
+ const glyph = step === 1 ? iconChevronRight : iconChevronLeft;
170
+ if (props.onSelect === undefined) {
171
+ const monthHrefFor = props.monthHrefFor;
172
+ if (monthHrefFor === undefined) return null;
173
+ return (
174
+ <a class={styles['nav']} href={monthHrefFor(target())} aria-label={label}>
175
+ <Icon glyph={glyph} size="sm" />
176
+ </a>
177
+ );
178
+ }
179
+ return (
180
+ <button
181
+ type="button"
182
+ class={styles['nav']}
183
+ aria-label={label}
184
+ onClick={() => setMonth(target())}
185
+ >
186
+ <Icon glyph={glyph} size="sm" />
187
+ </button>
188
+ );
189
+ };
190
+
191
+ return (
192
+ <div class={cx(styles['calendar'], props.class)}>
193
+ <div class={styles['header']}>
194
+ {monthControl(-1)}
195
+ <p class={styles['title']} id={titleId} aria-live="polite">
196
+ {monthLabel(month(), ui.locale)}
197
+ </p>
198
+ {monthControl(1)}
199
+ </div>
200
+ <table
201
+ class={styles['grid']}
202
+ role={props.onSelect === undefined ? undefined : 'grid'}
203
+ aria-labelledby={titleId}
204
+ onKeyDown={onKeyDown}
205
+ ref={(el: HTMLTableElement) => {
206
+ table = el;
207
+ }}
208
+ >
209
+ <thead>
210
+ <tr>
211
+ {weekdayLabels(ui.locale, weekStart()).map((weekday) => (
212
+ <th scope="col" abbr={weekday.long} class={styles['weekday']}>
213
+ {weekday.short}
214
+ </th>
215
+ ))}
216
+ </tr>
217
+ </thead>
218
+ <tbody>
219
+ {grid().map((week) => (
220
+ <tr>
221
+ {week.map((day) => (
222
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-selected is set only with role="gridcell"
223
+ <td
224
+ class={styles['cell']}
225
+ role={props.onSelect === undefined ? undefined : 'gridcell'}
226
+ aria-selected={
227
+ props.onSelect === undefined || !day.inMonth
228
+ ? undefined
229
+ : selected(day.date)
230
+ ? 'true'
231
+ : 'false'
232
+ }
233
+ data-span={
234
+ props.range === undefined ? undefined : spanPosition(day.date, props.range)
235
+ }
236
+ >
237
+ {cell(day)}
238
+ </td>
239
+ ))}
240
+ </tr>
241
+ ))}
242
+ </tbody>
243
+ </table>
244
+ </div>
245
+ );
246
+ }
@@ -18,6 +18,18 @@
18
18
  --series: #{t.role('chart-#{$slot}')};
19
19
  }
20
20
  }
21
+
22
+ // A `tone` replaces the slot's colour — after the slots, so it wins on the same element. The
23
+ // series keeps its dash and marker: the tone is a meaning, never the only cue.
24
+ [data-tone='neutral'] {
25
+ --series: #{t.role('fg-strong')};
26
+ }
27
+
28
+ @each $tone in (accent, success, warning, danger, info) {
29
+ [data-tone='#{$tone}'] {
30
+ --series: #{t.role($tone)};
31
+ }
32
+ }
21
33
  }
22
34
 
23
35
  .caption {
@@ -109,6 +121,27 @@
109
121
  fill: var(--series);
110
122
  }
111
123
 
124
+ // --- Reference rules ---------------------------------------------------------
125
+ .reference {
126
+ stroke: var(--series, #{t.role('fg-strong')});
127
+ stroke-width: 1.5;
128
+ stroke-dasharray: 6 3;
129
+ vector-effect: non-scaling-stroke;
130
+ }
131
+
132
+ .referenceLabel {
133
+ @include t.data-text;
134
+ position: absolute;
135
+ inset-block-start: var(--y);
136
+ inset-inline-end: 0;
137
+ translate: 0 -100%;
138
+ padding-inline: t.space(1);
139
+ color: var(--series, #{t.role('fg-strong')});
140
+ font-size: t.text(xs);
141
+ line-height: t.leading(tight);
142
+ pointer-events: none;
143
+ }
144
+
112
145
  // --- The keyboard layer ------------------------------------------------------
113
146
  // Over the plot box, which every chart sheet makes `position: relative`. The layer lets the
114
147
  // pointer through to nothing in particular; only the points themselves are targets.
@@ -155,21 +188,25 @@
155
188
  opacity: 0;
156
189
  transition: opacity t.duration(fast) t.easing(out);
157
190
 
191
+ // `:focus`, not only `:focus-visible`: a tap focuses the point without a visible-focus match,
192
+ // and on a phone the tap is the only way to ask for a reading.
158
193
  .hit:hover > &,
159
- .hit:focus-visible > & {
194
+ .hit:focus > & {
160
195
  visibility: visible;
161
196
  opacity: 1;
162
197
  }
163
198
 
164
199
  // A point at the edge of the plot opens its readout inward, so it never leaves the box.
200
+ // Anchored at the point's CENTRE, not the hit's edge: an outermost point sits `pad` inside the
201
+ // plot, and the hit box around it reaches past the plot's edge, so a readout from the box's edge
202
+ // started outside the plot (measured at 390px: 4px).
165
203
  .hit[data-edge='start'] > & {
166
- inset-inline-start: 0;
167
204
  justify-content: flex-start;
168
205
  }
169
206
 
170
207
  .hit[data-edge='end'] > & {
171
208
  inset-inline-start: auto;
172
- inset-inline-end: 0;
209
+ inset-inline-end: 50%;
173
210
  justify-content: flex-end;
174
211
  }
175
212
  }
@@ -5,9 +5,15 @@
5
5
 
6
6
  import type { JSX } from 'solid-js';
7
7
  import { cx } from '../cx';
8
+ import type { Accessor } from '../theme/solid-adapter';
9
+ import { solid } from '../theme/solid-adapter';
8
10
  import styles from './ChartFrame.module.scss';
9
11
  import type { ChartTable, SeriesStyle } from './chart-frame-view';
10
- import { markerPath } from './chart-frame-view';
12
+ import { cssPercent, markerPath } from './chart-frame-view';
13
+ import type { ReferenceLine } from './chart-reference-view';
14
+ import { keyedRows } from './data-table-rows';
15
+ import { thunk } from './thunk';
16
+ import type { Tone } from './variants';
11
17
 
12
18
  export interface ChartLegendItem {
13
19
  /** The series' name, already translated. */
@@ -22,6 +28,8 @@ export interface ChartLegendItem {
22
28
 
23
29
  /** One focusable point over the plot: where it sits, and what it reads. */
24
30
  export interface ChartHit {
31
+ /** Stable across ticks (`hitIds`): the node, and the focus on it, follow the id. */
32
+ readonly id: string;
25
33
  /** CSS percentages of the plot box (`cssPercent`). */
26
34
  readonly x: string;
27
35
  readonly y: string;
@@ -112,10 +120,16 @@ function chartTableNode(table: ChartTable): JSX.Element {
112
120
  );
113
121
  }
114
122
 
115
- /** A legend key for a line series: its dash, its marker, its colour slot. */
116
- export function seriesSwatch(style: SeriesStyle): JSX.Element {
123
+ /** A legend key for a line series: its dash, its marker, its colour slot — or its tone. */
124
+ export function seriesSwatch(style: SeriesStyle, tone?: Tone | undefined): JSX.Element {
117
125
  return (
118
- <svg class={styles['key']} viewBox="0 0 24 12" data-series={style.slot} aria-hidden="true">
126
+ <svg
127
+ class={styles['key']}
128
+ viewBox="0 0 24 12"
129
+ data-series={style.slot}
130
+ data-tone={tone}
131
+ aria-hidden="true"
132
+ >
119
133
  <path class={styles['keyLine']} d="M1,6 L23,6" stroke-dasharray={style.dash} />
120
134
  <path class={styles['keyMarker']} d={markerPath(style.marker, 12, 6, 4)} />
121
135
  </svg>
@@ -124,32 +138,74 @@ export function seriesSwatch(style: SeriesStyle): JSX.Element {
124
138
 
125
139
  /**
126
140
  * The keyboard layer: one focusable point per datum, over the plot, each reading its own value on
127
- * `:hover` and `:focus-visible` — CSS only, so a chart that asks for it still ships no JS. DOM
128
- * order is the tab order, and the caller passes them in reading order.
141
+ * `:hover`, focus and a tap — CSS only, so a chart that asks for it still ships no JS. DOM order is
142
+ * the tab order, and the caller passes them in reading order.
143
+ *
144
+ * KEYED by `ChartHit.id` (`data-table-rows.ts`'s rule): a live tick that moves a point moves its
145
+ * node, so a focused point keeps focus and its readout. Rebuilt per tick, focus fell to `<body>`.
129
146
  */
130
- export function chartHits(hits: readonly ChartHit[]): JSX.Element {
147
+ export function ChartHits(props: { hits: readonly ChartHit[] }): JSX.Element {
148
+ const nodes = keyedRows<ChartHit, JSX.Element>(solid(), (hit) => hit.id, hitNode);
149
+ return <div class={styles['hits']}>{thunk(() => nodes(props.hits))}</div>;
150
+ }
151
+
152
+ function hitNode(hit: Accessor<ChartHit>): JSX.Element {
131
153
  return (
132
- <div class={styles['hits']}>
133
- {hits.map((hit) => (
134
- // A datum is an image of one value: `img` is the role that names it and nothing more.
135
- <span
136
- class={styles['hit']}
137
- role="img"
138
- tabindex={0}
139
- aria-label={hit.label}
140
- data-hit="true"
141
- data-edge={hit.edge}
142
- style={{ '--x': hit.x, '--y': hit.y }}
143
- >
144
- {/* A zero-width anchor that centres the bubble over the point with flex, not a
145
- percentage translate: that one lands a whole width off under `dir="rtl"`. */}
146
- <span class={styles['readout']} aria-hidden="true">
147
- <span class={styles['bubble']} data-readout="true">
148
- {hit.label}
149
- </span>
150
- </span>
154
+ // A datum is an image of one value: `img` is the role that names it and nothing more.
155
+ <span
156
+ class={styles['hit']}
157
+ role="img"
158
+ tabindex={0}
159
+ aria-label={hit().label}
160
+ data-hit={hit().id}
161
+ data-edge={hit().edge}
162
+ style={{ '--x': hit().x, '--y': hit().y }}
163
+ >
164
+ {/* A zero-width anchor that centres the bubble over the point with flex, not a
165
+ percentage translate: that one lands a whole width off under `dir="rtl"`. */}
166
+ <span class={styles['readout']} aria-hidden="true">
167
+ <span class={styles['bubble']} data-readout="true">
168
+ {hit().label}
151
169
  </span>
152
- ))}
153
- </div>
170
+ </span>
171
+ </span>
154
172
  );
155
173
  }
174
+
175
+ /** Reference rules inside a chart's svg: dashed in every tone, so a rule never reads as a series. */
176
+ export function ReferenceRules(props: {
177
+ lines: readonly ReferenceLine[];
178
+ width: number;
179
+ }): JSX.Element {
180
+ return props.lines.map((line) => (
181
+ <line
182
+ class={styles['reference']}
183
+ data-reference="true"
184
+ data-tone={line.tone}
185
+ x1={0}
186
+ x2={props.width}
187
+ y1={line.y}
188
+ y2={line.y}
189
+ />
190
+ ));
191
+ }
192
+
193
+ /**
194
+ * Each rule's label as HTML over the plot's inline end — text in a viewBox scales to a speck — and
195
+ * NOT hidden: "Target 120" is a fact the picture states and the table has no row for.
196
+ */
197
+ export function ReferenceLabels(props: {
198
+ lines: readonly ReferenceLine[];
199
+ height: number;
200
+ format: (value: number) => string;
201
+ }): JSX.Element {
202
+ return props.lines.map((line) => (
203
+ <span
204
+ class={styles['referenceLabel']}
205
+ data-tone={line.tone}
206
+ style={{ '--y': cssPercent(line.y, props.height) }}
207
+ >
208
+ {`${line.label} ${props.format(line.value)}`}
209
+ </span>
210
+ ));
211
+ }
@@ -0,0 +1,101 @@
1
+ @use '../tokens' as t;
2
+
3
+ // The code surface is `bg-soft`; every run colour is a role measured AA on it
4
+ // (`contrast-pairs.ts`, "a code token on the code surface"), so a theme recolours code for free.
5
+ .block {
6
+ position: relative;
7
+ border: t.stroke(hairline) solid t.role('line');
8
+ border-radius: t.radius(md);
9
+ background: t.role('bg-soft');
10
+ }
11
+
12
+ .bar {
13
+ @include t.row(t.space(1), center, flex-end);
14
+ padding-block: t.space(1);
15
+ padding-inline: t.space(2);
16
+ border-block-end: t.stroke(hairline) solid t.role('line');
17
+ }
18
+
19
+ // Wrap and copy need script; without it the text is still there and selectable.
20
+ .block:not([data-enhanced='true']) .bar {
21
+ display: none;
22
+ }
23
+
24
+ .toggle {
25
+ @include t.focus-ring;
26
+ border: 0;
27
+ border-radius: t.radius(sm);
28
+ background: transparent;
29
+ color: t.role('fg-muted');
30
+ font-size: t.text(sm);
31
+ cursor: pointer;
32
+ min-block-size: t.touch(target);
33
+
34
+ &[aria-pressed='true'] {
35
+ color: t.role('accent');
36
+ }
37
+ }
38
+
39
+ .pre {
40
+ @include t.focus-ring;
41
+ margin: 0;
42
+ padding: t.space(3);
43
+ overflow-x: auto;
44
+ font-family: var(--font-mono);
45
+ font-size: t.text(sm);
46
+ line-height: t.leading(normal);
47
+ color: t.role('fg');
48
+ text-align: start;
49
+ white-space: pre;
50
+ tab-size: 2;
51
+ }
52
+
53
+ .block[data-wrap='true'] .pre {
54
+ white-space: pre-wrap;
55
+ overflow-wrap: anywhere;
56
+ }
57
+
58
+ .tok-plain {
59
+ color: inherit;
60
+ }
61
+ .tok-keyword {
62
+ color: t.role('accent-strong');
63
+ }
64
+ .tok-string {
65
+ color: t.role('success');
66
+ }
67
+ .tok-number {
68
+ color: t.role('warning');
69
+ }
70
+ .tok-comment {
71
+ color: t.role('fg-muted');
72
+ font-style: italic;
73
+ }
74
+ .tok-type {
75
+ color: t.role('info');
76
+ }
77
+ .tok-function {
78
+ color: t.role('fg-strong');
79
+ }
80
+ .tok-property {
81
+ color: t.role('info');
82
+ }
83
+ .tok-tag {
84
+ color: t.role('danger');
85
+ }
86
+ .tok-attribute {
87
+ color: t.role('warning');
88
+ }
89
+ .tok-operator,
90
+ .tok-punctuation {
91
+ color: t.role('fg');
92
+ }
93
+ .tok-inserted {
94
+ color: t.role('success');
95
+ }
96
+ .tok-deleted {
97
+ color: t.role('danger');
98
+ }
99
+ .tok-meta {
100
+ color: t.role('fg-muted');
101
+ }