@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
@@ -1,12 +1,9 @@
1
1
  // The ONE path grammar a form speaks, in both directions: the dotted, bracketed string a schema
2
- // issue carries (`items[2].price`) and the `name` attribute the control renders. One grammar is
3
- // what lets a rejection find its control at all — two would be a hand-written mapping table per
4
- // form, which is the 40-forms-40-mappings cost this binding exists to delete.
5
-
6
- /** A Standard Schema issue segment. Wrapped or bare — conforming libraries send both. */
7
- export interface IssuePathSegment {
8
- readonly key: PropertyKey;
9
- }
2
+ // issue carries (`items[2].price`, rendered by `@ultimat3/schema`'s `formatPath`, never copied)
3
+ // and the `name` attribute the control renders.
4
+ //
5
+ // One grammar is what lets a rejection find its control at all — two would be a hand-written
6
+ // mapping table per form, which is the 40-forms-40-mappings cost this binding exists to delete.
10
7
 
11
8
  export type FieldPathSegment = string | number;
12
9
 
@@ -16,31 +13,8 @@ export type FieldPathSegment = string | number;
16
13
  */
17
14
  export const MAX_FIELD_INDEX = 10_000;
18
15
 
19
- /**
20
- * Structural copy of `@ultimat3/schema`'s `formatPath`. **Keep in sync**: an issue that arrives
21
- * from the server was rendered by THAT function, so a divergence here is an error that lands on no
22
- * field. Copied rather than imported because `@ultimat3/ui` holds no dependency edge on
23
- * `@ultimat3/schema` — the tier table permits one, the manifest and the lockfile do not — the same
24
- * trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down.
25
- */
26
- export function formatFieldPath(
27
- path: readonly (PropertyKey | IssuePathSegment)[] | undefined,
28
- ): string {
29
- if (path === undefined || path.length === 0) return '';
30
- let out = '';
31
- for (const segment of path) {
32
- const key = typeof segment === 'object' ? segment.key : segment;
33
- if (typeof key === 'number') {
34
- out += `[${key}]`;
35
- } else {
36
- out += out === '' ? String(key) : `.${String(key)}`;
37
- }
38
- }
39
- return out;
40
- }
41
-
42
16
  const KEY = /^[A-Za-z_$][A-Za-z0-9_$]*/;
43
- /** `[0]`, never `[01]`: an index that does not round-trip through `formatFieldPath` is not one. */
17
+ /** `[0]`, never `[01]`: an index that does not round-trip through schema's `formatPath` is not one. */
44
18
  const INDEX = /^\[(0|[1-9][0-9]*)\]/;
45
19
 
46
20
  /**
@@ -51,7 +25,7 @@ const INDEX = /^\[(0|[1-9][0-9]*)\]/;
51
25
  const UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'prototype', 'constructor']);
52
26
 
53
27
  /**
54
- * The reverse of `formatFieldPath`, or `null` for anything that is not a field path. Total and
28
+ * The reverse of `@ultimat3/schema`'s `formatPath`, or `null` for anything that is not a field path. Total and
55
29
  * silent by design: the caller decides whether an unusable name is a refusal (`valuesOfForm`, which
56
30
  * is building an object) or simply a path that matches no field (`distributeIssues`, which must
57
31
  * surface it and carry on).
@@ -5,14 +5,10 @@
5
5
  // produced in exactly one place — after the caller's `submit` resolves — and the local parse's
6
6
  // VALUE is discarded, so a client cannot decide either that a form is valid or what it said.
7
7
 
8
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
8
9
  import { invalidFieldPathError } from '../errors';
9
10
  import { parseFieldPath } from './field-path';
10
- import {
11
- type FormIssue,
12
- type FormSchema,
13
- issuesFromRejection,
14
- issuesFromValidation,
15
- } from './form-issue';
11
+ import { type FormIssue, issuesFromRejection, issuesFromValidation } from './form-issue';
16
12
  import {
17
13
  distributeIssues,
18
14
  errorOf,
@@ -43,9 +39,10 @@ export interface FormBindingOptions<TValues, TResult> {
43
39
  /**
44
40
  * The action's `input`. Optional, and a LATENCY optimisation only: the action re-parses on the
45
41
  * server on every path, so omitting this changes when the user hears about a bad value, never
46
- * whether it is rejected.
42
+ * whether it is rejected. Any Standard Schema; its OUTPUT is never read, so a browser cannot
43
+ * choose what the server is asked to store.
47
44
  */
