@gusnips/react 0.9.24 → 0.9.26

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 (116) hide show
  1. package/README.md +173 -36
  2. package/dist/api-client.d.ts +51 -51
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +3 -3
  5. package/dist/api-error.d.ts +11 -11
  6. package/dist/api-error.js +11 -11
  7. package/dist/auth-email.d.ts +40 -0
  8. package/dist/auth-email.d.ts.map +1 -0
  9. package/dist/auth-email.js +106 -0
  10. package/dist/auth-email.js.map +1 -0
  11. package/dist/auth-store.d.ts +23 -23
  12. package/dist/auth-store.js +13 -13
  13. package/dist/commands.d.ts +80 -0
  14. package/dist/commands.d.ts.map +1 -0
  15. package/dist/commands.js +192 -0
  16. package/dist/commands.js.map +1 -0
  17. package/dist/copy.d.ts +3 -3
  18. package/dist/copy.js +3 -3
  19. package/dist/deploy-recovery.d.ts +40 -40
  20. package/dist/deploy-recovery.js +40 -40
  21. package/dist/describe-error.d.ts +68 -68
  22. package/dist/describe-error.d.ts.map +1 -1
  23. package/dist/describe-error.js +5 -5
  24. package/dist/error-boundary.d.ts +12 -12
  25. package/dist/guards.d.ts +15 -15
  26. package/dist/guards.js +3 -3
  27. package/dist/hydrate.d.ts +15 -16
  28. package/dist/hydrate.d.ts.map +1 -1
  29. package/dist/hydrate.js +9 -9
  30. package/dist/hydrate.js.map +1 -1
  31. package/dist/i18n.d.ts +15 -16
  32. package/dist/i18n.d.ts.map +1 -1
  33. package/dist/i18n.js +5 -6
  34. package/dist/i18n.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal-path.d.ts +8 -7
  40. package/dist/internal-path.d.ts.map +1 -1
  41. package/dist/internal-path.js +8 -7
  42. package/dist/internal-path.js.map +1 -1
  43. package/dist/prerender-contract.d.ts +7 -7
  44. package/dist/prerender-contract.js +7 -7
  45. package/dist/query.d.ts +16 -17
  46. package/dist/query.d.ts.map +1 -1
  47. package/dist/query.js +2 -2
  48. package/dist/query.js.map +1 -1
  49. package/dist/sse.d.ts +19 -20
  50. package/dist/sse.d.ts.map +1 -1
  51. package/dist/sse.js +18 -19
  52. package/dist/sse.js.map +1 -1
  53. package/dist/states.d.ts +12 -12
  54. package/dist/states.d.ts.map +1 -1
  55. package/dist/supabase-session.d.ts +47 -48
  56. package/dist/supabase-session.d.ts.map +1 -1
  57. package/dist/supabase-session.js +35 -35
  58. package/dist/supabase-session.js.map +1 -1
  59. package/dist/theme.d.ts +4 -4
  60. package/dist/theme.js +4 -4
  61. package/dist/ui/class-name.d.ts +3 -4
  62. package/dist/ui/class-name.d.ts.map +1 -1
  63. package/dist/ui/class-name.js +3 -4
  64. package/dist/ui/class-name.js.map +1 -1
  65. package/dist/ui/combobox.d.ts +11 -11
  66. package/dist/ui/combobox.d.ts.map +1 -1
  67. package/dist/ui/dialog.d.ts +5 -5
  68. package/dist/ui/dialog.js +2 -2
  69. package/dist/ui/drawer.d.ts +10 -10
  70. package/dist/ui/drawer.d.ts.map +1 -1
  71. package/dist/ui/drawer.js +2 -2
  72. package/dist/ui/input.d.ts +2 -2
  73. package/dist/ui/input.js +2 -2
  74. package/dist/ui/menu.d.ts +9 -9
  75. package/dist/ui/menu.d.ts.map +1 -1
  76. package/dist/ui/menu.js +4 -4
  77. package/dist/ui/menu.js.map +1 -1
  78. package/dist/ui/select.d.ts +12 -12
  79. package/dist/ui/select.d.ts.map +1 -1
  80. package/dist/ui/select.js +3 -3
  81. package/dist/ui/tabs.d.ts +14 -15
  82. package/dist/ui/tabs.d.ts.map +1 -1
  83. package/dist/ui/tabs.js +13 -14
  84. package/dist/ui/tabs.js.map +1 -1
  85. package/package.json +5 -1
  86. package/src/api-client.ts +51 -51
  87. package/src/api-error.ts +11 -11
  88. package/src/auth-email.test.ts +145 -0
  89. package/src/auth-email.ts +112 -0
  90. package/src/auth-store.ts +23 -23
  91. package/src/commands-hook.test.ts +444 -0
  92. package/src/commands.test.ts +245 -0
  93. package/src/commands.ts +268 -0
  94. package/src/copy.ts +3 -3
  95. package/src/deploy-recovery.ts +40 -40
  96. package/src/describe-error.ts +68 -68
  97. package/src/error-boundary.tsx +12 -12
  98. package/src/guards.tsx +15 -15
  99. package/src/hydrate.ts +15 -16
  100. package/src/i18n.ts +15 -16
  101. package/src/index.ts +2 -0
  102. package/src/internal-path.ts +8 -7
  103. package/src/prerender-contract.ts +7 -7
  104. package/src/query.ts +16 -17
  105. package/src/sse.ts +19 -20
  106. package/src/states.ts +12 -12
  107. package/src/supabase-session.ts +46 -47
  108. package/src/theme.ts +4 -4
  109. package/src/ui/class-name.ts +3 -4
  110. package/src/ui/combobox.tsx +11 -11
  111. package/src/ui/dialog.tsx +5 -5
  112. package/src/ui/drawer.tsx +10 -10
  113. package/src/ui/input.tsx +2 -2
  114. package/src/ui/menu.tsx +8 -8
  115. package/src/ui/select.tsx +12 -12
  116. package/src/ui/tabs.tsx +14 -15
