@griddo/ax 12.9.0-rc.1 → 12.9.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.
- package/package.json +2 -2
- package/src/__tests__/components/Fields/TranslateButton/TranslateButton.test.tsx +36 -5
- package/src/__tests__/hooks/users.test.tsx +36 -1
- package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/Field/index.tsx +1 -1
- package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/TemplateManager/index.tsx +58 -52
- package/src/components/Fields/TranslateButton/index.tsx +47 -33
- package/src/hooks/users.tsx +18 -17
- package/src/__tests__/hooks/shortcuts/engine.test.ts +0 -344
- package/src/__tests__/hooks/shortcuts/hooks.test.tsx +0 -259
- package/src/hooks/shortcuts/README.md +0 -515
- package/src/hooks/shortcuts/engine.ts +0 -205
- package/src/hooks/shortcuts/index.ts +0 -48
- package/src/hooks/shortcuts/presets.ts +0 -144
- package/src/hooks/shortcuts/types.ts +0 -23
- package/src/hooks/shortcuts/useShortcuts.ts +0 -106
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
import { parseShortcut, type ShortcutConfig, shortcutEngine } from "@ax/hooks";
|
|
2
|
-
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Helpers
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
|
|
7
|
-
/** Dispatch a keydown on `window` and return the event. */
|
|
8
|
-
function keydown(init: KeyboardEventInit): KeyboardEvent {
|
|
9
|
-
const event = new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true });
|
|
10
|
-
window.dispatchEvent(event);
|
|
11
|
-
return event;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Register a shortcut, track its cleanup automatically. */
|
|
15
|
-
let cleanups: Array<() => void> = [];
|
|
16
|
-
|
|
17
|
-
function register(
|
|
18
|
-
overrides: Partial<ShortcutConfig> & Pick<ShortcutConfig, "id" | "keys">,
|
|
19
|
-
target?: EventTarget,
|
|
20
|
-
): ShortcutConfig {
|
|
21
|
-
const config: ShortcutConfig = { handler: jest.fn(), ...overrides };
|
|
22
|
-
const cleanup = shortcutEngine.register(config, target);
|
|
23
|
-
cleanups.push(cleanup);
|
|
24
|
-
return config;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
cleanups.forEach((fn) => {
|
|
29
|
-
fn();
|
|
30
|
-
});
|
|
31
|
-
cleanups = [];
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// parseShortcut
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
describe("parseShortcut", () => {
|
|
39
|
-
it("parses a single key", () => {
|
|
40
|
-
expect(parseShortcut("escape")).toEqual([{ modifiers: [], key: "escape" }]);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("parses key with one modifier", () => {
|
|
44
|
-
expect(parseShortcut("mod+s")).toEqual([{ modifiers: ["mod"], key: "s" }]);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("parses key with multiple modifiers", () => {
|
|
48
|
-
expect(parseShortcut("mod+shift+z")).toEqual([{ modifiers: ["mod", "shift"], key: "z" }]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("parses chord (multi-step sequence)", () => {
|
|
52
|
-
expect(parseShortcut("g l")).toEqual([
|
|
53
|
-
{ modifiers: [], key: "g" },
|
|
54
|
-
{ modifiers: [], key: "l" },
|
|
55
|
-
]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("parses chord with modifiers", () => {
|
|
59
|
-
expect(parseShortcut("mod+k mod+d")).toEqual([
|
|
60
|
-
{ modifiers: ["mod"], key: "k" },
|
|
61
|
-
{ modifiers: ["mod"], key: "d" },
|
|
62
|
-
]);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// ShortcutEngine — basic dispatch
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// In jsdom, isMac() → false, so "mod" maps to ctrlKey.
|
|
70
|
-
|
|
71
|
-
describe("ShortcutEngine", () => {
|
|
72
|
-
describe("basic dispatch", () => {
|
|
73
|
-
it("fires handler on matching key", () => {
|
|
74
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
75
|
-
keydown({ key: "s", ctrlKey: true });
|
|
76
|
-
expect(config.handler).toHaveBeenCalledTimes(1);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("ignores non-matching key", () => {
|
|
80
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
81
|
-
keydown({ key: "a", ctrlKey: true });
|
|
82
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("ignores when required modifier is missing", () => {
|
|
86
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
87
|
-
keydown({ key: "s" });
|
|
88
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("ignores when unexpected modifier is present", () => {
|
|
92
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
93
|
-
keydown({ key: "s", ctrlKey: true, shiftKey: true });
|
|
94
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("matches shift+alt combo", () => {
|
|
98
|
-
const config = register({ id: "s1", keys: parseShortcut("shift+alt+n") });
|
|
99
|
-
keydown({ key: "n", shiftKey: true, altKey: true });
|
|
100
|
-
expect(config.handler).toHaveBeenCalledTimes(1);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("ignores events with falsy event.key", () => {
|
|
104
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
105
|
-
// KeyboardEvent with no key option defaults to ""
|
|
106
|
-
window.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, bubbles: true }));
|
|
107
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// -----------------------------------------------------------------------
|
|
112
|
-
// preventDefault / stopPropagation
|
|
113
|
-
// -----------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
describe("event control", () => {
|
|
116
|
-
it("calls preventDefault when handler does not return false", () => {
|
|
117
|
-
register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
118
|
-
const event = keydown({ key: "s", ctrlKey: true });
|
|
119
|
-
expect(event.defaultPrevented).toBe(true);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("skips preventDefault when handler returns false", () => {
|
|
123
|
-
register({ id: "s1", keys: parseShortcut("mod+s"), handler: () => false });
|
|
124
|
-
const event = keydown({ key: "s", ctrlKey: true });
|
|
125
|
-
expect(event.defaultPrevented).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// -----------------------------------------------------------------------
|
|
130
|
-
// enabled flag
|
|
131
|
-
// -----------------------------------------------------------------------
|
|
132
|
-
|
|
133
|
-
describe("enabled flag", () => {
|
|
134
|
-
it("skips disabled shortcuts", () => {
|
|
135
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s"), enabled: false });
|
|
136
|
-
keydown({ key: "s", ctrlKey: true });
|
|
137
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
// -----------------------------------------------------------------------
|
|
142
|
-
// Priority
|
|
143
|
-
// -----------------------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
describe("priority", () => {
|
|
146
|
-
it("dispatches higher priority first", () => {
|
|
147
|
-
const order: string[] = [];
|
|
148
|
-
register({
|
|
149
|
-
id: "low",
|
|
150
|
-
keys: parseShortcut("mod+s"),
|
|
151
|
-
priority: 0,
|
|
152
|
-
handler: () => {
|
|
153
|
-
order.push("low");
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
register({
|
|
157
|
-
id: "high",
|
|
158
|
-
keys: parseShortcut("mod+s"),
|
|
159
|
-
priority: 10,
|
|
160
|
-
handler: () => {
|
|
161
|
-
order.push("high");
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
keydown({ key: "s", ctrlKey: true });
|
|
166
|
-
// "high" wins and stops propagation
|
|
167
|
-
expect(order).toEqual(["high"]);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("falls through when higher priority handler returns false", () => {
|
|
171
|
-
const order: string[] = [];
|
|
172
|
-
register({
|
|
173
|
-
id: "high",
|
|
174
|
-
keys: parseShortcut("mod+s"),
|
|
175
|
-
priority: 10,
|
|
176
|
-
handler: () => {
|
|
177
|
-
order.push("high");
|
|
178
|
-
return false;
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
register({
|
|
182
|
-
id: "low",
|
|
183
|
-
keys: parseShortcut("mod+s"),
|
|
184
|
-
priority: 0,
|
|
185
|
-
handler: () => {
|
|
186
|
-
order.push("low");
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
keydown({ key: "s", ctrlKey: true });
|
|
191
|
-
expect(order).toEqual(["high", "low"]);
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// -----------------------------------------------------------------------
|
|
196
|
-
// Input element filtering
|
|
197
|
-
// -----------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
describe("input element filtering", () => {
|
|
200
|
-
let input: HTMLInputElement;
|
|
201
|
-
|
|
202
|
-
beforeEach(() => {
|
|
203
|
-
input = document.createElement("input");
|
|
204
|
-
document.body.appendChild(input);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
afterEach(() => {
|
|
208
|
-
document.body.removeChild(input);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("does not fire inside input by default", () => {
|
|
212
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
213
|
-
input.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true }));
|
|
214
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("fires inside input when allowInInput is true", () => {
|
|
218
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s"), allowInInput: true });
|
|
219
|
-
input.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true }));
|
|
220
|
-
expect(config.handler).toHaveBeenCalledTimes(1);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("Escape always fires inside input even without allowInInput", () => {
|
|
224
|
-
const config = register({ id: "s1", keys: parseShortcut("escape") });
|
|
225
|
-
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
226
|
-
expect(config.handler).toHaveBeenCalledTimes(1);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("does not fire inside textarea by default", () => {
|
|
230
|
-
const textarea = document.createElement("textarea");
|
|
231
|
-
document.body.appendChild(textarea);
|
|
232
|
-
const config = register({ id: "s1", keys: parseShortcut("mod+s") });
|
|
233
|
-
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true }));
|
|
234
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
235
|
-
document.body.removeChild(textarea);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// Note: contentEditable filtering cannot be tested in jsdom
|
|
239
|
-
// (isContentEditable is not properly implemented).
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// -----------------------------------------------------------------------
|
|
243
|
-
// Chords
|
|
244
|
-
// -----------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
describe("chords", () => {
|
|
247
|
-
it("fires on completed chord sequence", () => {
|
|
248
|
-
const config = register({ id: "goto", keys: parseShortcut("g l") });
|
|
249
|
-
keydown({ key: "g" });
|
|
250
|
-
keydown({ key: "l" });
|
|
251
|
-
expect(config.handler).toHaveBeenCalledTimes(1);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("does not fire on first key alone", () => {
|
|
255
|
-
const config = register({ id: "goto", keys: parseShortcut("g l") });
|
|
256
|
-
keydown({ key: "g" });
|
|
257
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("resets on wrong second key", () => {
|
|
261
|
-
const config = register({ id: "goto", keys: parseShortcut("g l") });
|
|
262
|
-
keydown({ key: "g" });
|
|
263
|
-
keydown({ key: "x" });
|
|
264
|
-
keydown({ key: "l" });
|
|
265
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it("resets on timeout", () => {
|
|
269
|
-
jest.useFakeTimers();
|
|
270
|
-
const config = register({ id: "goto", keys: parseShortcut("g l") });
|
|
271
|
-
keydown({ key: "g" });
|
|
272
|
-
jest.advanceTimersByTime(1100);
|
|
273
|
-
keydown({ key: "l" });
|
|
274
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
275
|
-
jest.useRealTimers();
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("calls preventDefault on first key of chord", () => {
|
|
279
|
-
register({ id: "goto", keys: parseShortcut("g l") });
|
|
280
|
-
const event = keydown({ key: "g" });
|
|
281
|
-
expect(event.defaultPrevented).toBe(true);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it("does not fire single-key shortcuts while chord is advancing", () => {
|
|
285
|
-
const chordHandler = jest.fn();
|
|
286
|
-
const singleHandler = jest.fn();
|
|
287
|
-
register({ id: "chord", keys: parseShortcut("g l") });
|
|
288
|
-
register({ id: "single-g", keys: parseShortcut("g"), handler: singleHandler });
|
|
289
|
-
|
|
290
|
-
keydown({ key: "g" });
|
|
291
|
-
expect(singleHandler).not.toHaveBeenCalled();
|
|
292
|
-
});
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
// -----------------------------------------------------------------------
|
|
296
|
-
// Scoped target
|
|
297
|
-
// -----------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
describe("scoped target", () => {
|
|
300
|
-
it("fires only on registered target", () => {
|
|
301
|
-
const div = document.createElement("div");
|
|
302
|
-
document.body.appendChild(div);
|
|
303
|
-
const config = register({ id: "scoped", keys: parseShortcut("mod+b") }, div);
|
|
304
|
-
|
|
305
|
-
// Event on window — should NOT trigger
|
|
306
|
-
keydown({ key: "b", ctrlKey: true });
|
|
307
|
-
expect(config.handler).not.toHaveBeenCalled();
|
|
308
|
-
|
|
309
|
-
// Event on the registered target — should trigger
|
|
310
|
-
div.dispatchEvent(new KeyboardEvent("keydown", { key: "b", ctrlKey: true, bubbles: true }));
|
|
311
|
-
expect(config.handler).toHaveBeenCalledTimes(1);
|
|
312
|
-
|
|
313
|
-
document.body.removeChild(div);
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
// -----------------------------------------------------------------------
|
|
318
|
-
// Cleanup
|
|
319
|
-
// -----------------------------------------------------------------------
|
|
320
|
-
|
|
321
|
-
describe("cleanup", () => {
|
|
322
|
-
it("unregisters shortcut when cleanup is called", () => {
|
|
323
|
-
const handler = jest.fn();
|
|
324
|
-
const cleanup = shortcutEngine.register({ id: "tmp", keys: parseShortcut("mod+s"), handler }, window);
|
|
325
|
-
cleanup();
|
|
326
|
-
|
|
327
|
-
keydown({ key: "s", ctrlKey: true });
|
|
328
|
-
expect(handler).not.toHaveBeenCalled();
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it("removes listener when last shortcut on a target is unregistered", () => {
|
|
332
|
-
const div = document.createElement("div");
|
|
333
|
-
document.body.appendChild(div);
|
|
334
|
-
const spy = jest.spyOn(div, "removeEventListener");
|
|
335
|
-
|
|
336
|
-
const cleanup = shortcutEngine.register({ id: "tmp", keys: parseShortcut("mod+x"), handler: jest.fn() }, div);
|
|
337
|
-
cleanup();
|
|
338
|
-
|
|
339
|
-
expect(spy).toHaveBeenCalledWith("keydown", expect.any(Function));
|
|
340
|
-
spy.mockRestore();
|
|
341
|
-
document.body.removeChild(div);
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
});
|
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
shortcut,
|
|
3
|
-
useEscapeShortcut,
|
|
4
|
-
useGlobalShortcuts,
|
|
5
|
-
useIframeShortcutBridge,
|
|
6
|
-
useSaveShortcut,
|
|
7
|
-
useShortcuts,
|
|
8
|
-
useUndoRedoShortcuts,
|
|
9
|
-
} from "@ax/hooks";
|
|
10
|
-
|
|
11
|
-
import { renderHook } from "@testing-library/react";
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Helpers
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
function keydown(init: KeyboardEventInit): KeyboardEvent {
|
|
18
|
-
const event = new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true });
|
|
19
|
-
window.dispatchEvent(event);
|
|
20
|
-
return event;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// shortcut builder
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
describe("shortcut()", () => {
|
|
28
|
-
it("creates a ShortcutConfig with parsed keys", () => {
|
|
29
|
-
const handler = jest.fn();
|
|
30
|
-
const config = shortcut("save", "mod+s", handler);
|
|
31
|
-
expect(config.id).toBe("save");
|
|
32
|
-
expect(config.keys).toEqual([{ modifiers: ["mod"], key: "s" }]);
|
|
33
|
-
expect(config.handler).toBe(handler);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("applies overrides", () => {
|
|
37
|
-
const config = shortcut("save", "mod+s", jest.fn(), {
|
|
38
|
-
allowInInput: true,
|
|
39
|
-
priority: 5,
|
|
40
|
-
description: "Save document",
|
|
41
|
-
});
|
|
42
|
-
expect(config.allowInInput).toBe(true);
|
|
43
|
-
expect(config.priority).toBe(5);
|
|
44
|
-
expect(config.description).toBe("Save document");
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
// useShortcuts
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
describe("useShortcuts", () => {
|
|
53
|
-
it("registers and fires shortcut on matching key", () => {
|
|
54
|
-
const handler = jest.fn();
|
|
55
|
-
renderHook(() => useShortcuts([shortcut("s1", "mod+s", handler)]));
|
|
56
|
-
keydown({ key: "s", ctrlKey: true });
|
|
57
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("cleans up shortcuts on unmount", () => {
|
|
61
|
-
const handler = jest.fn();
|
|
62
|
-
const { unmount } = renderHook(() => useShortcuts([shortcut("s1", "mod+s", handler)]));
|
|
63
|
-
unmount();
|
|
64
|
-
keydown({ key: "s", ctrlKey: true });
|
|
65
|
-
expect(handler).not.toHaveBeenCalled();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("does not fire when enabled is false", () => {
|
|
69
|
-
const handler = jest.fn();
|
|
70
|
-
renderHook(() => useShortcuts([shortcut("s1", "mod+s", handler)], { enabled: false }));
|
|
71
|
-
keydown({ key: "s", ctrlKey: true });
|
|
72
|
-
expect(handler).not.toHaveBeenCalled();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("always calls the latest handler (no stale closures)", () => {
|
|
76
|
-
let value = 0;
|
|
77
|
-
const { rerender } = renderHook(({ handler }) => useShortcuts([shortcut("s1", "mod+s", handler)]), {
|
|
78
|
-
initialProps: {
|
|
79
|
-
handler: () => {
|
|
80
|
-
value = 1;
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
rerender({
|
|
85
|
-
handler: () => {
|
|
86
|
-
value = 2;
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
keydown({ key: "s", ctrlKey: true });
|
|
90
|
-
expect(value).toBe(2);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("scopes shortcuts to a ref element", () => {
|
|
94
|
-
const div = document.createElement("div");
|
|
95
|
-
document.body.appendChild(div);
|
|
96
|
-
const ref = { current: div } as React.RefObject<HTMLDivElement>;
|
|
97
|
-
const handler = jest.fn();
|
|
98
|
-
|
|
99
|
-
renderHook(() => useShortcuts([shortcut("s1", "mod+b", handler)], { ref }));
|
|
100
|
-
|
|
101
|
-
// Event on window — should NOT trigger
|
|
102
|
-
keydown({ key: "b", ctrlKey: true });
|
|
103
|
-
expect(handler).not.toHaveBeenCalled();
|
|
104
|
-
|
|
105
|
-
// Event on ref element — should trigger
|
|
106
|
-
div.dispatchEvent(new KeyboardEvent("keydown", { key: "b", ctrlKey: true, bubbles: true }));
|
|
107
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
108
|
-
|
|
109
|
-
document.body.removeChild(div);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// Preset hooks
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
describe("useSaveShortcut", () => {
|
|
118
|
-
it("fires handler on mod+s", () => {
|
|
119
|
-
const handler = jest.fn();
|
|
120
|
-
renderHook(() => useSaveShortcut(handler));
|
|
121
|
-
keydown({ key: "s", ctrlKey: true });
|
|
122
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("does not fire when disabled", () => {
|
|
126
|
-
const handler = jest.fn();
|
|
127
|
-
renderHook(() => useSaveShortcut(handler, false));
|
|
128
|
-
keydown({ key: "s", ctrlKey: true });
|
|
129
|
-
expect(handler).not.toHaveBeenCalled();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("works inside input elements", () => {
|
|
133
|
-
const handler = jest.fn();
|
|
134
|
-
renderHook(() => useSaveShortcut(handler));
|
|
135
|
-
const input = document.createElement("input");
|
|
136
|
-
document.body.appendChild(input);
|
|
137
|
-
input.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true }));
|
|
138
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
139
|
-
document.body.removeChild(input);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe("useEscapeShortcut", () => {
|
|
144
|
-
it("fires handler on Escape", () => {
|
|
145
|
-
const handler = jest.fn();
|
|
146
|
-
renderHook(() => useEscapeShortcut(handler));
|
|
147
|
-
keydown({ key: "Escape" });
|
|
148
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("does not fire when disabled", () => {
|
|
152
|
-
const handler = jest.fn();
|
|
153
|
-
renderHook(() => useEscapeShortcut(handler, false));
|
|
154
|
-
keydown({ key: "Escape" });
|
|
155
|
-
expect(handler).not.toHaveBeenCalled();
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe("useUndoRedoShortcuts", () => {
|
|
160
|
-
it("fires onUndo on mod+z", () => {
|
|
161
|
-
const onUndo = jest.fn();
|
|
162
|
-
const onRedo = jest.fn();
|
|
163
|
-
renderHook(() => useUndoRedoShortcuts(onUndo, onRedo));
|
|
164
|
-
keydown({ key: "z", ctrlKey: true });
|
|
165
|
-
expect(onUndo).toHaveBeenCalledTimes(1);
|
|
166
|
-
expect(onRedo).not.toHaveBeenCalled();
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("fires onRedo on mod+shift+z", () => {
|
|
170
|
-
const onUndo = jest.fn();
|
|
171
|
-
const onRedo = jest.fn();
|
|
172
|
-
renderHook(() => useUndoRedoShortcuts(onUndo, onRedo));
|
|
173
|
-
keydown({ key: "z", ctrlKey: true, shiftKey: true });
|
|
174
|
-
expect(onRedo).toHaveBeenCalledTimes(1);
|
|
175
|
-
expect(onUndo).not.toHaveBeenCalled();
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe("useGlobalShortcuts", () => {
|
|
180
|
-
it("prevents browser default on mod+s", () => {
|
|
181
|
-
renderHook(() => useGlobalShortcuts());
|
|
182
|
-
const event = keydown({ key: "s", ctrlKey: true });
|
|
183
|
-
expect(event.defaultPrevented).toBe(true);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("yields to higher-priority shortcut", () => {
|
|
187
|
-
const handler = jest.fn();
|
|
188
|
-
renderHook(() => useGlobalShortcuts());
|
|
189
|
-
renderHook(() => useSaveShortcut(handler));
|
|
190
|
-
|
|
191
|
-
keydown({ key: "s", ctrlKey: true });
|
|
192
|
-
// The preset (priority 0) wins over global (priority -1)
|
|
193
|
-
expect(handler).toHaveBeenCalledTimes(1);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
// useIframeShortcutBridge
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
|
|
201
|
-
describe("useIframeShortcutBridge", () => {
|
|
202
|
-
it("re-dispatches same-origin shortcut messages as keydown", () => {
|
|
203
|
-
renderHook(() => useIframeShortcutBridge());
|
|
204
|
-
const spy = jest.fn();
|
|
205
|
-
window.addEventListener("keydown", spy);
|
|
206
|
-
|
|
207
|
-
window.dispatchEvent(
|
|
208
|
-
new MessageEvent("message", {
|
|
209
|
-
origin: window.location.origin,
|
|
210
|
-
data: {
|
|
211
|
-
type: "shortcut-keydown",
|
|
212
|
-
message: { key: "s", code: "KeyS", metaKey: false, ctrlKey: true, shiftKey: false, altKey: false },
|
|
213
|
-
},
|
|
214
|
-
}),
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
expect(spy).toHaveBeenCalled();
|
|
218
|
-
const event = spy.mock.calls[0][0] as KeyboardEvent;
|
|
219
|
-
expect(event.key).toBe("s");
|
|
220
|
-
expect(event.ctrlKey).toBe(true);
|
|
221
|
-
|
|
222
|
-
window.removeEventListener("keydown", spy);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("ignores cross-origin messages", () => {
|
|
226
|
-
renderHook(() => useIframeShortcutBridge());
|
|
227
|
-
const spy = jest.fn();
|
|
228
|
-
window.addEventListener("keydown", spy);
|
|
229
|
-
|
|
230
|
-
window.dispatchEvent(
|
|
231
|
-
new MessageEvent("message", {
|
|
232
|
-
origin: "https://evil.example.com",
|
|
233
|
-
data: {
|
|
234
|
-
type: "shortcut-keydown",
|
|
235
|
-
message: { key: "s", code: "KeyS", metaKey: false, ctrlKey: true, shiftKey: false, altKey: false },
|
|
236
|
-
},
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
expect(spy).not.toHaveBeenCalled();
|
|
241
|
-
window.removeEventListener("keydown", spy);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it("ignores messages with unrecognized type", () => {
|
|
245
|
-
renderHook(() => useIframeShortcutBridge());
|
|
246
|
-
const spy = jest.fn();
|
|
247
|
-
window.addEventListener("keydown", spy);
|
|
248
|
-
|
|
249
|
-
window.dispatchEvent(
|
|
250
|
-
new MessageEvent("message", {
|
|
251
|
-
origin: window.location.origin,
|
|
252
|
-
data: { type: "unrelated-message" },
|
|
253
|
-
}),
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
expect(spy).not.toHaveBeenCalled();
|
|
257
|
-
window.removeEventListener("keydown", spy);
|
|
258
|
-
});
|
|
259
|
-
});
|