48
- readonly schema?: FormSchema | undefined;
45
+ readonly schema?: StandardSchemaV1 | undefined;
49
46
  /** Called on every transition — how a reactive shell mirrors the state into a signal. */
50
47
  readonly onState?: ((state: FormState<TResult>) => void) | undefined;
51
48
  /**
@@ -3,7 +3,13 @@
3
3
  // — `FormIssue.message` is a schema's diagnostic text, never a translated string.
4
4
 
5
5
  import { renderThrowable, stringField } from '@ultimat3/core';
6
- import { formatFieldPath, type IssuePathSegment, parseFieldPath } from './field-path';
6
+ import {
7
+ formatPath,
8
+ issueRuleFacts,
9
+ type SchemaIssueParams,
10
+ type StandardResult,
11
+ } from '@ultimat3/schema';
12
+ import { parseFieldPath } from './field-path';
7
13
 
8
14
  /**
9
15
  * One rejected value, addressed by the path the schema gave it.
@@ -18,47 +24,30 @@ export interface FormIssue {
18
24
  readonly message: string;
19
25
  /** The framework code that carried it, when a rejection named one. */
20
26
  readonly code: string | undefined;
21
- }
22
-
23
- /** One issue as a conforming schema library reports it. */
24
- export interface FormValidationIssue {
25
- readonly message: string;
26
- readonly path?: readonly (PropertyKey | IssuePathSegment)[] | undefined;
27
+ /**
28
+ * Which rule refused — `@ultimat3/schema`'s `SCHEMA_RULES` (`string.min`), the same from a local
29
+ * parse and a server rejection. What `messageFor` keys on, with `params` interpolated. Absent
30
+ * for a library that names none, so a translator falls back to `message`.
31
+ */
32
+ readonly rule?: string;
33
+ /** The declared facts the rule names (`{ min: 3 }`), for interpolation. Scalars only. */
34
+ readonly params?: SchemaIssueParams;
27
35
  }
28
36
 
29
37
  /**
30
- * Standard Schema's result: `issues` present is the failure, absent is the pass.
31
- *
32
- * The success member holds `value: unknown` and nothing reads it — the type says what the runtime
33
- * does. A client-side parse is a latency optimisation, so its OUTPUT is not something the server
34
- * agreed to, and typing it would invite a caller to submit it.
38
+ * A local parse's issues, addressed by path. A result with none answers `[]`. Standard Schema's own
39
+ * result, `@ultimat3/schema`'s types: its success `value` is never read, because a client-side
40
+ * parse is a latency optimisation and its OUTPUT is nothing the server agreed to.
35
41
  */
36
- export type FormValidationResult =
37
- | { readonly value: unknown; readonly issues?: undefined }
38
- | { readonly issues: readonly FormValidationIssue[] };
39
-
40
- /**
41
- * The minimum of an input schema this package needs — Standard Schema's one member, declared
42
- * structurally so `@ultimat3/ui` needs no dependency edge on `@ultimat3/schema`. An `action()`'s
43
- * own `input` satisfies it as written.
44
- *
45
- * The OUTPUT type is deliberately absent: a client-side parse is a latency optimisation, its value
46
- * is thrown away, and typing it would invite a caller to submit it.
47
- */
48
- export interface FormSchema {
49
- readonly '~standard': {
50
- readonly validate: (value: unknown) => FormValidationResult | Promise<FormValidationResult>;
51
- };
52
- }
53
-
54
- /** A local parse's issues, addressed by path. A result with none answers `[]`. */
55
- export function issuesFromValidation(result: FormValidationResult): readonly FormIssue[] {
42
+ export function issuesFromValidation(result: StandardResult<unknown>): readonly FormIssue[] {
56
43
  const issues = result.issues;
57
44
  if (issues === undefined) return [];
58
45
  return issues.map((issue) => ({
59
- path: formatFieldPath(issue.path),
46
+ path: formatPath(issue.path),
60
47
  message: issue.message,
61
48
  code: undefined,
49
+ // Schema's one reader: a non-string rule dropped, scalar params only, total.
50
+ ...issueRuleFacts(issue),
62
51
  }));
63
52
  }
64
53
 
@@ -71,8 +60,8 @@ const LIST_PREFIX = 'failed validation: ';
71
60
  /**
72
61
  * `formatIssues()`'s separator, on both sides of the wire. A message that itself contains `'; '`
73
62
  * splits wrongly here — and degrades VISIBLY, because a fragment whose head is not a field path
74
- * lands at the form rather than on a control. That is the cost of the wire carrying no structured
75
- * issue list; see this file's note in `README.md`.
63
+ * lands at the form rather than on a control. Only for a rejection that lost its `meta.issues`
64
+ * (`toProblem` serves the list since 2026-10); see this file's note in `README.md`.
76
65
  */
77
66
  const ISSUE_SEPARATOR = '; ';
78
67
 
@@ -109,7 +98,12 @@ function issuesFromMeta(rejection: object, code: string | undefined): readonly F
109
98
  for (const entry of held as readonly unknown[]) {
110
99
  const message = stringField(entry, 'message');
111
100
  if (message === undefined) return null;
112
- issues.push({ path: stringField(entry, 'path') ?? '', message, code });
101
+ issues.push({
102
+ path: stringField(entry, 'path') ?? '',
103
+ message,
104
+ code,
105
+ ...issueRuleFacts(entry),
106
+ });
113
107
  }
114
108
  return issues.length === 0 ? null : issues;
115
109
  }