@@ -0,0 +1,245 @@
1
+ // @vitest-environment node
2
+ import { createElement } from "react";
3
+ import { renderToString } from "react-dom/server";
4
+ import { describe, expect, expectTypeOf, it, vi } from "vitest";
5
+ import {
6
+ completeCommand,
7
+ matchCommands,
8
+ parseCommand,
9
+ resolveCommand,
10
+ useSlashCommands,
11
+ } from "./commands.ts";
12
+
13
+ const help = {
14
+ name: "help",
15
+ description: "Show available commands",
16
+ aliases: ["assist", "GUIDE"],
17
+ tags: ["manual"],
18
+ };
19
+ const schedule = {
20
+ name: "schedule",
21
+ description: "Run a task later",
22
+ aliases: ["later", "book"],
23
+ tags: ["timer"],
24
+ takesArg: true,
25
+ };
26
+ const commands = [help, schedule];
27
+
28
+ describe("parseCommand", () => {
29
+ it.each([
30
+ { text: "/", expected: { name: "" } },
31
+ { text: "/ ", expected: { name: "", arg: "" } },
32
+ { text: "/HELP", expected: { name: "help" } },
33
+ { text: "/help ", expected: { name: "help", arg: "" } },
34
+ { text: " \t/HeLp", expected: { name: "help" } },
35
+ {
36
+ text: "/SCHEDULE Keep these spaces\tand case ",
37
+ expected: { name: "schedule", arg: "Keep these spaces\tand case " },
38
+ },
39
+ ])("parses a single line: $text", ({ text, expected }) => {
40
+ expect(parseCommand(text)).toEqual(expected);
41
+ });
42
+
43
+ it.each(["/tmp/file", "/help/topic", "//help"])(
44
+ "keeps a slash path as an unknown command token: %s",
45
+ (text) => {
46
+ const name = text.slice(1);
47
+ expect(parseCommand(text)).toEqual({ name });
48
+ expect(resolveCommand(commands, name)).toEqual({ kind: "unknown", name });
49
+ },
50
+ );
51
+
52
+ it.each(["", " \t", "help", "Please /help", "https://example.test/help", "Hello /help there"])(
53
+ "ignores non-command prose: %s",
54
+ (text) => {
55
+ expect(parseCommand(text)).toBeNull();
56
+ },
57
+ );
58
+
59
+ it.each(["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"])(
60
+ "rejects a line terminator anywhere: %j",
61
+ (separator) => {
62
+ for (const text of [`${separator}/help`, `/help${separator}`, `/help ${separator}topic`]) {
63
+ expect(parseCommand(text)).toBeNull();
64
+ }
65
+ },
66
+ );
67
+ });
68
+
69
+ describe("resolveCommand", () => {
70
+ it("prefers an exact canonical name over an earlier alias", () => {
71
+ const alias = { ...schedule, aliases: ["help"] };
72
+ expect(resolveCommand([alias, help], "HELP")).toEqual({ kind: "matched", command: help });
73
+ });
74
+
75
+ it.each([
76
+ { token: "guide", command: help },
77
+ { token: "LATER", command: schedule },
78
+ ])("matches an alias without case sensitivity: $token", ({ token, command }) => {
79
+ expect(resolveCommand(commands, token)).toEqual({ kind: "matched", command });
80
+ });
81
+
82
+ it("reports aliases shared by different commands in registry order", () => {
83
+ const first = { ...schedule, aliases: ["shared", "SHARED"] };
84
+ const second = { ...help, aliases: ["shared"] };
85
+ expect(resolveCommand([first, second], "SHARED")).toEqual({
86
+ kind: "ambiguous",
87
+ name: "SHARED",
88
+ commands: [first, second],
89
+ });
90
+ expect(resolveCommand([first], "shared")).toEqual({ kind: "matched", command: first });
91
+ });
92
+
93
+ it("reports duplicate canonical names even when an alias also matches", () => {
94
+ const duplicate = { ...schedule, name: "HELP" };
95
+ const alias = { ...schedule, aliases: ["help"] };
96
+ expect(resolveCommand([help, alias, duplicate], "help")).toEqual({
97
+ kind: "ambiguous",
98
+ name: "help",
99
+ commands: [help, duplicate],
100
+ });
101
+ });
102
+
103
+ it.each(["manual", "timer", "Show available commands", "hel", "lat", "missing"])(
104
+ "does not execute a tag, description, prefix or unknown token: %s",
105
+ (name) => {
106
+ expect(resolveCommand(commands, name)).toEqual({ kind: "unknown", name });
107
+ },
108
+ );
109
+
110
+ it("never executes an empty token, even if the registry contains one", () => {
111
+ expect(resolveCommand([{ ...help, name: "", aliases: [""] }], "")).toEqual({
112
+ kind: "unknown",
113
+ name: "",
114
+ });
115
+ });
116
+ });
117
+
118
+ describe("matchCommands", () => {
119
+ it("ranks names, aliases, prefixes, tags and descriptions with stable ties", () => {
120
+ const registry = [
121
+ { name: "description", description: "Find a task", aliases: [], tags: [] },
122
+ { name: "tag", description: "", aliases: [], tags: ["finder"] },
123
+ { name: "shortcut", description: "", aliases: ["finder"], tags: [] },
124
+ { name: "finding", description: "", aliases: [], tags: [] },
125
+ { name: "alias", description: "", aliases: ["find"], tags: [] },
126
+ { name: "find", description: "", aliases: [], tags: [] },
127
+ { name: "second-tag", description: "", aliases: [], tags: ["find"] },
128
+ { name: "second-description", description: "Find another task", aliases: [], tags: [] },
129
+ { name: "unrelated", description: "", aliases: [], tags: [] },
130
+ ];
131
+ expect(matchCommands(registry, "find").map((command) => command.name)).toEqual([
132
+ "find",
133
+ "alias",
134
+ "finding",
135
+ "shortcut",
136
+ "tag",
137
+ "second-tag",
138
+ "description",
139
+ "second-description",
140
+ ]);
141
+ });
142
+
143
+ const translated = [
144
+ {
145
+ name: "ação",
146
+ description: "A próxima revisão",
147
+ aliases: ["café"],
148
+ tags: ["rápido"],
149
+ },
150
+ {
151
+ name: "mañana",
152
+ description: "Resumen de la reunión",
153
+ aliases: ["después"],
154
+ tags: ["configuración"],
155
+ },
156
+ ];
157
+ it.each([
158
+ { query: " ACAO ", name: "ação" },
159
+ { query: "ac\u{0327}a\u{0303}o", name: "ação" },
160
+ { query: "CAFE", name: "ação" },
161
+ { query: "RAPIDO", name: "ação" },
162
+ { query: "PROXIMA REVISAO", name: "ação" },
163
+ { query: "MANANA", name: "mañana" },
164
+ { query: "DESPUES", name: "mañana" },
165
+ { query: "CONFIGURACION", name: "mañana" },
166
+ { query: "REUNION", name: "mañana" },
167
+ ])("matches case and accents across search fields: $query", ({ query, name }) => {
168
+ expect(matchCommands(translated, query).map((command) => command.name)).toEqual([name]);
169
+ });
170
+
171
+ it("returns only the first descriptor for each canonical name", () => {
172
+ const duplicate = { ...help, name: "HELP" };
173
+ const registry = [help, schedule, duplicate];
174
+ expect(matchCommands(registry, "help")).toEqual([help]);
175
+ expect(matchCommands(registry, "")).toEqual(commands);
176
+ });
177
+
178
+ it("does not turn repeated or shared aliases into duplicate rows", () => {
179
+ const first = { ...help, aliases: ["shared", "shared", "SHARED"] };
180
+ const second = { ...schedule, aliases: ["shared"] };
181
+ expect(matchCommands([first, second], "shared")).toEqual([first, second]);
182
+ });
183
+
184
+ it("returns original descriptors and preserves their extra fields in the type", () => {
185
+ const custom = { ...help, execute: () => "done" };
186
+ const [matched] = matchCommands([custom], "assist");
187
+ expectTypeOf(matched).toEqualTypeOf<typeof custom | undefined>();
188
+ expect(matched).toBe(custom);
189
+ expect(matched?.execute()).toBe("done");
190
+ });
191
+
192
+ it.each(["", " \t "])("keeps registry order for an empty query: %j", (query) => {
193
+ expect(matchCommands([schedule, help], query)).toEqual([schedule, help]);
194
+ });
195
+
196
+ it("returns no rows for an unrelated query", () => {
197
+ expect(matchCommands(commands, "missing")).toEqual([]);
198
+ });
199
+ });
200
+
201
+ describe("completeCommand", () => {
202
+ it.each([
203
+ ["/later", "/schedule "],
204
+ ["/sche", "/schedule "],
205
+ ["/SCHEDULE", "/schedule "],
206
+ ["/", "/schedule "],
207
+ ["/later ", "/schedule "],
208
+ [" \t/later Keep these spaces\tand case ", "/schedule Keep these spaces\tand case "],
209
+ ])("canonicalizes an argument-taking command: %s", (text, expected) => {
210
+ expect(completeCommand(schedule, text)).toBe(expected);
211
+ });
212
+
213
+ it.each([
214
+ ["/assi", "/help"],
215
+ ["/GUIDE", "/help"],
216
+ ["/", "/help"],
217
+ ["/assist topic with spaces ", "/help topic with spaces "],
218
+ ])("canonicalizes a plain command without dropping its arguments: %s", (text, expected) => {
219
+ expect(completeCommand(help, text)).toBe(expected);
220
+ });
221
+ });
222
+
223
+ describe("useSlashCommands on the server", () => {
224
+ it("imports and renders in Node without browser globals or running callbacks", () => {
225
+ expect(typeof window).toBe("undefined");
226
+ expect(typeof document).toBe("undefined");
227
+ const onChange = vi.fn();
228
+ const onExecute = vi.fn();
229
+ const onError = vi.fn();
230
+
231
+ function Menu() {
232
+ const menu = useSlashCommands({ text: "/he", commands, onChange, onExecute, onError });
233
+ return createElement(
234
+ "output",
235
+ { id: menu.listId },
236
+ `${menu.open}/${menu.active?.name}/${menu.items.length}`,
237
+ );
238
+ }
239
+
240
+ expect(renderToString(createElement(Menu))).toContain("true/help/1");
241
+ expect(onChange).not.toHaveBeenCalled();
242
+ expect(onExecute).not.toHaveBeenCalled();
243
+ expect(onError).not.toHaveBeenCalled();
244
+ });
245
+ });
@@ -0,0 +1,268 @@
1
+ import { useId, useState } from "react";
2
+
3
+ export interface SlashCommand {
4
+ /** One token, without the leading slash. */
5
+ name: string;
6
+ description: string;
7
+ /** Other executable names. Tags only help search. */
8
+ aliases: readonly string[];
9
+ tags: readonly string[];
10
+ takesArg?: boolean;
11
+ }
12
+
13
+ export interface ParsedCommand {
14
+ name: string;
15
+ /** Undefined before a space; empty after a space with no argument yet. */
16
+ arg?: string;
17
+ }
18
+
19
+ /** Multiline text and a slash inside a message are not commands. */
20
+ export function parseCommand(text: string): ParsedCommand | null {
21
+ if (/[\r\n\u{2028}\u{2029}]/u.test(text)) return null;
22
+ const match = /^\s*\/(\S*)(?:[^\S\r\n]+(.*))?$/u.exec(text);
23
+ if (!match) return null;
24
+ const name = (match[1] ?? "").toLowerCase();
25
+ return match[2] === undefined ? { name } : { name, arg: match[2] };
26
+ }
27
+
28
+ export type CommandResolution<T extends SlashCommand = SlashCommand> =
29
+ | { kind: "matched"; command: T }
30
+ | { kind: "unknown"; name: string }
31
+ | { kind: "ambiguous"; name: string; commands: readonly T[] };
32
+
33
+ /** Exact names win over aliases. Search tags never execute an action. */
34
+ export function resolveCommand<T extends SlashCommand>(
35
+ commands: readonly T[],
36
+ name: string,
37
+ ): CommandResolution<T> {
38
+ const token = name.toLowerCase();
39
+ if (!token) return { kind: "unknown", name };
40
+ let matches = commands.filter((command) => command.name.toLowerCase() === token);
41
+ if (matches.length === 0) {
42
+ matches = commands.filter((command) =>
43
+ command.aliases.some((alias) => alias.toLowerCase() === token),
44
+ );
45
+ }
46
+ if (matches.length > 1) return { kind: "ambiguous", name, commands: matches };
47
+ const command = matches[0];
48
+ return command ? { kind: "matched", command } : { kind: "unknown", name };
49
+ }
50
+
51
+ function searchText(text: string): string {
52
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
53
+ }
54
+
55
+ function rank(command: SlashCommand, query: string): number {
56
+ if (!query) return 0;
57
+ const name = searchText(command.name);
58
+ const aliases = command.aliases.map(searchText);
59
+ if (name === query) return 0;
60
+ if (aliases.includes(query)) return 1;
61
+ if (name.startsWith(query)) return 2;
62
+ if (aliases.some((alias) => alias.startsWith(query))) return 3;
63
+ if (command.tags.some((tag) => searchText(tag).includes(query))) return 4;
64
+ if (searchText(command.description).includes(query)) return 5;
65
+ return Infinity;
66
+ }
67
+
68
+ /** One row per name, in registry order when two results have the same rank. */
69
+ export function matchCommands<T extends SlashCommand>(commands: readonly T[], query: string): T[] {
70
+ const token = searchText(query.trim());
71
+ const names = new Set<string>();
72
+ return commands
73
+ .filter((command) => {
74
+ const name = command.name.toLowerCase();
75
+ if (names.has(name)) return false;
76
+ names.add(name);
77
+ return true;
78
+ })
79
+ .map((command) => ({ command, rank: rank(command, token) }))
80
+ .filter((match) => Number.isFinite(match.rank))
81
+ .sort((a, b) => a.rank - b.rank)
82
+ .map((match) => match.command);
83
+ }
84
+
85
+ /** Replace only the name, keeping the argument already typed. */
86
+ export function completeCommand(command: SlashCommand, text: string): string {
87
+ const arg = parseCommand(text)?.arg;
88
+ if (arg !== undefined) return `/${command.name} ${arg}`;
89
+ return `/${command.name}${command.takesArg ? " " : ""}`;
90
+ }
91
+
92
+ export type CommandProblem<T extends SlashCommand = SlashCommand> = Exclude<
93
+ CommandResolution<T>,
94
+ { kind: "matched" }
95
+ >;
96
+
97
+ export interface CommandOptions<T extends SlashCommand> {
98
+ text: string;
99
+ /** Supply only commands allowed here, with current translated descriptions and tags. */
100
+ commands: readonly T[];
101
+ onChange: (text: string) => void;
102
+ /** The app owns authorization, errors, draft cleanup and attachments. */
103
+ onExecute: (command: T, arg: string | undefined, altKey: boolean) => void;
104
+ /** Required: show a way back to the available commands, never send this text to the model. */
105
+ onError: (problem: CommandProblem<T>) => void;
106
+ }
107
+
108
+ /** Structural so React events and small test events both fit. */
109
+ export interface CommandKeyEvent {
110
+ key: string;
111
+ shiftKey?: boolean;
112
+ altKey?: boolean;
113
+ ctrlKey?: boolean;
114
+ metaKey?: boolean;
115
+ isComposing?: boolean;
116
+ keyCode?: number;
117
+ nativeEvent?: { isComposing?: boolean; keyCode?: number };
118
+ preventDefault: () => void;
119
+ }
120
+
121
+ export interface SlashCommandMenu<T extends SlashCommand = SlashCommand> {
122
+ parsed: ParsedCommand | null;
123
+ open: boolean;
124
+ items: readonly T[];
125
+ index: number;
126
+ active: T | undefined;
127
+ listId: string;
128
+ itemId: (command: T) => string;
129
+ activeId: string | undefined;
130
+ highlight: (index: number) => void;
131
+ close: () => void;
132
+ showAll: () => void;
133
+ pick: (command: T, altKey?: boolean) => void;
134
+ submit: (altKey?: boolean) => boolean;
135
+ handleKeyDown: (event: CommandKeyEvent) => boolean;
136
+ }
137
+
138
+ /** A controlled textarea menu. No UI, browser globals, timers or attachment state. */
139
+ export function useSlashCommands<T extends SlashCommand>({
140
+ text,
141
+ commands,
142
+ onChange,
143
+ onExecute,
144
+ onError,
145
+ }: CommandOptions<T>): SlashCommandMenu<T> {
146
+ const parsed = parseCommand(text);
147
+ const resolution = parsed ? resolveCommand(commands, parsed.name) : null;
148
+ const editingName =
149
+ parsed !== null && (parsed.arg === undefined || resolution?.kind !== "matched");
150
+ const items = editingName ? matchCommands(commands, parsed.name) : [];
151
+ const key = JSON.stringify([text, items.map((command) => command.name)]);
152
+ const [state, setState] = useState({ key, text, index: 0, deliberate: false, dismissed: false });
153
+ // Reset selection on a changed result set, but keep Escape's dismissal until the draft changes.
154
+ if (state.key !== key) {
155
+ setState({
156
+ key,
157
+ text,
158
+ index: 0,
159
+ deliberate: false,
160
+ dismissed: state.text === text && state.dismissed,
161
+ });
162
+ }
163
+
164
+ const listId = `${useId()}-commands`;
165
+ const open = editingName && !state.dismissed;
166
+ const active = open ? items[state.index] : undefined;
167
+ const itemId = (command: T) => `${listId}-${encodeURIComponent(command.name)}`;
168
+
169
+ function close(): void {
170
+ setState((current) => ({ ...current, dismissed: true }));
171
+ }
172
+
173
+ function showAll(): void {
174
+ setState((current) => ({ ...current, index: 0, deliberate: false, dismissed: false }));
175
+ onChange("/");
176
+ }
177
+
178
+ function highlight(index: number): void {
179
+ if (!items[index]) return;
180
+ setState((current) => ({ ...current, index, deliberate: true }));
181
+ }
182
+
183
+ function pick(command: T, altKey = false): void {
184
+ // Re-resolve against current capabilities, not a descriptor retained by a caller.
185
+ const selected = resolveCommand(commands, command.name);
186
+ if (selected.kind !== "matched") {
187
+ onError(selected);
188
+ return;
189
+ }
190
+ if (selected.command.takesArg || parsed?.arg) {
191
+ onChange(completeCommand(selected.command, text));
192
+ return;
193
+ }
194
+ onExecute(selected.command, parsed?.arg, altKey);
195
+ }
196
+
197
+ /** Call before the normal send/queue/quota path, including button and imperative submits. */
198
+ function submit(altKey = false): boolean {
199
+ if (!parsed || !resolution) return false;
200
+ if (resolution.kind !== "matched") {
201
+ onError(resolution);
202
+ return true;
203
+ }
204
+ if (
205
+ resolution.command.takesArg &&
206
+ parsed.arg === undefined &&
207
+ parsed.name !== resolution.command.name.toLowerCase()
208
+ ) {
209
+ onChange(completeCommand(resolution.command, text));
210
+ return true;
211
+ }
212
+ onExecute(resolution.command, parsed.arg, altKey);
213
+ return true;
214
+ }
215
+
216
+ /** Call first. True means skip your own key handler; IME still gets the browser's default. */
217
+ function handleKeyDown(event: CommandKeyEvent): boolean {
218
+ const native = event.nativeEvent ?? event;
219
+ if (native.isComposing || native.keyCode === 229) return true;
220
+ if (event.shiftKey) return false;
221
+ if (open && event.key === "Escape") {
222
+ event.preventDefault();
223
+ close();
224
+ return true;
225
+ }
226
+ if (open && items.length > 0 && !event.ctrlKey && !event.metaKey) {
227
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
228
+ event.preventDefault();
229
+ const step = event.key === "ArrowDown" ? 1 : -1;
230
+ highlight((state.index + step + items.length) % items.length);
231
+ return true;
232
+ }
233
+ if (active && (event.key === "Enter" || event.key === "Tab")) {
234
+ event.preventDefault();
235
+ if (event.key === "Tab") {
236
+ onChange(completeCommand(active, text));
237
+ } else if (resolution?.kind === "ambiguous" && !state.deliberate) {
238
+ onError(resolution);
239
+ } else {
240
+ pick(active, event.altKey);
241
+ }
242
+ return true;
243
+ }
244
+ }
245
+ if (parsed && event.key === "Enter") {
246
+ event.preventDefault();
247
+ return submit(event.altKey);
248
+ }
249
+ return false;
250
+ }
251
+
252
+ return {
253
+ parsed,
254
+ open,
255
+ items,
256
+ index: state.index,
257
+ active,
258
+ listId,
259
+ itemId,
260
+ activeId: active ? itemId(active) : undefined,
261
+ highlight,
262
+ close,
263
+ showAll,
264
+ pick,
265
+ submit,
266
+ handleKeyDown,
267
+ };
268
+ }
package/src/copy.ts CHANGED
@@ -29,9 +29,9 @@ export interface CopyOptions {
29
29
  }
