@runtypelabs/persona 4.17.1 → 4.18.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 (158) hide show
  1. package/dist/chunk-3I2S3I5P.js +1 -0
  2. package/dist/chunk-76IELU7F.js +1 -0
  3. package/dist/chunk-EFJBUH5F.js +1 -0
  4. package/dist/chunk-PDPVORUF.js +1 -0
  5. package/dist/chunk-Q735PSZL.js +1 -0
  6. package/dist/chunk-VK6JOIYJ.js +1 -0
  7. package/dist/codegen.cjs +4 -4
  8. package/dist/codegen.js +4 -4
  9. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  10. package/dist/context-mentions.cjs +9 -8
  11. package/dist/context-mentions.d.cts +1212 -14
  12. package/dist/context-mentions.d.ts +1212 -14
  13. package/dist/context-mentions.js +9 -8
  14. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  15. package/dist/event-stream-view.cjs +129 -0
  16. package/dist/event-stream-view.d.cts +8384 -0
  17. package/dist/event-stream-view.d.ts +8384 -0
  18. package/dist/event-stream-view.js +129 -0
  19. package/dist/history-view-BD6OXLNA.js +898 -0
  20. package/dist/history-view.cjs +898 -0
  21. package/dist/history-view.d.cts +1461 -0
  22. package/dist/history-view.d.ts +1461 -0
  23. package/dist/history-view.js +898 -0
  24. package/dist/index.cjs +77 -77
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +1987 -17
  27. package/dist/index.d.ts +1987 -17
  28. package/dist/index.global.js +67 -66
  29. package/dist/index.global.js.map +1 -1
  30. package/dist/index.js +77 -77
  31. package/dist/index.js.map +1 -1
  32. package/dist/launcher.global.js +5 -4
  33. package/dist/launcher.global.js.map +1 -1
  34. package/dist/plugin-kit.cjs +1 -1
  35. package/dist/plugin-kit.d.cts +0 -8
  36. package/dist/plugin-kit.d.ts +0 -8
  37. package/dist/plugin-kit.js +1 -1
  38. package/dist/smart-dom-reader.d.cts +1212 -14
  39. package/dist/smart-dom-reader.d.ts +1212 -14
  40. package/dist/theme-editor-preview.cjs +970 -73
  41. package/dist/theme-editor-preview.d.cts +1331 -15
  42. package/dist/theme-editor-preview.d.ts +1331 -15
  43. package/dist/theme-editor-preview.js +81 -81
  44. package/dist/theme-editor.cjs +7 -7
  45. package/dist/theme-editor.d.cts +1215 -15
  46. package/dist/theme-editor.d.ts +1215 -15
  47. package/dist/theme-editor.js +9 -9
  48. package/dist/theme-reference.cjs +1 -1
  49. package/dist/theme-reference.d.cts +226 -7
  50. package/dist/theme-reference.d.ts +226 -7
  51. package/dist/theme-reference.js +1 -1
  52. package/dist/widget.css +1 -1
  53. package/package.json +16 -4
  54. package/src/client-history-rest.test.ts +1034 -0
  55. package/src/client-visitor-history.test.ts +596 -0
  56. package/src/client.ts +1040 -24
  57. package/src/components/artifact-inline.ts +8 -1
  58. package/src/components/artifact-pane.test.ts +278 -1
  59. package/src/components/artifact-pane.ts +135 -67
  60. package/src/components/artifact-preview.test.ts +14 -1
  61. package/src/components/artifact-preview.ts +9 -0
  62. package/src/components/header-builder.ts +3 -0
  63. package/src/components/header-layouts.test.ts +171 -7
  64. package/src/components/header-layouts.ts +83 -53
  65. package/src/components/header-parts.test.ts +154 -1
  66. package/src/components/header-parts.ts +266 -138
  67. package/src/components/history-confirm-dialog.ts +177 -0
  68. package/src/components/history-shell-copy.ts +99 -0
  69. package/src/components/history-view/announcer.ts +40 -0
  70. package/src/components/history-view/copy.ts +105 -0
  71. package/src/components/history-view/css.ts +1078 -0
  72. package/src/components/history-view/grouping.test.ts +142 -0
  73. package/src/components/history-view/grouping.ts +142 -0
  74. package/src/components/history-view/icons.ts +73 -0
  75. package/src/components/history-view/rows.ts +341 -0
  76. package/src/components/history-view/state.ts +309 -0
  77. package/src/components/history-view.test.ts +2660 -0
  78. package/src/components/history-view.ts +1891 -0
  79. package/src/components/launcher-surface.test.ts +133 -0
  80. package/src/components/launcher.test.ts +18 -0
  81. package/src/components/launcher.ts +7 -7
  82. package/src/components/panel.ts +17 -7
  83. package/src/components/welcome.ts +72 -2
  84. package/src/defaults.ts +36 -3
  85. package/src/event-stream-view-bundle.test.ts +69 -0
  86. package/src/event-stream-view-entry.ts +19 -0
  87. package/src/event-stream-view-loader.ts +31 -0
  88. package/src/event-stream-view.ts +19 -0
  89. package/src/generated/runtype-openapi-contract.ts +1 -0
  90. package/src/history-render.ts +325 -0
  91. package/src/history-view-bundle.test.ts +78 -0
  92. package/src/history-view-entry.ts +41 -0
  93. package/src/history-view-loader.test.ts +30 -0
  94. package/src/history-view-loader.ts +32 -0
  95. package/src/history-view.ts +28 -0
  96. package/src/index-core.ts +50 -2
  97. package/src/index-global.ts +55 -0
  98. package/src/internal/demo-history-provider.test.ts +402 -0
  99. package/src/internal/demo-history-provider.ts +689 -0
  100. package/src/internal/history-provider-registry.ts +25 -0
  101. package/src/internal/history-provider.ts +150 -0
  102. package/src/internal/runtype-history-provider.test.ts +313 -0
  103. package/src/internal/runtype-history-provider.ts +240 -0
  104. package/src/plugin-kit.test.ts +16 -0
  105. package/src/plugin-kit.ts +13 -5
  106. package/src/plugins/types.ts +177 -1
  107. package/src/session-history.test.ts +1397 -0
  108. package/src/session.ts +1200 -26
  109. package/src/styles/widget-styles.test.ts +133 -0
  110. package/src/styles/widget.css +227 -8
  111. package/src/theme-editor/preview-utils.test.ts +19 -0
  112. package/src/theme-editor/preview-utils.ts +15 -1
  113. package/src/theme-editor/preview.ts +7 -1
  114. package/src/theme-editor/role-mappings.ts +4 -1
  115. package/src/theme-editor/sections.test.ts +168 -1
  116. package/src/theme-editor/sections.ts +136 -7
  117. package/src/theme-editor/webmcp/summary.ts +16 -2
  118. package/src/theme-reference.ts +20 -6
  119. package/src/types/theme.ts +213 -7
  120. package/src/types.ts +927 -7
  121. package/src/ui.artifact-rail-resize.test.ts +235 -0
  122. package/src/ui.attachments-drop.test.ts +1 -0
  123. package/src/ui.header-update-stability.test.ts +2 -2
  124. package/src/ui.history-chunk.test.ts +72 -0
  125. package/src/ui.history-config-provider.test.ts +318 -0
  126. package/src/ui.history-controller.test.ts +711 -0
  127. package/src/ui.history-lifecycle.test.ts +456 -0
  128. package/src/ui.history-render-hooks.test.ts +904 -0
  129. package/src/ui.history-shell.test.ts +1887 -0
  130. package/src/ui.rail-overlay.test.ts +573 -0
  131. package/src/ui.rail-resize.test.ts +284 -0
  132. package/src/ui.rebuild-composer.test.ts +21 -0
  133. package/src/ui.render-welcome.test.ts +42 -0
  134. package/src/ui.shortcuts.test.ts +166 -0
  135. package/src/ui.ts +3071 -240
  136. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  137. package/src/utils/artifact-file.ts +15 -0
  138. package/src/utils/artifact-gate.test.ts +4 -0
  139. package/src/utils/artifact-gate.ts +13 -17
  140. package/src/utils/artifact-resize.test.ts +28 -0
  141. package/src/utils/artifact-resize.ts +9 -2
  142. package/src/utils/buttons.ts +188 -1
  143. package/src/utils/config-merge.test.ts +91 -0
  144. package/src/utils/dropdown.ts +10 -1
  145. package/src/utils/history-messages.test.ts +284 -0
  146. package/src/utils/history-messages.ts +284 -0
  147. package/src/utils/icons.ts +3 -0
  148. package/src/utils/shortcuts.test.ts +288 -0
  149. package/src/utils/shortcuts.ts +221 -0
  150. package/src/utils/theme.test.ts +501 -4
  151. package/src/utils/theme.ts +42 -3
  152. package/src/utils/tokens.ts +153 -15
  153. package/src/utils/tooltip-timing.ts +4 -0
  154. package/src/utils/tooltip.test.ts +249 -7
  155. package/src/utils/tooltip.ts +146 -4
  156. package/src/utils/visitor-store.test.ts +425 -0
  157. package/src/utils/visitor-store.ts +337 -0
  158. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,288 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ ariaCombo,
