@gusnips/react 0.9.25 → 0.9.27
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.
- package/README.md +179 -1
- package/dist/busy-ring.d.ts +13 -0
- package/dist/busy-ring.d.ts.map +1 -0
- package/dist/busy-ring.js +16 -0
- package/dist/busy-ring.js.map +1 -0
- package/dist/commands.d.ts +80 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +192 -0
- package/dist/commands.js.map +1 -0
- package/dist/held-pending.d.ts +14 -0
- package/dist/held-pending.d.ts.map +1 -0
- package/dist/held-pending.js +36 -0
- package/dist/held-pending.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ui/menu.d.ts +1 -1
- package/dist/ui/menu.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/busy-ring.tsx +14 -0
- package/src/commands-hook.test.ts +444 -0
- package/src/commands.test.ts +245 -0
- package/src/commands.ts +268 -0
- package/src/held-pending.test.ts +88 -0
- package/src/held-pending.ts +39 -0
- package/src/index.ts +2 -0
|
@@ -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
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useHeldPending } from "./held-pending.ts";
|
|
6
|
+
|
|
7
|
+
// A refresh that answers in 80 ms flipped `pending` on and off inside a frame, so the button never
|
|
8
|
+
// visibly changed and the reader thought the press was ignored. The flag has to stay on for the
|
|
9
|
+
// minimum once it turns on, and a slow request must not be made to wait any longer.
|
|
10
|
+
|
|
11
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
12
|
+
|
|
13
|
+
const roots = new Set<Root>();
|
|
14
|
+
|
|
15
|
+
beforeEach(() => vi.useFakeTimers());
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const root of roots) act(() => root.unmount());
|
|
19
|
+
roots.clear();
|
|
20
|
+
document.body.replaceChildren();
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function mount(initial: boolean, minMs?: number) {
|
|
25
|
+
const element = document.createElement("div");
|
|
26
|
+
document.body.append(element);
|
|
27
|
+
const root = createRoot(element);
|
|
28
|
+
roots.add(root);
|
|
29
|
+
const seen: boolean[] = [];
|
|
30
|
+
|
|
31
|
+
function Probe({ pending }: { pending: boolean }) {
|
|
32
|
+
const held = useHeldPending(pending, minMs);
|
|
33
|
+
seen.push(held);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const render = (pending: boolean) => act(() => root.render(createElement(Probe, { pending })));
|
|
38
|
+
render(initial);
|
|
39
|
+
return { render, current: () => seen.at(-1) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("useHeldPending", () => {
|
|
43
|
+
it("is false while nothing is pending", () => {
|
|
44
|
+
expect(mount(false).current()).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("is true on the very render pending starts true, with no wait for an effect", () => {
|
|
48
|
+
const { render, current } = mount(false);
|
|
49
|
+
render(true);
|
|
50
|
+
expect(current()).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("holds a request that ended early until the minimum has passed", () => {
|
|
54
|
+
const { render, current } = mount(false, 400);
|
|
55
|
+
render(true);
|
|
56
|
+
act(() => void vi.advanceTimersByTime(80));
|
|
57
|
+
render(false);
|
|
58
|
+
expect(current()).toBe(true);
|
|
59
|
+
act(() => void vi.advanceTimersByTime(319));
|
|
60
|
+
expect(current()).toBe(true);
|
|
61
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
62
|
+
expect(current()).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not make a slow request wait longer than one timer tick", () => {
|
|
66
|
+
const { render, current } = mount(false, 400);
|
|
67
|
+
render(true);
|
|
68
|
+
act(() => void vi.advanceTimersByTime(900));
|
|
69
|
+
render(false);
|
|
70
|
+
act(() => void vi.advanceTimersByTime(0));
|
|
71
|
+
expect(current()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("starts the clock again when it turns on a second time", () => {
|
|
75
|
+
const { render, current } = mount(false, 400);
|
|
76
|
+
render(true);
|
|
77
|
+
act(() => void vi.advanceTimersByTime(100));
|
|
78
|
+
render(false);
|
|
79
|
+
act(() => void vi.advanceTimersByTime(300));
|
|
80
|
+
expect(current()).toBe(false);
|
|
81
|
+
render(true);
|
|
82
|
+
act(() => void vi.advanceTimersByTime(50));
|
|
83
|
+
render(false);
|
|
84
|
+
expect(current()).toBe(true);
|
|
85
|
+
act(() => void vi.advanceTimersByTime(350));
|
|
86
|
+
expect(current()).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// A refresh that answers in 80 ms flips its `pending` flag on and off inside a frame or two. The
|
|
4
|
+
// reader pressed the button, nothing on screen changed, and the page looks like it ignored them.
|
|
5
|
+
// A louder spinner does not fix that, because the spinner is never painted. The flag itself has
|
|
6
|
+
// to stay on long enough to be seen.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `pending`, kept on for at least `minMs` once it turns on.
|
|
10
|
+
*
|
|
11
|
+
* It answers `true` on the very render `pending` turns true, with no wait for an effect, so the
|
|
12
|
+
* busy state shows on the press and not a frame later. A slow request is not made slower: the
|
|
13
|
+
* hold only fills the gap when the request ended early, and ends within one timer tick of it
|
|
14
|
+
* otherwise.
|
|
15
|
+
*
|
|
16
|
+
* 400 ms is the floor for a busy state a person can read as "it ran". Use it for a button's busy
|
|
17
|
+
* look, a refresh icon, a save that returns at once. Do not use it to gate real work: it changes
|
|
18
|
+
* what is shown, never what the request does.
|
|
19
|
+
*/
|
|
20
|
+
export function useHeldPending(pending: boolean, minMs = 400): boolean {
|
|
21
|
+
const [held, setHeld] = useState(false);
|
|
22
|
+
const startedAt = useRef(0);
|
|
23
|
+
|
|
24
|
+
// Raised while rendering, not in an effect, so there is no frame where `pending` is true and the
|
|
25
|
+
// hold is not. It only ever turns on here. The timer below is the one place it turns off.
|
|
26
|
+
if (pending && !held) setHeld(true);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (pending) {
|
|
30
|
+
startedAt.current = Date.now();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const remaining = Math.max(0, startedAt.current + minMs - Date.now());
|
|
34
|
+
const timer = setTimeout(() => setHeld(false), remaining);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, [pending, minMs]);
|
|
37
|
+
|
|
38
|
+
return pending || held;
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
|
|
33
33
|
export { cn } from "./cn.ts";
|
|
34
34
|
export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
|
|
35
|
+
export { BusyRing } from "./busy-ring.tsx";
|
|
36
|
+
export { useHeldPending } from "./held-pending.ts";
|
|
35
37
|
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
36
38
|
|
|
37
39
|
export { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.ts";
|