30
30
 
31
31
  /**
32
- * A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
33
- * and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
34
- * needs `aria-live="polite"` to be heard.
32
+ * A copy with an outcome the reader can see. You choose what it says, such as "Copied" or "Select
33
+ * it and copy it yourself", in a label or a toast. You also choose how to announce it: a label that
34
+ * changes needs `aria-live="polite"` to be heard.
35
35
  *
36
36
  * The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
37
37
  * look like it did not take.
@@ -5,26 +5,26 @@
5
5
  * every hashed asset underneath whatever tabs are already open, and those tabs go on running
6
6
  * yesterday's bundle. It surfaces two ways, and only one of them looks like a missing file:
7
7
  *
8
- * 1. The next navigation asks for a chunk whose hashed name is gone. React's lazy boundary
9
- * throws, and the person gets a white screen on a click that worked a minute ago.
10
- * 2. The old bundle reads a field the API has since renamed, and throws. That one arrives as a
11
- * plain `TypeError` with nothing in it about a deploy — no file, no hash, no signal.
8
+ * 1. The next navigation asks for a chunk whose hashed name is gone. React's lazy boundary throws,
9
+ * and the person gets a white screen on a click that worked a minute ago. 2. The old bundle reads a
10
+ * field the API has since renamed, and throws. That one arrives as a plain `TypeError` with nothing
11
+ * in it about a deploy: no file, no hash, no signal.
12
12
  *
13
13
  * So there are two detectors here and they are not interchangeable. {@link isChunkLoadError}
14
- * matches the message, which is all you have for a failure that never reaches a boundary.
15
- * {@link isStaleBuild} asks the server, which is evidence rather than a guess — and it is the
16
- * only one that catches case 2 at all.
14
+ * matches the message, which is all you have for a failure that never reaches a boundary. {@link
15
+ * isStaleBuild} asks the server for evidence rather than a guess. It is the only one that catches
16
+ * case 2 at all.
17
17
  *
18
18
  * **The deepest fix is not in this file, and it is worth more than everything in it.** A static
19
- * host answers a missing `/assets/x.js` with the SPA fallback — `index.html`, at a 200, as
20
- * `text/html` — which is what the browser's "MIME type text/html" module-script refusal actually
21
- * is. Hosts then match cache rules against the REQUEST PATH rather than the outcome, so the
22
- * `immutable, max-age=31536000` rule written for hashed assets gets applied to that HTML body:
23
- * one poisoned asset URL, at the edge and in every browser that touched it, for a year. One
24
- * adopter removed the cause instead of recovering from it — no blanket fallback over `/assets/`,
25
- * and an edge function that refuses to answer an asset path with `text/html` — which turns a
26
- * dead chunk into a real, uncacheable 404 and lets everything below be recovery rather than the
27
- * plan. A package cannot ship that; it can refuse to let it go unwritten.
19
+ * host answers a missing `/assets/x.js` with the SPA fallback, `index.html`, at a 200, as
20
+ * `text/html`. That is what the browser's "MIME type text/html" module-script refusal actually is.
21
+ * Hosts then match cache rules against the REQUEST PATH rather than the outcome, so the `immutable,
22
+ * max-age=31536000` rule written for hashed assets gets applied to that HTML body: one poisoned
23
+ * asset URL, at the edge and in every browser that touched it, for a year. One adopter removed the
24
+ * cause instead of recovering from it: no blanket fallback over `/assets/`, and an edge function
25
+ * that refuses to answer an asset path with `text/html`. That turns a dead chunk into a real,
26
+ * uncacheable 404 and lets everything below be recovery rather than the plan. A package cannot ship
27
+ * that; it can refuse to let it go unwritten.
28
28
  */