@@ -4,8 +4,9 @@
4
4
  //
5
5
  // A form is user input reaching an object graph, so a name is refused, never repaired.
6
6
 
7
+ import { formatPath } from '@ultimat3/schema';
7
8
  import { conflictingFieldNameError, invalidFieldPathError } from '../errors';
8
- import { type FieldPathSegment, formatFieldPath, parseFieldPath } from './field-path';
9
+ import { type FieldPathSegment, parseFieldPath } from './field-path';
9
10
 
10
11
  type Container = Record<string, unknown> | readonly unknown[];
11
12
 
@@ -52,7 +53,7 @@ function place(
52
53
  const [head, ...rest] = segments;
53
54
  // `parseFieldPath` never answers an empty list, so this is the type's obligation, not a case.
54
55
  if (head === undefined) return;
55
- const at = (): string => formatFieldPath([...prefix, head]);
56
+ const at = (): string => formatPath([...prefix, head]);
56
57
  const existing = childOf(container, head);
57
58
  const next = rest[0];
58
59
 
@@ -0,0 +1,150 @@
1
+ // What a key press means to a page's shortcut table, apart from any listener: never while typing,
2
+ // never with a modifier, a two-key chord inside one window, and `mod+` that fires anywhere. Pure,
3
+ // so `useHotkeys` and `ShortcutSheet` read ONE table and the binding and its legend cannot drift.
4
+
5
+ import { invalidValueError } from './errors';
6
+
7
+ /** One shortcut: the bindings that run it, its already-translated legend line, and the work. */
8
+ export interface HotkeyRow {
9
+ /**
10
+ * Alternatives, each spelled as `KeyboardEvent.key` reports it: `'?'`, `'Escape'`, a chord of
11
+ * two steps `'g r'`, or the platform modifier `'mod+k'` (⌘ on Apple, Ctrl elsewhere).
12
+ */
13
+ readonly keys: readonly string[];
14
+ readonly label: string;
15
+ readonly run: () => void;
16
+ }
17
+
18
+ export type HotkeyTable = readonly HotkeyRow[];
19
+
20
+ /** How long the second key of a chord may follow the first. */
21
+ export const CHORD_WINDOW_MS = 1_000;
22
+
23
+ /** The parts of a `KeyboardEvent` the rule reads; `editable` is the shell's `closest()` answer. */
24
+ export interface HotkeyKeyEvent {
25
+ readonly key: string;
26
+ readonly ctrlKey: boolean;
27
+ readonly metaKey: boolean;
28
+ readonly altKey: boolean;
29
+ readonly isComposing: boolean;
30
+ readonly editable: boolean;
31
+ /** `event.timeStamp` — monotonic, which is all the chord window compares. */
32
+ readonly at: number;
33
+ }
34
+
35
+ export interface HotkeyState {
36
+ /** `mod` is ⌘ on Apple platforms, Ctrl everywhere else. */
37
+ readonly mac: boolean;
38
+ /** The first step of a chord in flight, or `null`. */
39
+ readonly pending: string | null;
40
+ readonly since: number;
41
+ }
42
+
43
+ export const IDLE_HOTKEY_STATE: HotkeyState = { mac: false, pending: null, since: 0 };
44
+
45
+ /** A validated table: every binding to its row, and every chord's first step. */
46
+ export interface CompiledHotkeys {
47
+ readonly bindings: ReadonlyMap<string, HotkeyRow>;
48
+ readonly prefixes: ReadonlySet<string>;
49
+ }
50
+
51
+ const MOD = 'mod+';
52
+ const KEY = /^(?:\S|[A-Z][A-Za-z0-9]+)$/;
53
+
54
+ /** The steps of one binding, or `null` when it is not in the grammar. */
55
+ function stepsOf(binding: string): readonly string[] | null {
56
+ const steps = binding.split(' ');
57
+ if (steps.length > 2) return null;
58
+ for (const step of steps) {
59
+ const key = step.startsWith(MOD) && steps.length === 1 ? step.slice(MOD.length) : step;
60
+ if (!KEY.test(key)) return null;
61
+ }
62
+ return steps;
63
+ }
64
+
65
+ /**
66
+ * Validate and index rows — one table or every island's merged. A binding twice, a chord over two
67
+ * keys, a chord whose first step is also a binding of its own, or `mod+` inside a chord is
68
+ * `X_UI_INVALID_VALUE` naming the keys: each is a shortcut that can never fire as written.
69
+ */
70
+ export function compileHotkeys(rows: HotkeyTable): CompiledHotkeys {
71
+ const bindings = new Map<string, HotkeyRow>();
72
+ const prefixes = new Set<string>();
73
+ const expected =
74
+ "unique bindings of one key ('?'), 'mod+<key>', or a two-key chord ('g r'), none a prefix of another";
75
+ for (const row of rows) {
76
+ if (row.keys.length === 0)
77
+ throw invalidValueError('useHotkeys', `${row.label}: no keys`, expected);
78
+ for (const binding of row.keys) {
79
+ const steps = stepsOf(binding);
80
+ if (steps === null) throw invalidValueError('useHotkeys', binding, expected);
81
+ if (bindings.has(binding)) throw invalidValueError('useHotkeys', binding, expected);
82
+ const first = steps[0] ?? '';
83
+ if (steps.length === 2) {
84
+ if (bindings.has(first)) throw invalidValueError('useHotkeys', first, expected);
85
+ prefixes.add(first);
86
+ } else if (prefixes.has(binding)) {
87
+ throw invalidValueError('useHotkeys', binding, expected);
88
+ }
89
+ bindings.set(binding, row);
90
+ }
91
+ }
92
+ return { bindings, prefixes };
93
+ }
94
+
95
+ export interface HotkeyAnswer {
96
+ /** The row to run, if the key completed one. The shell calls `preventDefault` then `run`. */
97
+ readonly row: HotkeyRow | undefined;
98
+ readonly state: HotkeyState;
99
+ }
100
+
101
+ const idle = (state: HotkeyState): HotkeyState =>
102
+ state.pending === null ? state : { ...state, pending: null };
103
+
104
+ /** What one key press does, given the chord in flight and the table. */
105
+ export function hotkeyAction(
106
+ event: HotkeyKeyEvent,
107
+ state: HotkeyState,
108
+ table: CompiledHotkeys,
109
+ ): HotkeyAnswer {
110
+ // An IME is mid-word: every key belongs to the composition, not the page.
111
+ if (event.isComposing || event.key === 'Process') return { row: undefined, state };
112
+ const mod = state.mac ? event.metaKey : event.ctrlKey;
113
+ const otherMod = state.mac ? event.ctrlKey : event.metaKey;
114
+ if (mod && !otherMod && !event.altKey) {
115
+ return { row: table.bindings.get(`${MOD}${event.key}`), state: idle(state) };
116
+ }
117
+ if (event.ctrlKey || event.metaKey || event.altKey) return { row: undefined, state: idle(state) };
118
+ if (event.editable && event.key !== 'Escape') return { row: undefined, state: idle(state) };
119
+
120
+ if (state.pending !== null && event.at - state.since <= CHORD_WINDOW_MS) {
121
+ const chord = table.bindings.get(`${state.pending} ${event.key}`);
122
+ if (chord !== undefined) return { row: chord, state: idle(state) };
123
+ }
124
+ const single = table.bindings.get(event.key);
125
+ if (single !== undefined) return { row: single, state: idle(state) };
126
+ if (table.prefixes.has(event.key)) {
127
+ return { row: undefined, state: { ...state, pending: event.key, since: event.at } };
128
+ }
129
+ return { row: undefined, state: idle(state) };
130
+ }
131
+
132
+ const NAMED = new Map<string, string>([
133
+ ['Escape', 'Esc'],
134
+ ['ArrowUp', '↑'],
135
+ ['ArrowDown', '↓'],
136
+ ['ArrowLeft', '←'],
137
+ ['ArrowRight', '→'],
138
+ ]);
139
+
140
+ /**
141
+ * A binding as keycaps, one group per chord step: `mod+k` → `[['⌘', 'K']]` on Apple and
142
+ * `[['Ctrl', 'K']]` elsewhere — the glyphs printed on the keys, so they are not translated.
143
+ */
144
+ export function hotkeyKeycaps(binding: string, mac: boolean): readonly (readonly string[])[] {
145
+ return binding.split(' ').map((step) => {
146
+ if (!step.startsWith(MOD)) return [NAMED.get(step) ?? step];
147
+ const key = step.slice(MOD.length);
148
+ return [mac ? '⌘' : 'Ctrl', key.length === 1 ? key.toUpperCase() : (NAMED.get(key) ?? key)];
149
+ });
150
+ }
package/src/i18n-keys.ts CHANGED
@@ -1,53 +1,10 @@
1
- // Every string the design system needs but cannot receive as a prop. Components
2
- // resolve these through the context translator, so an app can localise the whole
3
- // system by adding one namespace to its catalog — and a miss is loud (⟦key⟧).
1
+ // `UI_KEYS`: every string the design system resolves through the context translator, so an app
2
+ // localises the whole system with one catalog namespace (a miss is loud, ⟦key⟧). A NAMESPACE over
3
+ // `ui-keys.ts`, never an object literal: `UI_KEYS.x` is then a static member read a bundler turns
4
+ // into one string, so an island keeps only the keys it reads (`i18n-keys-shake.test.ts`).
4
5
 
5
- export const UI_KEYS = {
6
- close: 'ui.close',
7
- cancel: 'ui.cancel',
8
- confirm: 'ui.confirm',
9
- dismiss: 'ui.dismiss',
10
- loading: 'ui.loading',
11
- empty: 'ui.empty',
12
- // `ui.error` cannot be a leaf: `ui.error.code|cause|fix` are branches under it, and a catalog is
13
- // authored nested — `parseNestedCatalog` refuses a dot inside a key, so a name is a leaf or a
14
- // branch, never both. Same reason `theme` is `ui.theme.label`.
15
- error: 'ui.error.title',
16
- retry: 'ui.retry',
17
- next: 'ui.next',
18
- previous: 'ui.previous',
19
- page: 'ui.page',
20
- sortAscending: 'ui.sort.ascending',
21
- sortDescending: 'ui.sort.descending',
22
- breadcrumb: 'ui.breadcrumb',
23
- /** AppShell's skip link — the first Tab stop on every page. */
24
- skip: 'ui.skip',
25
- /** AppShell's sidebar landmark name. */
26
- navigation: 'ui.navigation',
27
- menu: 'ui.menu',
28
- more: 'ui.more',
29
- required: 'ui.required',
30
- optional: 'ui.optional',
31
- theme: 'ui.theme.label',
32
- themeLight: 'ui.theme.light',
33
- themeDark: 'ui.theme.dark',
34
- themeSystem: 'ui.theme.system',
35
- language: 'ui.language',
36
- /** Combobox: how many suggestions the datalist currently offers. Takes `{ count }`. */
37
- suggestions: 'ui.suggestions',
38
- /** Combobox: the query matched nothing. */
39
- noResults: 'ui.results.empty',
40
- /** InfiniteScroll: the label on the next-page control. */
41
- loadMore: 'ui.load.more',
42
- /** InfiniteScroll: announced when the last page has arrived. */
43
- endOfList: 'ui.load.end',
44
- /** CopyButton: the control's name before a copy. */
45
- copy: 'ui.copy',
46
- /** CopyButton: announced, and the control's name, once the write succeeded. */
47
- copied: 'ui.copied',
48
- errorCode: 'ui.error.code',
49
- errorCause: 'ui.error.cause',
50
- errorFix: 'ui.error.fix',
51
- } as const;
6
+ import * as UI_KEYS from './ui-keys';
7
+
8
+ export { UI_KEYS };
52
9
 
53
10
  export type UiKey = (typeof UI_KEYS)[keyof typeof UI_KEYS];