@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,200 @@
1
+ // A time window in the URL — a preset id, or a custom `from`/`to` as wall-clock times in an
2
+ // explicit IANA zone — read leniently and written as hrefs. It composes `listUrl`, so "keep every
3
+ // other parameter, drop the cursor" is that module's one rule. Pure: no DOM, no router, no clock.
4
+
5
+ import { renderCauseValue } from '@ultimat3/core';
6
+ import {
7
+ assertTimeZone,
8
+ fromIso,
9
+ fromZoned,
10
+ type Instant,
11
+ instant,
12
+ isPlainDate,
13
+ type TimeZone,
14
+ toZoned,
15
+ } from '@ultimat3/time';
16
+ import { invalidValueError, UI_ERROR_CODES, UiError } from './errors';
17
+ import { listUrl } from './list-url';
18
+
19
+ /** One window the app offers. The list and its words are the app's; none ships (axiom 8). */
20
+ export interface TimeRangePreset {
21
+ readonly id: string;
22
+ /** Already-translated. */
23
+ readonly label: string;
24
+ /** A window ending at `now`, this many seconds long. */
25
+ readonly seconds?: number | undefined;
26
+ /** A fixed window, as ISO instants with an offset. */
27
+ readonly from?: string | undefined;
28
+ readonly to?: string | undefined;
29
+ }
30
+
31
+ export interface TimeRangeUrlOptions {
32
+ readonly presets: readonly TimeRangePreset[];
33
+ /** The preset id shown when the URL names none, or names one that is not valid. */
34
+ readonly default: string;
35
+ /** Prefixes every parameter, for two windows on one page. */
36
+ readonly ns?: string | undefined;
37
+ /** The zone a custom `from`/`to` is wall-clock time in. Required: there is no ambient one. */
38
+ readonly timeZone: TimeZone;
39
+ /** Where a `seconds` preset ends — the caller's clock, read once per request. */
40
+ readonly now: Date;
41
+ }
42
+
43
+ export type TimeRangeChange =
44
+ | { readonly preset: string }
45
+ | { readonly from: Date; readonly to: Date };
46
+
47
+ export interface TimeRangeUrl {
48
+ /** The preset shown; `undefined` for a custom window. */
49
+ readonly preset: string | undefined;
50
+ readonly from: Instant;
51
+ readonly to: Instant;
52
+ /** The URL asked for a window that is malformed, backwards or unknown, and got the default. */
53
+ readonly invalid: boolean;
54
+ /** `from` and `to` as `datetime-local` values in `timeZone`: a custom form's defaults. */
55
+ readonly local: { readonly from: string; readonly to: string };
56
+ /** The parameter names, `ns`-prefixed: a GET form's field names. */
57
+ readonly names: { readonly preset: string; readonly from: string; readonly to: string };
58
+ /** Every other parameter, cursor dropped: what a GET form must carry as hidden inputs. */
59
+ readonly hidden: readonly (readonly [string, string])[];
60
+ /** The URL's path: a GET form's `action`. */
61
+ readonly action: string;
62
+ readonly presets: readonly TimeRangePreset[];
63
+ readonly timeZone: TimeZone;
64
+ /** The page under `change`, every other parameter kept, every cursor dropped. */
65
+ href(change: TimeRangeChange): string;
66
+ }
67
+
68
+ const BASE = 'http://range.invalid';
69
+ const WALL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
70
+
71
+ function invalidPreset(preset: TimeRangePreset, why: string): UiError {
72
+ return new UiError({
73
+ code: UI_ERROR_CODES.invalidValue,
74
+ cause: `timeRangeUrl preset ${renderCauseValue(preset.id)} ${why}`,
75
+ fix: "presets: [{ id: 'day', label: labels.day, seconds: 86_400 }] # each preset: seconds > 0, or an ISO from before an ISO to",
76
+ });
77
+ }
78
+
79
+ /** A preset's window at `now`. Neither `seconds` nor `from`/`to` is refused at construction. */
80
+ function presetWindow(preset: TimeRangePreset, now: Instant): { from: Instant; to: Instant } {
81
+ if (preset.seconds !== undefined) {
82
+ if (!Number.isFinite(preset.seconds) || preset.seconds <= 0) {
83
+ throw invalidPreset(preset, `has seconds ${String(preset.seconds)}, not a length above 0`);
84
+ }
85
+ return { from: instant(new Date(now.getTime() - preset.seconds * 1000)), to: now };
86
+ }
87
+ if (preset.from === undefined || preset.to === undefined) {
88
+ throw invalidPreset(preset, 'has neither seconds nor both from and to');
89
+ }
90
+ const from = fromIso(preset.from);
91
+ const to = fromIso(preset.to);
92
+ if (to.getTime() <= from.getTime()) throw invalidPreset(preset, 'ends before it starts');
93
+ return { from, to };
94
+ }
95
+
96
+ const pad = (value: number): string => String(value).padStart(2, '0');
97
+
98
+ /** `YYYY-MM-DDTHH:mm` in `zone`: what `<input type="datetime-local">` holds. */
99
+ export function toLocalDateTime(at: Instant, zone: TimeZone): string {
100
+ const wall = toZoned(at, zone);
101
+ return `${String(wall.year).padStart(4, '0')}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`;
102
+ }
103
+
104
+ /** A `datetime-local` value in `zone` as an instant; `undefined` for anything not one. */
105
+ export function fromLocalDateTime(raw: string | null, zone: TimeZone): Instant | undefined {
106
+ const match = raw === null ? null : WALL.exec(raw);
107
+ if (match === null) return undefined;
108
+ const [, year, month, day, rawHour, rawMinute, rawSecond] = match;
109
+ const hour = Number(rawHour);
110
+ const minute = Number(rawMinute);
111
+ const second = rawSecond === undefined ? 0 : Number(rawSecond);
112
+ if (!isPlainDate(`${String(year)}-${String(month)}-${String(day)}`)) return undefined;
113
+ if (hour > 23 || minute > 59 || second > 59) return undefined;
114
+ // A wall time a spring-forward skipped resolves to the instant just after the gap.
115
+ return fromZoned(
116
+ { year: Number(year), month: Number(month), day: Number(day), hour, minute, second },
117
+ zone,
118
+ );
119
+ }
120
+
121
+ export function timeRangeUrl(url: URL | string, options: TimeRangeUrlOptions): TimeRangeUrl {
122
+ const zone = assertTimeZone(options.timeZone);
123
+ const now = instant(options.now);
124
+ const presets = new Map<string, TimeRangePreset>();
125
+ for (const preset of options.presets) {
126
+ if (presets.has(preset.id)) throw invalidPreset(preset, 'is declared twice');
127
+ presetWindow(preset, now);
128
+ presets.set(preset.id, preset);
129
+ }
130
+ const fallback = presets.get(options.default);
131
+ if (fallback === undefined) {
132
+ throw invalidValueError('timeRangeUrl default', options.default, 'the id of one of presets');
133
+ }
134
+ const name = (param: string): string =>
135
+ options.ns === undefined ? param : `${options.ns}.${param}`;
136
+ const names = { preset: name('range'), from: name('from'), to: name('to') };
137
+ const parsed = new URL(url, BASE);
138
+ const params = parsed.searchParams;
139
+
140
+ const rawFrom = params.get(names.from);
141
+ const rawTo = params.get(names.to);
142
+ const rawPreset = params.get(names.preset);
143
+ let preset: string | undefined = fallback.id;
144
+ let window = presetWindow(fallback, now);
145
+ let invalid = false;
146
+ if (rawFrom !== null || rawTo !== null) {
147
+ const from = fromLocalDateTime(rawFrom, zone);
148
+ const to = fromLocalDateTime(rawTo, zone);
149
+ if (from !== undefined && to !== undefined && to.getTime() > from.getTime()) {
150
+ preset = undefined;
151
+ window = { from, to };
152
+ } else invalid = true;
153
+ } else if (rawPreset !== null) {
154
+ const named = presets.get(rawPreset);
155
+ if (named === undefined) invalid = true;
156
+ else {
157
+ preset = named.id;
158
+ window = presetWindow(named, now);
159
+ }
160
+ }
161
+
162
+ // `listUrl` owns "every other parameter kept, every cursor dropped"; this only adds its own.
163
+ const base = new URL(listUrl(parsed, { sorts: [], ns: options.ns }).href({}), BASE);
164
+ for (const param of Object.values(names)) base.searchParams.delete(param);
165
+ const write = (edit: (next: URLSearchParams) => void): string => {
166
+ const next = new URLSearchParams(base.searchParams);
167
+ edit(next);
168
+ const search = next.toString();
169
+ return search === '' ? base.pathname : `${base.pathname}?${search}`;
170
+ };
171
+
172
+ return {
173
+ preset,
174
+ from: window.from,
175
+ to: window.to,
176
+ invalid,
177
+ local: { from: toLocalDateTime(window.from, zone), to: toLocalDateTime(window.to, zone) },
178
+ names,
179
+ hidden: [...base.searchParams.entries()],
180
+ action: base.pathname,
181
+ presets: options.presets,
182
+ timeZone: zone,
183
+ href: (change) =>
184
+ write((next) => {
185
+ if ('preset' in change) {
186
+ if (!presets.has(change.preset)) {
187
+ throw invalidValueError(
188
+ 'timeRangeUrl href preset',
189
+ change.preset,
190
+ 'the id of one of presets',
191
+ );
192
+ }
193
+ if (change.preset !== fallback.id) next.set(names.preset, change.preset);
194
+ return;
195
+ }
196
+ next.set(names.from, toLocalDateTime(instant(change.from), zone));
197
+ next.set(names.to, toLocalDateTime(instant(change.to), zone));
198
+ }),
199
+ };
200
+ }
@@ -38,7 +38,7 @@
38
38
  /// and only takes it out of flow. After a long run of inline text the static position is already