29
29
 
30
30
  /**
@@ -48,14 +48,14 @@ const RELOAD_WINDOW_MS = 60_000;
48
48
  const PROBE_TIMEOUT_MS = 4_000;
49
49
 
50
50
  /**
51
- * A lazy-chunk fetch failure — recoverable by reloading.
51
+ * A lazy-chunk fetch failure that a reload can fix.
52
52
  *
53
- * Matched by message because no browser gives it a shared type. Every phrasing in the wild is
54
- * here: Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading
55
- * dynamically imported module", Safari "Importing a module script failed", webpack's
56
- * ChunkLoadError and "Loading chunk N failed", and the SPA-fallback "MIME type" refusal —
57
- * which is the one that fires on a static host that has not fixed the cause above, because the
58
- * missing `.js` is answered with `index.html`.
53
+ * Matched by message because no browser gives it a shared type. Every phrasing in the wild is here:
54
+ * Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading dynamically
55
+ * imported module", Safari "Importing a module script failed", webpack's ChunkLoadError and
56
+ * "Loading chunk N failed", and the SPA-fallback "MIME type" refusal. The last one that fires on a
57
+ * static host that has not fixed the cause above, because the missing `.js` is answered with
58
+ * `index.html`.
59
59
  *
60
60
  * A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
61
61
  * handler can classify the same failure without importing a component.
@@ -93,13 +93,13 @@ function runningEntry(): string {
93
93
  /**
94
94
  * Is the build this tab is running still the one being served?
95
95
  *
96
- * Asked of the server, so the answer is evidence rather than a guess. Everything ambiguous —
97
- * offline, a non-200, a captive portal, a probe that times out — resolves to `false`: dressing a
98
- * genuine bug up as an update hides it from the user and from us.
96
+ * Asked of the server, so the answer is evidence rather than a guess. An offline request, a non-200
97
+ * response, a captive portal or a timed-out probe resolves to `false`: dressing a genuine bug up as
98
+ * an update hides it from the user and from us.
99
99
  *
100
100
  * It asks for the PAGE, not for the asset, and that is not the obvious choice. A missing asset
101
101
  * looks like the direct question, but a CDN commonly serves assets with a long `s-maxage`, so a
102
- * bundle a deploy retired an hour ago still answers 200 from the edge — it would report "current"
102
+ * bundle a deploy retired an hour ago still answers 200 from the edge. It would report "current"
103
103
  * during exactly the window when skew is most likely. The page is the one URL that cannot lie: it
104
104
  * ships `max-age=0, must-revalidate` and never enters the edge cache.
105
105
  */