7
+ createShortcutRegistry,
8
+ formatCombo,
9
+ parseCombo,
10
+ setMacPlatformOverride,
11
+ } from "./shortcuts";
12
+
13
+ const press = (
14
+ target: EventTarget,
15
+ key: string,
16
+ init: Record<string, unknown> = {}
17
+ ): KeyboardEvent => {
18
+ const event = new KeyboardEvent("keydown", {
19
+ key,
20
+ bubbles: true,
21
+ cancelable: true,
22
+ composed: true,
23
+ ...init,
24
+ });
25
+ target.dispatchEvent(event);
26
+ return event;
27
+ };
28
+
29
+ describe("parseCombo", () => {
30
+ afterEach(() => setMacPlatformOverride(null));
31
+
32
+ it("normalizes modifiers and lowercases the key", () => {
33
+ expect(parseCombo("Mod+B")).toEqual({
34
+ key: "b",
35
+ mod: true,
36
+ shift: false,
37
+ alt: false,
38
+ });
39
+ expect(parseCombo("mod+shift+k")).toEqual({
40
+ key: "k",
41
+ mod: true,
42
+ shift: true,
43
+ alt: false,
44
+ });
45
+ expect(parseCombo("mod+alt+arrowup")).toEqual({
46
+ key: "arrowup",
47
+ mod: true,
48
+ shift: false,
49
+ alt: true,
50
+ });
51
+ });
52
+
53
+ it("maps space to the literal key value the event carries", () => {
54
+ expect(parseCombo("mod+space")?.key).toBe(" ");
55
+ });
56
+
57
+ it("rejects reserved keys, unknown modifiers, and bare keys", () => {
58
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
59
+ expect(parseCombo("mod+enter")).toBeNull();
60
+ expect(parseCombo("mod+escape")).toBeNull();
61
+ expect(parseCombo("hyper+b")).toBeNull();
62
+ expect(parseCombo("b")).toBeNull();
63
+ expect(parseCombo("mod+shift+alt+b")).toBeNull();
64
+ expect(parseCombo("")).toBeNull();
65
+ expect(warn).toHaveBeenCalledTimes(6);
66
+ });
67
+ });
68
+
69
+ describe("formatCombo", () => {
70
+ afterEach(() => setMacPlatformOverride(null));
71
+
72
+ it("uses symbols with no separator on Apple platforms", () => {
73
+ setMacPlatformOverride(true);
74
+ expect(formatCombo("mod+b")).toBe("⌘B");
75
+ expect(formatCombo("mod+shift+k")).toBe("⇧⌘K");
76
+ expect(formatCombo("mod+alt+b")).toBe("⌥⌘B");
77
+ });
78
+
79
+ it("uses named modifiers joined with + elsewhere", () => {
80
+ setMacPlatformOverride(false);
81
+ expect(formatCombo("mod+b")).toBe("Ctrl+B");
82
+ expect(formatCombo("mod+shift+k")).toBe("Ctrl+Shift+K");
83
+ expect(formatCombo("mod+alt+arrowup")).toBe("Ctrl+Alt+ArrowUp");
84
+ });
85
+
86
+ it("formats an invalid combo as empty", () => {
87
+ vi.spyOn(console, "warn").mockImplementation(() => {});
88
+ expect(formatCombo("mod+enter")).toBe("");
89
+ });
90
+ });
91
+
92
+ describe("ariaCombo", () => {
93
+ afterEach(() => setMacPlatformOverride(null));
94
+
95
+ it("names the platform modifier per the ARIA spec, not the display symbol", () => {
96
+ setMacPlatformOverride(true);
97
+ expect(ariaCombo("mod+b")).toBe("Meta+B");
98
+ expect(ariaCombo("mod+shift+k")).toBe("Meta+Shift+K");
99
+ setMacPlatformOverride(false);
100
+ expect(ariaCombo("mod+b")).toBe("Control+B");
101
+ expect(ariaCombo("mod+alt+b")).toBe("Control+Alt+B");
102
+ });
103
+ });
104
+
105
+ describe("createShortcutRegistry", () => {
106
+ let mount: HTMLElement;
107
+ let outside: HTMLElement;
108
+
109
+ beforeEach(() => {
110
+ setMacPlatformOverride(false);
111
+ mount = document.createElement("div");
112
+ outside = document.createElement("div");
113
+ document.body.append(mount, outside);
114
+ });
115
+
116
+ afterEach(() => {
117
+ setMacPlatformOverride(null);
118
+ document.body.innerHTML = "";
119
+ vi.restoreAllMocks();
120
+ });
121
+
122
+ it("fires a widget-scoped binding only from inside the mount", () => {
123
+ const registry = createShortcutRegistry(mount);
124
+ const run = vi.fn();
125
+ registry.register({ id: "collapse", combo: "mod+b", run });
126
+
127
+ const inside = document.createElement("button");
128
+ mount.appendChild(inside);
129
+ press(inside, "b", { ctrlKey: true });
130
+ expect(run).toHaveBeenCalledTimes(1);
131
+
132
+ press(outside, "b", { ctrlKey: true });
133
+ expect(run).toHaveBeenCalledTimes(1);
134
+ registry.destroy();
135
+ });
136
+
137
+ it("resolves the widget scope through a shadow boundary", () => {
138
+ const registry = createShortcutRegistry(mount);
139
+ const run = vi.fn();
140
+ registry.register({ id: "collapse", combo: "mod+b", run });
141
+
142
+ const shadow = mount.attachShadow({ mode: "open" });
143
+ const inner = document.createElement("button");
144
+ shadow.appendChild(inner);
145
+ press(inner, "b", { ctrlKey: true });
146
+ expect(run).toHaveBeenCalledTimes(1);
147
+ registry.destroy();
148
+ });
149
+
150
+ it("fires a page-scoped binding from anywhere", () => {
151
+ const registry = createShortcutRegistry(mount);
152
+ const run = vi.fn();
153
+ registry.register({ id: "collapse", combo: "mod+b", scope: "page", run });
154
+
155
+ press(outside, "b", { ctrlKey: true });
156
+ press(document, "b", { ctrlKey: true });
157
+ expect(run).toHaveBeenCalledTimes(2);
158
+ registry.destroy();
159
+ });
160
+
161
+ it("preventDefaults only the keypress it handles", () => {
162
+ const registry = createShortcutRegistry(mount);
163
+ registry.register({ id: "collapse", combo: "mod+b", run: () => {} });
164
+ const inside = document.createElement("button");
165
+ mount.appendChild(inside);
166
+
167
+ expect(press(inside, "b", { ctrlKey: true }).defaultPrevented).toBe(true);
168
+ expect(press(inside, "b").defaultPrevented).toBe(false);
169
+ expect(
170
+ press(inside, "b", { ctrlKey: true, shiftKey: true }).defaultPrevented
171
+ ).toBe(false);
172
+ // The other platform's modifier must be clear.
173
+ expect(
174
+ press(inside, "b", { ctrlKey: true, metaKey: true }).defaultPrevented
175
+ ).toBe(false);
176
+ registry.destroy();
177
+ });
178
+
179
+ it("skips text entry unless the binding opts in", () => {
180
+ const registry = createShortcutRegistry(mount);
181
+ const run = vi.fn();
182
+ const allowed = vi.fn();
183
+ registry.register({ id: "collapse", combo: "mod+b", run });
184
+ registry.register({ id: "send", combo: "mod+k", run: allowed, allowInInput: true });
185
+
186
+ const input = document.createElement("input");
187
+ const editable = document.createElement("div");
188
+ editable.setAttribute("contenteditable", "true");
189
+ Object.defineProperty(editable, "isContentEditable", { value: true });
190
+ mount.append(input, editable);
191
+
192
+ press(input, "b", { ctrlKey: true });
193
+ press(editable, "b", { ctrlKey: true });
194
+ expect(run).not.toHaveBeenCalled();
195
+
196
+ press(input, "k", { ctrlKey: true });
197
+ expect(allowed).toHaveBeenCalledTimes(1);
198
+ registry.destroy();
199
+ });
200
+
201
+ it("skips a keypress mid-composition", () => {
202
+ const registry = createShortcutRegistry(mount);
203
+ const run = vi.fn();
204
+ registry.register({ id: "collapse", combo: "mod+b", run });
205
+ press(mount, "b", { ctrlKey: true, isComposing: true });
206
+ expect(run).not.toHaveBeenCalled();
207
+ registry.destroy();
208
+ });
209
+
210
+ it("honors the when() gate without unregistering", () => {
211
+ const registry = createShortcutRegistry(mount);
212
+ const run = vi.fn();
213
+ let ready = false;
214
+ registry.register({ id: "collapse", combo: "mod+b", run, when: () => ready });
215
+
216
+ press(mount, "b", { ctrlKey: true });
217
+ expect(run).not.toHaveBeenCalled();
218
+ ready = true;
219
+ press(mount, "b", { ctrlKey: true });
220
+ expect(run).toHaveBeenCalledTimes(1);
221
+ registry.destroy();
222
+ });
223
+
224
+ it("warns once for a duplicate combo and keeps the first binding", () => {
225
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
226
+ const registry = createShortcutRegistry(mount);
227
+ const first = vi.fn();
228
+ const second = vi.fn();
229
+ registry.register({ id: "first", combo: "mod+b", run: first });
230
+ const unregisterSecond = registry.register({
231
+ id: "second",
232
+ combo: "mod+b",
233
+ run: second,
234
+ });
235
+ expect(warn).toHaveBeenCalledTimes(1);
236
+
237
+ press(mount, "b", { ctrlKey: true });
238
+ expect(first).toHaveBeenCalledTimes(1);
239
+ expect(second).not.toHaveBeenCalled();
240
+
241
+ // The rejected duplicate must not take the winner's slot down with it.
242
+ unregisterSecond();
243
+ press(mount, "b", { ctrlKey: true });
244
+ expect(first).toHaveBeenCalledTimes(2);
245
+ registry.destroy();
246
+ });
247
+
248
+ it("stamps aria-keyshortcuts while a bound element is registered", () => {
249
+ const registry = createShortcutRegistry(mount);
250
+ const button = document.createElement("button");
251
+ mount.appendChild(button);
252
+ const unregister = registry.register({
253
+ id: "collapse",
254
+ combo: "mod+b",
255
+ run: () => {},
256
+ element: button,
257
+ });
258
+ expect(button.getAttribute("aria-keyshortcuts")).toBe("Control+B");
259
+ unregister();
260
+ expect(button.hasAttribute("aria-keyshortcuts")).toBe(false);
261
+ registry.destroy();
262
+ });
263
+
264
+ it("stops firing after unregister and after destroy", () => {
265
+ const registry = createShortcutRegistry(mount);
266
+ const run = vi.fn();
267
+ const unregister = registry.register({ id: "collapse", combo: "mod+b", run });
268
+ press(mount, "b", { ctrlKey: true });
269
+ unregister();
270
+ press(mount, "b", { ctrlKey: true });
271
+ expect(run).toHaveBeenCalledTimes(1);
272
+
273
+ registry.register({ id: "collapse", combo: "mod+b", run });
274
+ registry.destroy();
275
+ press(mount, "b", { ctrlKey: true });
276
+ expect(run).toHaveBeenCalledTimes(1);
277
+ });
278
+
279
+ it("ignores a binding whose combo never parsed", () => {
280
+ vi.spyOn(console, "warn").mockImplementation(() => {});
281
+ const registry = createShortcutRegistry(mount);
282
+ const run = vi.fn();
283
+ registry.register({ id: "bad", combo: "mod+enter", run });
284
+ press(mount, "Enter", { ctrlKey: true });
285
+ expect(run).not.toHaveBeenCalled();
286
+ registry.destroy();
287
+ });
288
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Keyboard shortcuts: one declaration, three artifacts that cannot drift — the
3
+ * keydown binding, the tooltip hint chip (`formatCombo`), and the control's
4
+ * `aria-keyshortcuts` value (`ariaCombo`).
5
+ *
6
+ * Escape is reserved: dismissal stays on the surfaces that own it and never
7
+ * passes through this registry.
8
+ */
9
+
10
+ /** Parsed combo. `key` is lowercased so it compares against `event.key`. */
11
+ export interface ShortcutCombo {
12
+ key: string;
13
+ /** Meta on Apple platforms, Control everywhere else. */
14
+ mod: boolean;
15
+ shift: boolean;
16
+ alt: boolean;
17
+ }
18
+
19
+ export type ShortcutScope = "widget" | "page";
20
+
21
+ export interface ShortcutRegistration {
22
+ /** Stable identity, used only in warnings. */
23
+ id: string;
24
+ /** e.g. `"mod+b"`, `"mod+shift+k"`. */
25
+ combo: string;
26
+ run: (event: KeyboardEvent) => void;
27
+ /** Returning false skips the binding for this keypress. */
28
+ when?: () => boolean;
29
+ /** Default `"widget"`: fires only from inside the widget mount. */
30
+ scope?: ShortcutScope;
31
+ /** Default false: text entry swallows the combo. */
32
+ allowInInput?: boolean;
33
+ /** Stamped with `aria-keyshortcuts` while registered. */
34
+ element?: HTMLElement | null;
35
+ }
36
+
37
+ export interface ShortcutRegistry {
38
+ /** Returns an unregister function. A duplicate combo keeps the first. */
39
+ register(entry: ShortcutRegistration): () => void;
40
+ destroy(): void;
41
+ }
42
+
43
+ let platformOverride: boolean | null = null;
44
+
45
+ /** Test seam. `null` restores platform sniffing. */
46
+ export const setMacPlatformOverride = (value: boolean | null): void => {
47
+ platformOverride = value;
48
+ };
49
+
50
+ /** True where the "mod" modifier means Command rather than Control. */
51
+ export const isMacPlatform = (): boolean => {
52
+ if (platformOverride !== null) return platformOverride;
53
+ if (typeof navigator === "undefined") return false;
54
+ const agent = navigator as Navigator & {
55
+ userAgentData?: { platform?: string };
56
+ };
57
+ const raw = agent.userAgentData?.platform ?? navigator.platform ?? "";
58
+ return /mac|iphone|ipad|ipod/i.test(raw);
59
+ };
60
+
61
+ /** Key values whose canonical casing a title-case fallback would get wrong. */
62
+ const KEY_NAMES = "ArrowUp ArrowDown ArrowLeft ArrowRight PageUp PageDown Space";
63
+
64
+ const warn = (why: string, combo: string): void => {
65
+ console.warn(`[persona] ${why} shortcut: ${combo}`);
66
+ };
67
+
68
+ /**
69
+ * Parse `"mod+shift+b"` into a normalized combo. Returns null (with a warning)
70
+ * for an unknown modifier, a missing or reserved key, or more than one non-mod
71
+ * modifier.
72
+ */
73
+ export const parseCombo = (combo: string): ShortcutCombo | null => {
74
+ const parts = combo.toLowerCase().split("+").map((part) => part.trim());
75
+ const key = parts.pop() as string;
76
+ const parsed: ShortcutCombo = { key, mod: false, shift: false, alt: false };
77
+ for (const part of parts) {
78
+ if (part === "mod" || part === "cmd" || part === "ctrl") parsed.mod = true;
79
+ else if (part === "shift") parsed.shift = true;
80
+ else if (part === "alt" || part === "option") parsed.alt = true;
81
+ else return warn("unknown modifier in", combo), null;
82
+ }
83
+ // v1 takes "mod" plus at most one of shift/alt. Enter and Escape are
84
+ // reserved: submit and dismissal keep their own paths.
85
+ if (
86
+ !parsed.mod ||
87
+ (parsed.shift && parsed.alt) ||
88
+ /^(enter|esc(ape)?)$/.test(key) ||
89
+ !/^([^+\s]|[a-z]{2,12})$/.test(key)
90
+ ) {
91
+ return warn("unsupported", combo), null;
92
+ }
93
+ // `event.key` for the spacebar is a literal space.
94
+ if (key === "space") parsed.key = " ";
95
+ return parsed;
96
+ };
97
+
98
+ const keyName = (key: string): string =>
99
+ KEY_NAMES.split(" ").find((name) => name.toLowerCase() === key) ??
100
+ (key === " "
101
+ ? "Space"
102
+ : key.length === 1
103
+ ? key.toUpperCase()
104
+ : key[0].toUpperCase() + key.slice(1));
105
+
106
+ /** Every combo carries "mod", so only shift/alt vary below. */
107
+ const format = (combo: string, aria: boolean): string => {
108
+ const parsed = parseCombo(combo);
109
+ if (!parsed) return "";
110
+ const mac = isMacPlatform();
111
+ const key = keyName(parsed.key);
112
+ if (!aria && mac) {
113
+ return `${parsed.alt ? "⌥" : ""}${parsed.shift ? "⇧" : ""}⌘${key}`;
114
+ }
115
+ const parts = [aria ? (mac ? "Meta" : "Control") : "Ctrl"];
116
+ if (parsed.alt) parts.push("Alt");
117
+ if (parsed.shift) parts.push("Shift");
118
+ parts.push(key);
119
+ return parts.join("+");
120
+ };
121
+
122
+ /**
123
+ * Display string for a tooltip hint chip: `"⌘B"` / `"⇧⌘B"` on Apple platforms
124
+ * (symbols, no separator), `"Ctrl+B"` / `"Ctrl+Shift+B"` elsewhere. An
125
+ * unparseable combo formats as an empty string.
126
+ */
127
+ export const formatCombo = (combo: string): string => format(combo, false);
128
+
129
+ /**
130
+ * `aria-keyshortcuts` value, which names modifiers per the ARIA spec rather
131
+ * than the platform's display symbols: `"Meta+B"` on Apple, `"Control+B"`
132
+ * elsewhere.
133
+ */
134
+ export const ariaCombo = (combo: string): string => format(combo, true);
135
+
136
+ const matches = (combo: ShortcutCombo, event: KeyboardEvent): boolean =>
137
+ event.key?.toLowerCase() === combo.key &&
138
+ (isMacPlatform() ? event.metaKey : event.ctrlKey) === combo.mod &&
139
+ // The other platform's modifier must be clear, or ctrl+cmd+b would fire.
140
+ !(isMacPlatform() ? event.ctrlKey : event.metaKey) &&
141
+ event.shiftKey === combo.shift &&
142
+ event.altKey === combo.alt;
143
+
144
+ const isTextEntry = (node: EventTarget | null): boolean => {
145
+ if (!(node instanceof HTMLElement)) return false;
146
+ const tag = node.tagName;
147
+ return (
148
+ tag === "INPUT" ||
149
+ tag === "TEXTAREA" ||
150
+ tag === "SELECT" ||
151
+ node.isContentEditable
152
+ );
153
+ };
154
+
155
+ /**
156
+ * One keydown listener for the whole widget instance. `scopeElement` is the
157
+ * widget mount: `"widget"` bindings only fire from a target inside it (shadow
158
+ * DOM included, via `composedPath`), `"page"` bindings fire anywhere.
159
+ */
160
+ export const createShortcutRegistry = (
161
+ scopeElement: Element | null
162
+ ): ShortcutRegistry => {
163
+ const entries = new Map<string, ShortcutRegistration & { parsed: ShortcutCombo }>();
164
+ const doc = scopeElement?.ownerDocument ?? (typeof document !== "undefined" ? document : null);
165
+
166
+ const inScope = (entry: ShortcutRegistration, event: KeyboardEvent): boolean => {
167
+ if ((entry.scope ?? "widget") === "page") return true;
168
+ if (!scopeElement) return false;
169
+ const path = event.composedPath?.();
170
+ if (path?.length) return path.includes(scopeElement);
171
+ return scopeElement.contains(event.target as Node | null);
172
+ };
173
+
174
+ const onKeyDown = (event: KeyboardEvent): void => {
175
+ if (event.isComposing || !entries.size) return;
176
+ const target = event.composedPath?.()[0] ?? event.target;
177
+ for (const entry of entries.values()) {
178
+ if (!matches(entry.parsed, event)) continue;
179
+ if (entry.when?.() === false) continue;
180
+ if (!inScope(entry, event)) continue;
181
+ if (!entry.allowInInput && isTextEntry(target)) continue;
182
+ event.preventDefault();
183
+ try {
184
+ entry.run(event);
185
+ } catch (error) {
186
+ warn(`${entry.id} threw on`, entry.combo);
187
+ console.warn(error);
188
+ }
189
+ return;
190
+ }
191
+ };
192
+
193
+ doc?.addEventListener("keydown", onKeyDown);
194
+
195
+ return {
196
+ register(entry) {
197
+ const parsed = parseCombo(entry.combo);
198
+ if (!parsed) return () => {};
199
+ const slot = `${parsed.mod ? "m" : ""}${parsed.shift ? "s" : ""}${
200
+ parsed.alt ? "a" : ""
201
+ }${parsed.key}`;
202
+ const existing = entries.get(slot);
203
+ if (existing) {
204
+ // First registration wins; the loser is dropped, not queued.
205
+ warn(`${existing.id} already owns`, entry.combo);
206
+ return () => {};
207
+ }
208
+ entries.set(slot, { ...entry, parsed });
209
+ entry.element?.setAttribute("aria-keyshortcuts", ariaCombo(entry.combo));
210
+ return () => {
211
+ // Only the owner clears the slot: a rejected duplicate must not.
212
+ if (entries.get(slot)?.id === entry.id) entries.delete(slot);
213
+ entry.element?.removeAttribute("aria-keyshortcuts");
214
+ };
215
+ },
216
+ destroy() {
217
+ entries.clear();
218
+ doc?.removeEventListener("keydown", onKeyDown);
219
+ },
220
+ };
221
+ };