39
39
  /// past the viewport edge; the box then escapes the truncating ancestor's `overflow: hidden`,
40
40
  /// because that ancestor is not positioned and so is not its containing block, and its right edge
41
- /// extends the scrollable area. Measured in ai-maxxing: `Link`'s `.hint` ("opens in a new tab")
41
+ /// extends the scrollable area. Measured in one adopter: `Link`'s `.hint` ("opens in a new tab")
42
42
  /// after truncated titles put seven boxes at right edges of 753–1119px, making the document 1119px
43
43
  /// wide in a 390px viewport and 1480px wide in a 1440px one — a whole page scrolling sideways for
44
44
  /// text no sighted user sees.
@@ -1,6 +1,8 @@
1
1
  // The touch target: the smallest box a finger can hit reliably. WCAG 2.5.5's 44 CSS px, as rem
2
2
  // so a reader's larger text grows the target with it. One rung, in a map so it emits and reads
3
- // back like every other scale.
3
+ // back like every other scale. `safe-area-inset()` below is the device's unsafe edge, as a length.
4
+
5
+ @use 'sass:string';
4
6
 
5
7
  $touch: (
6
8
  target: 2.75rem,
@@ -37,3 +39,30 @@ $touch: (
37
39
  min-inline-size: touch(target);
38
40
  }
39
41
  }