@@ -124,9 +124,9 @@ export async function isStaleBuild(): Promise<boolean> {
124
124
  * rather than watching the same attempt loop.
125
125
  *
126
126
  * The guard is the part that matters. Reloading on a failure is obvious; reloading on a failure
127
- * that the reload does not fix is an infinite loop with the user inside it. At most one reload
128
- * per minute, so a genuinely broken deploy degrades to the error screen — which can at least say
129
- * something — instead of flickering forever.
127
+ * that the reload does not fix is an infinite loop with the user inside it. At most one reload per
128
+ * minute, so a genuinely broken deploy shows the error screen instead of flickering forever. That
129
+ * screen can at least say something.
130
130
  *
131
131
  * `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament, and
132
132
  * a stale stamp in another tab must not suppress a reload this one needs. Wrapped because storage
@@ -149,11 +149,11 @@ export function reloadOnce(): boolean {
149
149
  /**
150
150
  * Vite's *hint* failure, which is not the same thing and must not be treated as one.
151
151
  *
152
- * Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
153
- * When that link fails, the import itself still runs right after and usually succeeds — so this
154
- * one is worth swallowing, where a failure of the import is the real thing
155
- * {@link isChunkLoadError} matches. Vite builds this message in exactly one place and only ever
156
- * for a stylesheet link; script hints never reject.
152
+ * Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it. When
153
+ * that link fails, the import itself still runs right after and usually succeeds, so this one is
154
+ * worth swallowing, where a failure of the import is the real thing {@link isChunkLoadError}
155
+ * matches. Vite builds this message in exactly one place and only ever for a stylesheet link;
156
+ * script hints never reject.
157
157
  */
158
158
  export function isPreloadHintFailure(error: unknown): boolean {
159
159
  return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
@@ -162,12 +162,12 @@ export function isPreloadHintFailure(error: unknown): boolean {
162
162
  /**
163
163
  * Install the `vite:preloadError` listener. Call once, from the browser entry.
164
164
  *
165
- * Vite offers `preventDefault()` here so an app can own a failed asset itself — but taking that
166
- * deal unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
165
+ * Vite offers `preventDefault()` here so an app can own a failed asset itself, but taking that deal
166
+ * unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
167
167
  * `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
168
- * The user gets the crash screen and the developer gets a TypeError with only React frames in
169
- * it, naming no chunk. So it is taken ONLY for a failed preload hint, where the import still
170
- * runs. A real module failure is left to throw, where the error boundary can recognise it.
168
+ * The user gets the crash screen and the developer gets a TypeError with only React frames in it,
169
+ * naming no chunk. So it is taken ONLY for a failed preload hint, where the import still runs. A
170
+ * real module failure is left to throw, where the error boundary can recognise it.
171
171
  */
172
172
  export function installPreloadErrorHandler(): void {
173
173
  window.addEventListener("vite:preloadError", (event) => {