42
+
43
+ /// The device's unsafe edge — the home indicator, a notch, a rounded corner — as a length, `0rem`
44
+ /// where there is none or the page did not opt in (`viewport-fit=cover`). Logical names: the
45
+ /// block edges map to `env()`'s top and bottom; `inline` is the larger side inset, applied to both
46
+ /// inline edges, since `env()` is physical and a notch can sit on either side.
47
+ @function safe-area-inset($edge) {
48
+ @if $edge == block-start {
49
+ @return env(safe-area-inset-top, 0rem);
50
+ }
51
+ @if $edge == block-end {
52
+ @return env(safe-area-inset-bottom, 0rem);
53
+ }
54
+ @if $edge == inline {
55
+ @return max(env(safe-area-inset-left, 0rem), env(safe-area-inset-right, 0rem));
56
+ }
57
+ @error string.unquote('X_UI_INVALID_VALUE: safe-area-inset("#{$edge}") is not an edge. fix: write safe-area-inset(block-end), safe-area-inset(block-start) or safe-area-inset(inline)');
58
+ }
59
+
60
+ /// Padding that clears the unsafe edge on top of `$base`: a bottom bar keeps its own padding AND
61
+ /// sits above the home indicator. `$edge` is `block-start`, `block-end` or `inline`.
62
+ @mixin safe-area-padding($edge, $base: 0rem) {
63
+ @if $edge == inline {
64
+ padding-inline: calc(#{$base} + #{safe-area-inset(inline)});
65
+ } @else {
66
+ padding-#{$edge}: calc(#{$base} + #{safe-area-inset($edge)});
67
+ }
68
+ }
@@ -73,4 +73,16 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
73
73
  ...CHART_ROLES.flatMap((role) =>
74
74
  onSurfaces(role, AA_LARGE, `the ${role} series mark — a bar, line or slice — on a surface`),
75
75
  ),
76
+ // A `tone` replaces the slot colour on a series, a Gauge fill, a HeatStrip cell or a reference
77
+ // line: the same non-text floor. Text pairs above already hold most of these at 4.5:1; this
78
+ // names the use, so relaxing a text pairing cannot quietly drop a mark below 3:1.
79
+ ...(['fg-strong', 'accent', ...STATUS] as const).flatMap((role) =>
80
+ onSurfaces(role, AA_LARGE, `a ${role}-toned chart mark — series, fill, cell — on a surface`),
81
+ ),
82
+ // `TerminalView` writes ANSI magenta and cyan as these two series roles, and it is TEXT on the
83
+ // code surface (`bg-soft`): 4.5:1, not a mark's 3:1. `chart-6` measured 3.37 there, so cyan is
84
+ // `chart-1`. Every other code and terminal colour is a role already held as text above.
85
+ ...(['chart-1', 'chart-5'] as const).map((role) =>
86
+ pair(role, 'bg-soft', AA_TEXT, `ANSI ${role} terminal text on the code surface`),
87
+ ),
76
88
  ];
package/src/ui-keys.ts ADDED
@@ -0,0 +1,126 @@
1
+ // Every string the design system needs but cannot receive as a prop, one export per key — the one
2
+ // list. Read as `UI_KEYS.<name>` (`i18n-keys.ts` imports this module as a namespace), so a bundler
3
+ // keeps exactly the keys an island's components read: an object literal kept all of them.
4
+
5
+ export const close = 'ui.close';
6
+ export const cancel = 'ui.cancel';
7
+ export const confirm = 'ui.confirm';
8
+ export const dismiss = 'ui.dismiss';
9
+ export const loading = 'ui.loading';
10
+ export const empty = 'ui.empty';
11
+ // `ui.error` cannot be a leaf: `ui.error.code|cause|fix` are branches under it, and a catalog is
12
+ // authored nested — `parseNestedCatalog` refuses a dot inside a key, so a name is a leaf or a
13
+ // branch, never both. Same reason `theme` is `ui.theme.label`.
14
+ export const error = 'ui.error.title';
15
+ export const retry = 'ui.retry';
16
+ export const next = 'ui.next';
17
+ export const previous = 'ui.previous';
18
+ /** Pagination: back to the first page — the one way back a keyset list has. */
19
+ export const first = 'ui.first';
20
+ export const page = 'ui.page';
21
+ export const sortAscending = 'ui.sort.ascending';
22
+ export const sortDescending = 'ui.sort.descending';
23
+ export const breadcrumb = 'ui.breadcrumb';
24
+ /** AppShell's skip link — the first Tab stop on every page. */
25
+ export const skip = 'ui.skip';
26
+ /** AppShell's sidebar landmark name. */
27
+ export const navigation = 'ui.navigation';
28
+ export const menu = 'ui.menu';
29
+ export const more = 'ui.more';
30
+ export const required = 'ui.required';
31
+ export const optional = 'ui.optional';
32
+ export const theme = 'ui.theme.label';
33
+ export const themeLight = 'ui.theme.light';
34
+ export const themeDark = 'ui.theme.dark';
35
+ export const themeSystem = 'ui.theme.system';
36
+ export const language = 'ui.language';
37
+ /** Combobox: how many suggestions the datalist currently offers. Takes `{ count }`. */
38
+ export const suggestions = 'ui.suggestions';
39
+ /** Combobox: the query matched nothing. */
40
+ export const noResults = 'ui.results.empty';
41
+ /** InfiniteScroll: the label on the next-page control. */
42
+ export const loadMore = 'ui.load.more';
43
+ /** InfiniteScroll: announced when the last page has arrived. */
44
+ export const endOfList = 'ui.load.end';
45
+ /** CopyButton: the control's name before a copy. */
46
+ export const copy = 'ui.copy';
47
+ /** CopyButton: announced, and the control's name, once the write succeeded. */
48
+ export const copied = 'ui.copied';
49
+ export const errorCode = 'ui.error.code';
50
+ export const errorCause = 'ui.error.cause';
51
+ export const errorFix = 'ui.error.fix';
52
+ /** SearchableSelect: the field's text when nothing is chosen, and a required one's refusal. */
53
+ export const selectNone = 'ui.select.none';
54
+ /** SearchableSelect: the list is capped. Takes `{ shown, total }`. */
55
+ export const selectMore = 'ui.select.more';
56
+ /** SearchableSelect: a chip's remove control. Takes `{ label }`. */
57
+ export const selectRemove = 'ui.select.remove';
58
+ /** SearchableSelect: the control that empties a many-valued selection. */
59
+ export const selectClear = 'ui.select.clear';
60
+ /** Charts: a gap — the value is unknown, never zero. The data table's cell and a Gauge's readout. */
61
+ export const chartNone = 'ui.chart.none';
62
+ /** LiveIndicator: the four states of a live view, each one word beside a shape. */
63
+ export const liveLive = 'ui.live.live';
64
+ export const liveReconnecting = 'ui.live.reconnecting';
65
+ export const livePaused = 'ui.live.paused';
66
+ export const liveOffline = 'ui.live.offline';
67
+ /** VirtualList: the one way back for a reader who scrolled up from a list that follows its end. */
68
+ export const listToEnd = 'ui.list.toEnd';
69
+ /** Tree: a node whose children `onExpand` is still fetching. */
70
+ export const treeLoading = 'ui.tree.loading';
71
+ /** DatePicker: the button that opens the calendar beside the native input. */
72
+ export const dateOpen = 'ui.date.open';
73
+ /** Calendar: the month controls. */
74
+ export const datePreviousMonth = 'ui.date.previousMonth';
75
+ export const dateNextMonth = 'ui.date.nextMonth';
76
+ /** DateRangePicker: the button that opens the calendar beside the two inputs. */
77
+ export const rangeOpen = 'ui.range.open';
78
+ /** DateRangePicker/TimeRangePicker: the two ends' labels. */
79
+ export const rangeFrom = 'ui.range.from';
80
+ export const rangeTo = 'ui.range.to';
81
+ /** TimeRangePicker: the custom window's submit. */
82
+ export const rangeApply = 'ui.range.apply';
83
+ /** TimeRangePicker: the disclosure holding the custom window's form. */
84
+ export const rangeCustom = 'ui.range.custom';
85
+ /** TimeRangePicker: the URL's window was malformed or backwards, and the default is shown. */
86
+ export const rangeInvalid = 'ui.range.invalid';
87
+ /** ShortcutSheet: its title, and the legend line of the `?` that opens it. */
88
+ export const shortcuts = 'ui.shortcuts';
89
+ /** Stepper, below `sm`: takes `{ current, total, label }`. */
90
+ export const step = 'ui.step';
91
+ /** PullToRefresh: the hint while pulling, past the threshold, and while the refresh runs. */
92
+ export const refreshPull = 'ui.refresh.pull';
93
+ export const refreshRelease = 'ui.refresh.release';
94
+ export const refreshBusy = 'ui.refresh.busy';
95
+ /** SortableList/Board: the drag handle's name, and the no-script move submit. */
96
+ export const sortHandle = 'ui.sort.handle';
97
+ /** Board, no script: the target column `Select`'s label. */
98
+ export const sortTarget = 'ui.sort.target';
99
+ /** SortableList/Board: announced on lift, each step, the drop and a cancel or refused move. Each takes `{ position, total, group }`. */
100
+ export const sortLift = 'ui.sort.lift';
101
+ export const sortMoved = 'ui.sort.moved';
102
+ export const sortDropped = 'ui.sort.dropped';
103
+ export const sortCancelled = 'ui.sort.cancelled';
104
+ /** SortableList, no script: the two form buttons beside each row. */
105
+ export const sortUp = 'ui.sort.up';
106
+ export const sortDown = 'ui.sort.down';
107
+ /** SplitPane: the separator's name. */
108
+ export const paneResize = 'ui.pane.resize';
109
+ /** CodeBlock: the scroll region's name, and the wrap toggle. */
110
+ export const codeRegion = 'ui.code.region';
111
+ export const codeWrap = 'ui.code.wrap';
112
+ /** TerminalView: the output region's name. */
113
+ export const codeTerminal = 'ui.code.terminal';
114
+ /** DiffView: the region, the spoken gutters, the next-change control and a fold. `folded` takes `{ count }`. */
115
+ export const diffRegion = 'ui.diff.region';
116
+ export const diffAdded = 'ui.diff.added';
117
+ export const diffRemoved = 'ui.diff.removed';
118
+ export const diffNext = 'ui.diff.next';
119
+ export const diffFolded = 'ui.diff.folded';
120
+ /** LogViewer: the region, the text filter, the level filter and its "every level", follow, and a cap. `capped` takes `{ count }`. */
121
+ export const logRegion = 'ui.log.region';
122
+ export const logFilter = 'ui.log.filter';
123
+ export const logLevel = 'ui.log.level';
124
+ export const logAll = 'ui.log.all';
125
+ export const logFollow = 'ui.log.follow';
126
+ export const logCapped = 'ui.log.capped';
@@ -0,0 +1,145 @@
1
+ // The Solid shell over `hotkeys-view.ts`: ONE `keydown` listener per page, whatever number of
2
+ // islands register tables. Every island bundles its own copy of this module, so the listener and
3
+ // the merged table live on the document under one `Symbol.for` key, as `refresh-idle.ts` does.
4
+
5
+ import {
6
+ type CompiledHotkeys,
7
+ compileHotkeys,
8
+ type HotkeyRow,
9
+ type HotkeyState,
10
+ type HotkeyTable,
11
+ hotkeyAction,
12
+ } from './hotkeys-view';
13
+ import type { Accessor } from './theme/solid-adapter';
14
+ import { solid } from './theme/solid-adapter';
15
+
16
+ const SLOT: unique symbol = Symbol.for('ultimate.hotkeys');
17
+
18
+ /** Typing targets: a bare key there is text, never a shortcut. */
19
+ export const EDITABLE_SELECTOR =
20
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
21
+
22
+ /** What the slot needs of a document; the tests hand in a fake one. */
23
+ export interface HotkeyHost {
24
+ addEventListener(type: 'keydown', fn: (event: KeyboardEvent) => void): void;
25
+ }
26
+
27
+ interface HotkeySlot {
28
+ readonly tables: HotkeyTable[];
29
+ compiled: CompiledHotkeys;
30
+ state: HotkeyState;
31
+ readonly watchers: Set<() => void>;
32
+ }
33
+
34
+ type SlotHost = HotkeyHost & { [SLOT]?: HotkeySlot };
35
+
36
+ const isMac = (): boolean =>
37
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform);
38
+
39
+ const editableTarget = (target: EventTarget | null): boolean => {
40
+ const element = target as { closest?: (selector: string) => unknown } | null;
41
+ return typeof element?.closest === 'function' && element.closest(EDITABLE_SELECTOR) !== null;
42
+ };
43
+
44
+ /** The page's one slot over `host`, created — with its one listener — on first use. */
45
+ function slotOf(host: HotkeyHost, mac: boolean): HotkeySlot {
46
+ const holder = host as SlotHost;
47
+ const existing = holder[SLOT];
48
+ if (existing !== undefined) return existing;
49
+ const slot: HotkeySlot = {
50
+ tables: [],
51
+ compiled: compileHotkeys([]),
52
+ state: { mac, pending: null, since: 0 },
53
+ watchers: new Set(),
54
+ };
55
+ holder[SLOT] = slot;
56
+ host.addEventListener('keydown', (event) => {
57
+ const answer = hotkeyAction(
58
+ {
59
+ key: event.key,
60
+ ctrlKey: event.ctrlKey,
61
+ metaKey: event.metaKey,
62
+ altKey: event.altKey,
63
+ isComposing: event.isComposing === true,
64
+ editable: editableTarget(event.target),
65
+ at: event.timeStamp,
66
+ },
67
+ slot.state,
68
+ slot.compiled,
69
+ );
70
+ slot.state = answer.state;
71
+ if (answer.row === undefined) return;
72
+ event.preventDefault();
73
+ answer.row.run();
74
+ });
75
+ return slot;
76
+ }
77
+
78
+ const notify = (slot: HotkeySlot): void => {
79
+ for (const watcher of [...slot.watchers]) watcher();
80
+ };
81
+
82
+ /**
83
+ * Add a table to the page's merged one; answers the removal. A binding another table already holds
84
+ * is `X_UI_INVALID_VALUE`, thrown here and the table left out — never two rows for one key.
85
+ */
86
+ export function registerHotkeys(
87
+ host: HotkeyHost,
88
+ table: HotkeyTable,
89
+ mac: boolean = isMac(),
90
+ ): () => void {
91
+ const slot = slotOf(host, mac);
92
+ slot.compiled = compileHotkeys([...slot.tables, table].flat());
93
+ slot.tables.push(table);
94
+ notify(slot);
95
+ return (): void => {
96
+ const at = slot.tables.indexOf(table);
97
+ if (at === -1) return;
98
+ slot.tables.splice(at, 1);
99
+ slot.compiled = compileHotkeys(slot.tables.flat());
100
+ notify(slot);
101
+ };
102
+ }
103
+
104
+ /** Every row registered on the page, in registration order, and whether `mod` is ⌘. */
105
+ export function hotkeyRowsOn(host: HotkeyHost): {
106
+ readonly rows: readonly HotkeyRow[];
107
+ readonly mac: boolean;
108
+ } {
109
+ const slot = (host as SlotHost)[SLOT];
110
+ return { rows: slot?.tables.flat() ?? [], mac: slot?.state.mac ?? isMac() };
111
+ }
112
+
113
+ /** Called whenever the merged table changes; answers the unsubscribe. */
114
+ export function watchHotkeys(host: HotkeyHost, fn: () => void, mac: boolean = isMac()): () => void {
115
+ const slot = slotOf(host, mac);
116
+ slot.watchers.add(fn);
117
+ return (): void => {
118
+ slot.watchers.delete(fn);
119
+ };
120
+ }
121
+
122
+ /** Bind `table` for this component's life. Inert on the server: a shortcut needs a keyboard. */
123
+ export function useHotkeys(table: HotkeyTable): void {
124
+ const rt = solid();
125
+ rt.createEffect(() => {
126
+ if (typeof document === 'undefined') return;
127
+ rt.onCleanup(registerHotkeys(document, table));
128
+ });
129
+ }
130
+
131
+ /** The page's merged rows, kept current — what `ShortcutSheet` lists. */
132
+ export function useHotkeyRows(): Accessor<{
133
+ readonly rows: readonly HotkeyRow[];
134
+ readonly mac: boolean;
135
+ }> {
136
+ const rt = solid();
137
+ const [rows, setRows] = rt.createSignal(hotkeyRowsOn({ addEventListener: () => {} }));
138
+ rt.createEffect(() => {
139
+ if (typeof document === 'undefined') return;
140
+ const read = (): void => setRows(hotkeyRowsOn(document));
141
+ read();
142
+ rt.onCleanup(watchHotkeys(document, read));
143
+ });
144
+ return rows;
145
+ }