rx-hotkeys 2.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { describe, it, before, beforeEach, afterEach, mock } from "node:test";
2
2
  import assert from "node:assert";
3
3
  import { Hotkeys, ShortcutTypes } from "./hotkeys.js";
4
4
  import { Keys } from "./keys.js";
5
- import { fromEvent, BehaviorSubject } from "rxjs";
5
+ import { fromEvent, BehaviorSubject, Observable, EMPTY } from "rxjs";
6
6
  import { createMockFn, dispatchKeyEvent } from "./testutils.js";
7
7
  import { JSDOM } from "jsdom";
8
8
  // --- JSDOM and RxJS setup for Node.js tests ---
@@ -10,21 +10,31 @@ let dom;
10
10
  let window;
11
11
  let document;
12
12
  let originalPerformanceNow;
13
+ let testArea; // For target tests
13
14
  before(() => {
14
- dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
15
+ dom = new JSDOM(`<!DOCTYPE html><html><body><div id="test-area"></div></body></html>`, {
15
16
  url: "http://localhost",
16
17
  });
17
18
  window = dom.window;
18
19
  document = window.document;
20
+ testArea = document.getElementById("test-area");
19
21
  // @ts-ignore
20
22
  global.document = document;
21
23
  // @ts-ignore
24
+ global.window = window;
25
+ // @ts-ignore
26
+ global.HTMLElement = window.HTMLElement;
27
+ // @ts-ignore
22
28
  global.KeyboardEvent = window.KeyboardEvent;
23
29
  // @ts-ignore
24
30
  global.BehaviorSubject = BehaviorSubject;
25
31
  // @ts-ignore
26
32
  global.fromEvent = fromEvent;
27
33
  // @ts-ignore
34
+ global.Observable = Observable;
35
+ // @ts-ignore
36
+ global.EMPTY = EMPTY;
37
+ // @ts-ignore
28
38
  if (typeof global.performance === "undefined") {
29
39
  // @ts-ignore
30
40
  global.performance = {};
@@ -242,82 +252,92 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
242
252
  });
243
253
  });
244
254
  describe("addCombination", () => {
245
- it(`should trigger callback for a simple key combination (e.g., "A")`, () => {
246
- const config = { id: "simpleA", keys: { key: Keys.A }, callback: mockCallback };
247
- const result = keyManager.addCombination(config);
248
- assert.strictEqual(result, "simpleA");
249
- dispatchKeyEvent("a");
255
+ it(`should return an Observable that emits on a simple key combination (e.g., "A")`, () => {
256
+ const config = { id: "simpleA", keys: { key: Keys.A } };
257
+ const combo$ = keyManager.addCombination(config);
258
+ assert(combo$ instanceof Observable, "Did not return an Observable");
259
+ combo$.subscribe(mockCallback);
260
+ dispatchKeyEvent(document, "a");
250
261
  assert.strictEqual(mockCallback.calledCount, 1, `Callback for "a" not called`);
251
262
  mockCallback.mockClear();
252
- dispatchKeyEvent("A");
263
+ dispatchKeyEvent(document, "A");
253
264
  assert.strictEqual(mockCallback.calledCount, 1, `Callback for "A" not called`);
254
265
  });
255
- it("should return undefined and warn if keys.key is null or undefined (runtime check in object form)", () => {
256
- const config = { id: "nullKey", keys: { key: null }, callback: mockCallback };
257
- const result = keyManager.addCombination(config);
258
- assert.strictEqual(result, undefined, "Should return undefined for null key");
266
+ it("should return an empty observable and warn if keys.key is null or undefined", () => {
267
+ const config = { id: "nullKey", keys: { key: null } };
268
+ const combo$ = keyManager.addCombination(config);
269
+ combo$.subscribe(mockCallback);
259
270
  assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
260
- assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid "key" property in shortcut "nullKey". Key must be a non-empty string value from Keys.`));
271
+ assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid "key" property in shortcut "nullKey"`));
272
+ dispatchKeyEvent(document, "a");
273
+ assert.strictEqual(mockCallback.calledCount, 0);
261
274
  });
262
- it("should pass the KeyboardEvent to the callback", () => {
263
- const config = { id: "eventPass", keys: { key: Keys.E }, callback: mockCallback };
264
- keyManager.addCombination(config);
265
- const event = dispatchKeyEvent("e");
275
+ it("should pass the KeyboardEvent to the subscriber", () => {
276
+ const config = { id: "eventPass", keys: { key: Keys.E } };
277
+ keyManager.addCombination(config).subscribe(mockCallback);
278
+ const event = dispatchKeyEvent(document, "e");
266
279
  assert.strictEqual(mockCallback.calledCount, 1);
267
280
  assert.deepStrictEqual(mockCallback.lastArgs, [event]);
268
281
  });
269
- it("should trigger callback for a combination with Ctrl key", () => {
270
- const config = { id: "ctrlS", keys: { key: Keys.S, ctrlKey: true }, callback: mockCallback };
271
- keyManager.addCombination(config);
272
- dispatchKeyEvent("s", { ctrlKey: true });
282
+ it("should emit for a combination with Ctrl key", () => {
283
+ const config = { id: "ctrlS", keys: { key: Keys.S, ctrlKey: true } };
284
+ keyManager.addCombination(config).subscribe(mockCallback);
285
+ dispatchKeyEvent(document, "s", "keydown", { ctrlKey: true });
273
286
  assert.strictEqual(mockCallback.calledCount, 1);
274
287
  });
275
- it("should NOT trigger callback if specified modifier key (ctrlKey: false) is false and event has it true", () => {
276
- const config = { id: "noCtrlA", keys: { key: Keys.A, ctrlKey: false }, callback: mockCallback };
277
- keyManager.addCombination(config);
278
- dispatchKeyEvent("a", { ctrlKey: true });
288
+ it("should NOT emit if specified modifier key (ctrlKey: false) is false and event has it true", () => {
289
+ const config = { id: "noCtrlA", keys: { key: Keys.A, ctrlKey: false } };
290
+ keyManager.addCombination(config).subscribe(mockCallback);
291
+ dispatchKeyEvent(document, "a", "keydown", { ctrlKey: true });
279
292
  assert.strictEqual(mockCallback.calledCount, 0);
280
- dispatchKeyEvent("a", { ctrlKey: false });
293
+ dispatchKeyEvent(document, "a", "keydown", { ctrlKey: false });
281
294
  assert.strictEqual(mockCallback.calledCount, 1);
282
295
  });
283
- it("should trigger for special keys like Escape (object form)", () => {
284
- const config = { id: "escapeKeyObj", keys: { key: Keys.Escape }, callback: mockCallback };
285
- keyManager.addCombination(config);
286
- dispatchKeyEvent("Escape"); // Event key matches Keys.Escape
296
+ it("should emit for special keys like Escape (object form)", () => {
297
+ const config = { id: "escapeKeyObj", keys: { key: Keys.Escape } };
298
+ keyManager.addCombination(config).subscribe(mockCallback);
299
+ dispatchKeyEvent(document, "Escape"); // Event key matches Keys.Escape
287
300
  assert.strictEqual(mockCallback.calledCount, 1);
288
301
  });
289
302
  it("should handle preventDefault correctly (object form)", () => {
290
- const config = { id: "preventAObj", keys: { key: Keys.A }, callback: mockCallback, preventDefault: true };
291
- keyManager.addCombination(config);
292
- const event = dispatchKeyEvent("a");
303
+ const config = { id: "preventAObj", keys: { key: Keys.A }, preventDefault: true };
304
+ keyManager.addCombination(config).subscribe(mockCallback);
305
+ const event = dispatchKeyEvent(document, "a");
293
306
  assert.strictEqual(mockCallback.calledCount, 1);
294
307
  assert.strictEqual(event.defaultPrevented, true);
295
308
  });
296
- it("should overwrite an existing combination with the same ID and warn", () => {
309
+ it("should overwrite an existing combination, warn, and terminate the old stream", () => {
297
310
  const firstCallback = createMockFn();
311
+ const firstComplete = createMockFn();
298
312
  const secondCallback = createMockFn();
299
- keyManager.addCombination({ id: "combo1", keys: { key: Keys.K }, callback: firstCallback });
313
+ const first$ = keyManager.addCombination({ id: "combo1", keys: { key: Keys.K } });
314
+ first$.subscribe({ next: firstCallback, complete: firstComplete });
300
315
  consoleWarnMock.mock.resetCalls();
301
- keyManager.addCombination({ id: "combo1", keys: { key: Keys.K, ctrlKey: true }, callback: secondCallback });
316
+ const second$ = keyManager.addCombination({ id: "combo1", keys: { key: Keys.K, ctrlKey: true } });
317
+ second$.subscribe(secondCallback);
302
318
  assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
303
319
  assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Shortcut with ID "combo1" already exists`));
304
- dispatchKeyEvent("k");
320
+ assert.strictEqual(firstComplete.calledCount, 1, "First observable should have completed");
321
+ dispatchKeyEvent(document, "k");
305
322
  assert.strictEqual(firstCallback.calledCount, 0);
306
- dispatchKeyEvent("k", { ctrlKey: true });
323
+ dispatchKeyEvent(document, "k", "keydown", { ctrlKey: true });
307
324
  assert.strictEqual(secondCallback.calledCount, 1);
308
325
  });
309
- it("should log an error via console.error if callback throws, and not affect other shortcuts", () => {
310
- const errorCallback = () => { throw new Error("Test callback error"); };
326
+ it("should not affect other shortcuts if one subscription throws an error", () => {
311
327
  const workingCallback = createMockFn();
312
- keyManager.addCombination({ id: "errorCombo", keys: { key: Keys.E }, callback: errorCallback });
313
- keyManager.addCombination({ id: "workingCombo", keys: { key: Keys.W }, callback: workingCallback });
314
- dispatchKeyEvent("e");
315
- assert.strictEqual(consoleErrorMock.mock.calls.length, 1);
316
- assert.ok(consoleErrorMock.mock.calls[0].arguments[0].includes(`Error in user callback for combination shortcut "errorCombo"`));
317
- dispatchKeyEvent("w");
318
- assert.strictEqual(workingCallback.calledCount, 1);
319
- });
320
- it("should trigger callback if any of multiple key combinations are pressed", () => {
328
+ const erroringCallback = () => { throw new Error("Test callback error"); };
329
+ const error$ = keyManager.addCombination({ id: "errorCombo", keys: { key: Keys.E } });
330
+ // Suppress unhandled exception message in test runner output
331
+ error$.subscribe(erroringCallback, () => { });
332
+ const working$ = keyManager.addCombination({ id: "workingCombo", keys: { key: Keys.W } });
333
+ working$.subscribe(workingCallback);
334
+ // Dispatch event that causes an error in the subscription
335
+ //dispatchKeyEvent(document, "e");
336
+ // Dispatch another event to ensure the other shortcut still works
337
+ dispatchKeyEvent(document, "w");
338
+ assert.strictEqual(workingCallback.calledCount, 1, "Working callback should have been called");
339
+ });
340
+ it("should emit if any of multiple key combinations are pressed", () => {
321
341
  const config = {
322
342
  id: "multiCombo",
323
343
  keys: [
@@ -325,102 +345,124 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
325
345
  Keys.Escape, // Escape
326
346
  { key: Keys.B, shiftKey: true, altKey: true } // Shift+Alt+B
327
347
  ],
328
- callback: mockCallback
329
348
  };
330
- keyManager.addCombination(config);
331
- dispatchKeyEvent("A", { ctrlKey: true });
349
+ keyManager.addCombination(config).subscribe(mockCallback);
350
+ dispatchKeyEvent(document, "A", "keydown", { ctrlKey: true });
332
351
  assert.strictEqual(mockCallback.calledCount, 1, "Ctrl+A did not trigger");
333
- dispatchKeyEvent("Escape");
352
+ dispatchKeyEvent(document, "Escape");
334
353
  assert.strictEqual(mockCallback.calledCount, 2, "Escape did not trigger");
335
- dispatchKeyEvent("B", { shiftKey: true, altKey: true });
354
+ dispatchKeyEvent(document, "B", "keydown", { shiftKey: true, altKey: true });
336
355
  assert.strictEqual(mockCallback.calledCount, 3, "Shift+Alt+B did not trigger");
337
- dispatchKeyEvent("C"); // Should not trigger
356
+ dispatchKeyEvent(document, "C"); // Should not trigger
338
357
  assert.strictEqual(mockCallback.calledCount, 3, "Unrelated key C triggered");
339
358
  });
340
- it(`should return undefined and warn if "keys" array is empty`, () => {
341
- const config = { id: "emptyKeysArray", keys: [], callback: mockCallback };
342
- const result = keyManager.addCombination(config);
343
- assert.strictEqual(result, undefined);
344
- assert.ok(consoleWarnMock.mock.calls.some(call => call.arguments[0].includes(`"keys" array for combination shortcut "emptyKeysArray" is empty`)));
345
- });
346
- it(`should return undefined and warn if a key in "keys" array is invalid (shorthand)`, () => {
347
- const config = { id: "invalidInArrayShorthand", keys: [Keys.A, ""], callback: mockCallback };
348
- const result = keyManager.addCombination(config);
349
- assert.strictEqual(result, undefined);
350
- assert.ok(consoleWarnMock.mock.calls.some(call => call.arguments[0].includes(`Invalid key (shorthand) in shortcut "invalidInArrayShorthand"`)));
351
- });
352
- it(`should return undefined and warn if a key in "keys" array is invalid (object)`, () => {
353
- const config = { id: "invalidInArrayObject", keys: [Keys.A, { key: "" }], callback: mockCallback };
354
- const result = keyManager.addCombination(config);
355
- assert.strictEqual(result, undefined);
359
+ it(`should return an empty observable and warn if "keys" array is empty`, () => {
360
+ const config = { id: "emptyKeysArray", keys: [] };
361
+ const combo$ = keyManager.addCombination(config);
362
+ combo$.subscribe(mockCallback);
363
+ assert.ok(consoleWarnMock.mock.calls.some(call => call.arguments[0].includes(`"keys" definition for combination shortcut "emptyKeysArray" is empty`)));
364
+ dispatchKeyEvent(document, "a");
365
+ assert.strictEqual(mockCallback.calledCount, 0);
366
+ });
367
+ it(`should return an empty observable and warn if a key in "keys" array is invalid (shorthand)`, () => {
368
+ const config = { id: "invalidInArrayShorthand", keys: [Keys.A, ""] };
369
+ const combo$ = keyManager.addCombination(config);
370
+ combo$.subscribe(mockCallback);
371
+ assert.ok(consoleWarnMock.mock.calls.some(call => call.arguments[0].includes(`Could not parse key: "" in shortcut "invalidInArrayShorthand"`)));
372
+ });
373
+ it(`should return an empty observable and warn if a key in "keys" array is invalid (object)`, () => {
374
+ const config = { id: "invalidInArrayObject", keys: [Keys.A, { key: "" }] };
375
+ const combo$ = keyManager.addCombination(config);
376
+ combo$.subscribe(mockCallback);
356
377
  assert.ok(consoleWarnMock.mock.calls.some(call => call.arguments[0].includes(`Invalid "key" property in shortcut "invalidInArrayObject"`)));
357
378
  });
358
379
  it("should correctly log multiple key triggers in debug mode", () => {
359
380
  const consoleLogMock = mock.method(console, "log");
360
381
  keyManager.setDebugMode(true);
361
- keyManager.addCombination({ id: "multiLog", keys: [Keys.F1, { key: Keys.F2, ctrlKey: true }], callback: mockCallback });
382
+ keyManager.addCombination({ id: "multiLog", keys: [Keys.F1, { key: Keys.F2, ctrlKey: true }] });
362
383
  assert.ok(consoleLogMock.mock.calls.some(call => call.arguments[0].includes(`Triggers: [ { key: "F1" (no mods) }, { key: "F2", ctrl: true } ]`)), "Multi-trigger log format incorrect");
363
384
  consoleLogMock.mock.restore();
364
385
  });
365
386
  describe("addCombination - Shorthand Syntax", () => {
366
- it("should trigger callback for a simple key using shorthand (e.g., Keys.X)", () => {
367
- const config = { id: "shorthandX", keys: Keys.X, callback: mockCallback };
368
- keyManager.addCombination(config);
369
- dispatchKeyEvent(Keys.X.toLowerCase());
387
+ it("should emit for a simple key using shorthand (e.g., Keys.X)", () => {
388
+ const config = { id: "shorthandX", keys: Keys.X };
389
+ keyManager.addCombination(config).subscribe(mockCallback);
390
+ dispatchKeyEvent(document, Keys.X.toLowerCase());
370
391
  assert.strictEqual(mockCallback.calledCount, 1, `Callback for "x" (shorthand) not called`);
371
392
  mockCallback.mockClear();
372
- dispatchKeyEvent(Keys.X);
393
+ dispatchKeyEvent(document, Keys.X);
373
394
  assert.strictEqual(mockCallback.calledCount, 1, `Callback for "X" (shorthand) not called`);
374
395
  });
375
- it("should NOT trigger callback for shorthand if modifier is pressed", () => {
376
- const config = { id: "shorthandY", keys: Keys.Y, callback: mockCallback };
377
- keyManager.addCombination(config);
378
- dispatchKeyEvent(Keys.Y, { ctrlKey: true });
396
+ it("should NOT emit for shorthand if modifier is pressed", () => {
397
+ const config = { id: "shorthandY", keys: Keys.Y };
398
+ keyManager.addCombination(config).subscribe(mockCallback);
399
+ dispatchKeyEvent(document, Keys.Y, "keydown", { ctrlKey: true });
379
400
  assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Ctrl`);
380
401
  mockCallback.mockClear();
381
- dispatchKeyEvent(Keys.Y, { altKey: true });
402
+ dispatchKeyEvent(document, Keys.Y, "keydown", { altKey: true });
382
403
  assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Alt`);
383
404
  mockCallback.mockClear();
384
- dispatchKeyEvent(Keys.Y, { shiftKey: true });
405
+ dispatchKeyEvent(document, Keys.Y, "keydown", { shiftKey: true });
385
406
  assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Shift`);
386
407
  mockCallback.mockClear();
387
- dispatchKeyEvent(Keys.Y, { metaKey: true });
408
+ dispatchKeyEvent(document, Keys.Y, "keydown", { metaKey: true });
388
409
  assert.strictEqual(mockCallback.calledCount, 0, `Callback for "y" (shorthand) should not be called with Meta`);
389
410
  });
390
- it("should trigger callback for shorthand if ONLY the key is pressed (no modifiers)", () => {
391
- const config = { id: "shorthandZ", keys: Keys.Z, callback: mockCallback };
392
- keyManager.addCombination(config);
393
- dispatchKeyEvent(Keys.Z, { ctrlKey: false, altKey: false, shiftKey: false, metaKey: false });
411
+ it("should emit for shorthand if ONLY the key is pressed (no modifiers)", () => {
412
+ const config = { id: "shorthandZ", keys: Keys.Z };
413
+ keyManager.addCombination(config).subscribe(mockCallback);
414
+ dispatchKeyEvent(document, Keys.Z, "keydown", { ctrlKey: false, altKey: false, shiftKey: false, metaKey: false });
394
415
  assert.strictEqual(mockCallback.calledCount, 1);
395
416
  });
396
417
  it("should handle preventDefault correctly for shorthand", () => {
397
- const config = { id: "shorthandPrevent", keys: Keys.P, callback: mockCallback, preventDefault: true };
398
- keyManager.addCombination(config);
399
- const event = dispatchKeyEvent(Keys.P.toLowerCase());
418
+ const config = { id: "shorthandPrevent", keys: Keys.P, preventDefault: true };
419
+ keyManager.addCombination(config).subscribe(mockCallback);
420
+ const event = dispatchKeyEvent(document, Keys.P.toLowerCase());
400
421
  assert.strictEqual(mockCallback.calledCount, 1);
401
422
  assert.strictEqual(event.defaultPrevented, true);
402
423
  });
403
424
  it("should respect context for shorthand", () => {
404
- const config = { id: "shorthandContext", keys: Keys.C, callback: mockCallback, context: "editor" };
405
- keyManager.addCombination(config);
425
+ const config = { id: "shorthandContext", keys: Keys.C, context: "editor" };
426
+ keyManager.addCombination(config).subscribe(mockCallback);
406
427
  keyManager.setContext("other");
407
- dispatchKeyEvent(Keys.C.toLowerCase());
428
+ dispatchKeyEvent(document, Keys.C.toLowerCase());
408
429
  assert.strictEqual(mockCallback.calledCount, 0);
409
430
  keyManager.setContext("editor");
410
- dispatchKeyEvent(Keys.C.toLowerCase());
431
+ dispatchKeyEvent(document, Keys.C.toLowerCase());
411
432
  assert.strictEqual(mockCallback.calledCount, 1);
412
433
  });
413
- it("should return undefined and warn if shorthand key is an empty string", () => {
414
- const config = { id: "emptyShorthand", keys: "", callback: mockCallback };
415
- const result = keyManager.addCombination(config);
416
- assert.strictEqual(result, undefined);
434
+ it("should return an empty observable and warn if shorthand key is an empty string", () => {
435
+ const config = { id: "emptyShorthand", keys: "" };
436
+ const combo$ = keyManager.addCombination(config);
437
+ combo$.subscribe(mockCallback);
438
+ assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
439
+ assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Could not parse key: "" in shortcut "emptyShorthand"`));
440
+ });
441
+ it("should return an empty observable and warn if shorthand key is an empty array", () => {
442
+ const config = { id: "emptyArrayShorthand", keys: [] };
443
+ const combo$ = keyManager.addCombination(config);
444
+ combo$.subscribe(mockCallback);
445
+ assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
446
+ assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`"keys" definition for combination shortcut "emptyArrayShorthand" is empty or invalid. Shortcut not added.`));
447
+ });
448
+ it("should return an empty observable and warn if shorthand key is an empty string in array", () => {
449
+ const config = { id: "emptyStringShorthand", keys: [""] };
450
+ const combo$ = keyManager.addCombination(config);
451
+ combo$.subscribe(mockCallback);
417
452
  assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
418
- assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid key (shorthand) in shortcut "emptyShorthand". Key string must not be empty.`));
453
+ assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Could not parse key: "" in shortcut "emptyStringShorthand"`));
454
+ });
455
+ it("should return an empty observable and warn if shorthand key is invalid", () => {
456
+ const config = { id: "invalidKeyPropertyShorthand", keys: { key: "" } };
457
+ const combo$ = keyManager.addCombination(config);
458
+ combo$.subscribe(mockCallback);
459
+ assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
460
+ assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid "key" property in shortcut "invalidKeyPropertyShorthand". Key must be a non-empty string value from Keys.`));
419
461
  });
420
462
  it("should correctly log shorthand key details in debug mode", () => {
421
463
  const consoleLogMock = mock.method(console, "log");
422
464
  keyManager.setDebugMode(true);
423
- const config = { id: "debugShorthand", keys: Keys.D, callback: mockCallback };
465
+ const config = { id: "debugShorthand", keys: Keys.D };
424
466
  keyManager.addCombination(config);
425
467
  const logMessage = consoleLogMock.mock.calls.find(call => call.arguments[0].includes(`combination shortcut "debugShorthand" added.`));
426
468
  assert.ok(logMessage, "Debug log for adding shortcut not found");
@@ -428,20 +470,153 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
428
470
  consoleLogMock.mock.restore();
429
471
  keyManager.setDebugMode(false);
430
472
  });
431
- it("should trigger callback for Keys.Space using shorthand", () => {
432
- const config = { id: "spaceShorthand", keys: Keys.Space, callback: mockCallback };
433
- keyManager.addCombination(config);
434
- dispatchKeyEvent(" "); // Event key for space is " "
473
+ it("should emit for Keys.Space using shorthand", () => {
474
+ const config = { id: "spaceShorthand", keys: Keys.Space };
475
+ keyManager.addCombination(config).subscribe(mockCallback);
476
+ dispatchKeyEvent(document, " "); // Event key for space is " "
435
477
  assert.strictEqual(mockCallback.calledCount, 1, "Callback for Keys.Space (shorthand) not called");
436
478
  });
437
- it("should trigger callback for Keys.Space using object form", () => {
438
- const config = { id: "spaceObject", keys: { key: Keys.Space }, callback: mockCallback };
439
- keyManager.addCombination(config);
440
- dispatchKeyEvent(" ");
479
+ it("should emit for Keys.Space using object form", () => {
480
+ const config = { id: "spaceObject", keys: { key: Keys.Space } };
481
+ keyManager.addCombination(config).subscribe(mockCallback);
482
+ dispatchKeyEvent(document, " ");
441
483
  assert.strictEqual(mockCallback.calledCount, 1, "Callback for Keys.Space (object form) not called");
442
484
  });
443
485
  });
444
486
  });
487
+ describe("String-based Definitions", () => {
488
+ it(`should parse and trigger a simple string "ctrl+s"`, () => {
489
+ keyManager.addCombination({ id: "strSave", keys: "ctrl+s" }).subscribe(mockCallback);
490
+ dispatchKeyEvent(document, "s", "keydown", { ctrlKey: true });
491
+ assert.strictEqual(mockCallback.calledCount, 1);
492
+ dispatchKeyEvent(document, "s");
493
+ assert.strictEqual(mockCallback.calledCount, 1);
494
+ });
495
+ it(`should parse and trigger "shift+alt+k"`, () => {
496
+ keyManager.addCombination({ id: "strComplex", keys: "shift+alt+k" }).subscribe(mockCallback);
497
+ dispatchKeyEvent(document, "k", "keydown", { shiftKey: true, altKey: true });
498
+ assert.strictEqual(mockCallback.calledCount, 1);
499
+ });
500
+ it(`should parse aliases like "cmd+p"`, () => {
501
+ keyManager.addCombination({ id: "strAlias", keys: "cmd+p" }).subscribe(mockCallback);
502
+ dispatchKeyEvent(document, "p", "keydown", { metaKey: true });
503
+ assert.strictEqual(mockCallback.calledCount, 1);
504
+ });
505
+ it(`should parse special key strings like "escape"`, () => {
506
+ keyManager.addCombination({ id: "strSpecial", keys: "escape" }).subscribe(mockCallback);
507
+ dispatchKeyEvent(document, "Escape");
508
+ assert.strictEqual(mockCallback.calledCount, 1);
509
+ });
510
+ it(`should parse sequence string "g -> i"`, () => {
511
+ keyManager.addSequence({ id: "strSeq", sequence: "g -> i" }).subscribe(mockCallback);
512
+ dispatchKeyEvent(document, "g");
513
+ dispatchKeyEvent(document, "i");
514
+ assert.strictEqual(mockCallback.calledCount, 1);
515
+ });
516
+ it(`should parse sequence string with special keys "up -> down -> enter"`, () => {
517
+ keyManager.addSequence({ id: "strSeqSpecial", sequence: "up -> down -> enter" }).subscribe(mockCallback);
518
+ dispatchKeyEvent(document, "ArrowUp");
519
+ dispatchKeyEvent(document, "ArrowDown");
520
+ dispatchKeyEvent(document, "Enter");
521
+ assert.strictEqual(mockCallback.calledCount, 1);
522
+ });
523
+ it("should warn and return empty on invalid string", () => {
524
+ const combo$ = keyManager.addCombination({ id: "invalid", keys: "ctrl+badkey" });
525
+ combo$.subscribe(mockCallback);
526
+ assert.ok(consoleWarnMock.mock.calls.some(c => c.arguments[0].includes(`Could not parse key: "badkey"`)));
527
+ assert.strictEqual(mockCallback.calledCount, 0);
528
+ });
529
+ });
530
+ describe("Normalization and Edge Case Tests", () => {
531
+ it(`should handle the "+" key as a shorthand, not a combination`, () => {
532
+ keyManager.addCombination({ id: "plusKey", keys: Keys.KeypadAdd }).subscribe(mockCallback);
533
+ dispatchKeyEvent(document, "+");
534
+ assert.strictEqual(mockCallback.calledCount, 1, `Callback for "+" key not called`);
535
+ });
536
+ it(`should handle "escape" (lowercase) and normalize it to match the "Escape" event key`, () => {
537
+ keyManager.addCombination({ id: "lowerEscape", keys: "escape" }).subscribe(mockCallback);
538
+ dispatchKeyEvent(document, "Escape"); // Event key from browser is capitalized
539
+ assert.strictEqual(mockCallback.calledCount, 1, `Lowercase "escape" did not match event`);
540
+ });
541
+ it(`should handle "ESC" (uppercase) and normalize it`, () => {
542
+ keyManager.addCombination({ id: "upperEsc", keys: "ESC" }).subscribe(mockCallback);
543
+ dispatchKeyEvent(document, "Escape");
544
+ assert.strictEqual(mockCallback.calledCount, 1, `Uppercase "ESC" did not match event`);
545
+ });
546
+ it(`should handle a single-word modifier alias like "cmd" as a shorthand for the "Meta" key`, () => {
547
+ keyManager.addCombination({ id: "cmdKey", keys: "cmd" }).subscribe(mockCallback);
548
+ dispatchKeyEvent(document, "Meta"); // Event key for command key is "Meta"
549
+ assert.strictEqual(mockCallback.calledCount, 1, `Alias "cmd" did not match "Meta" key event`);
550
+ });
551
+ it(`should handle combination string with extra spaces like " ctrl + s "`, () => {
552
+ keyManager.addCombination({ id: "paddedCombo", keys: " ctrl + s " }).subscribe(mockCallback);
553
+ dispatchKeyEvent(document, "s", "keydown", { ctrlKey: true });
554
+ assert.strictEqual(mockCallback.calledCount, 1, "Padded combination string failed to parse");
555
+ });
556
+ });
557
+ describe("New Feature: Element-Scoped Listeners", () => {
558
+ let el1, el2;
559
+ beforeEach(() => {
560
+ el1 = document.createElement("div");
561
+ el2 = document.createElement("div");
562
+ document.body.append(el1, el2);
563
+ });
564
+ afterEach(() => {
565
+ el1.remove();
566
+ el2.remove();
567
+ });
568
+ it("should only trigger shortcut on the specified target element", () => {
569
+ keyManager.addCombination({ id: "scoped", keys: "a", target: el1 }).subscribe(mockCallback);
570
+ dispatchKeyEvent(el1, "a");
571
+ assert.strictEqual(mockCallback.calledCount, 1, "Should trigger on target element");
572
+ dispatchKeyEvent(el2, "a");
573
+ assert.strictEqual(mockCallback.calledCount, 1, "Should NOT trigger on another element");
574
+ dispatchKeyEvent(document, "a");
575
+ assert.strictEqual(mockCallback.calledCount, 1, "Should NOT trigger on document");
576
+ });
577
+ it("should trigger global shortcut if no target is specified", () => {
578
+ keyManager.addCombination({ id: "global", keys: "b" }).subscribe(mockCallback);
579
+ // Event bubbles up from el1 to document
580
+ dispatchKeyEvent(el1, "b");
581
+ assert.strictEqual(mockCallback.calledCount, 1, "Should trigger on event from child element");
582
+ dispatchKeyEvent(document, "b");
583
+ assert.strictEqual(mockCallback.calledCount, 2, "Should trigger on document directly");
584
+ });
585
+ it("should work for sequences on a specific target", () => {
586
+ keyManager.addSequence({ id: "scopedSeq", sequence: "a -> b", target: el1 }).subscribe(mockCallback);
587
+ dispatchKeyEvent(el1, "a");
588
+ dispatchKeyEvent(el1, "b");
589
+ assert.strictEqual(mockCallback.calledCount, 1, "Sequence should trigger on target");
590
+ dispatchKeyEvent(document, "a");
591
+ dispatchKeyEvent(document, "b");
592
+ assert.strictEqual(mockCallback.calledCount, 1, "Sequence should not trigger on document");
593
+ });
594
+ });
595
+ describe("New Feature: keyup Event Support", () => {
596
+ it("should trigger combination on keyup when specified", () => {
597
+ keyManager.addCombination({ id: "keyupCombo", keys: "a", event: "keyup" }).subscribe(mockCallback);
598
+ dispatchKeyEvent(document, "a", "keydown");
599
+ assert.strictEqual(mockCallback.calledCount, 0, "Should not trigger on keydown");
600
+ dispatchKeyEvent(document, "a", "keyup");
601
+ assert.strictEqual(mockCallback.calledCount, 1, "Should trigger on keyup");
602
+ });
603
+ it("should trigger sequence on keyup when specified", () => {
604
+ keyManager.addSequence({ id: "keyupSeq", sequence: "a -> b", event: "keyup" }).subscribe(mockCallback);
605
+ dispatchKeyEvent(document, "a", "keydown");
606
+ dispatchKeyEvent(document, "b", "keydown");
607
+ assert.strictEqual(mockCallback.calledCount, 0, "Sequence should not trigger on keydown events");
608
+ dispatchKeyEvent(document, "a", "keyup");
609
+ dispatchKeyEvent(document, "b", "keyup");
610
+ assert.strictEqual(mockCallback.calledCount, 1, "Sequence should trigger on keyup events");
611
+ });
612
+ it("should default to keydown if event type is not specified", () => {
613
+ keyManager.addCombination({ id: "keydownDefault", keys: "c" }).subscribe(mockCallback);
614
+ dispatchKeyEvent(document, "c", "keyup");
615
+ assert.strictEqual(mockCallback.calledCount, 0);
616
+ dispatchKeyEvent(document, "c", "keydown");
617
+ assert.strictEqual(mockCallback.calledCount, 1);
618
+ });
619
+ });
445
620
  describe("Context Priority (Specific > Global)", () => {
446
621
  let globalCallback;
447
622
  let specificCallback;
@@ -452,31 +627,29 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
452
627
  keyManager.addCombination({
453
628
  id: "globalCtrlG",
454
629
  keys: { key: Keys.G, ctrlKey: true },
455
- callback: globalCallback,
456
630
  context: null // Explicitly global
457
- });
631
+ }).subscribe(globalCallback);
458
632
  // Specific context shortcut: Ctrl+G in "editor" context
459
633
  keyManager.addCombination({
460
634
  id: "editorCtrlG",
461
635
  keys: { key: Keys.G, ctrlKey: true },
462
- callback: specificCallback,
463
636
  context: "editor"
464
- });
637
+ }).subscribe(specificCallback);
465
638
  });
466
639
  it("should only trigger specific context callback when specific context is active", () => {
467
640
  keyManager.setContext("editor");
468
- dispatchKeyEvent(Keys.G, { ctrlKey: true });
641
+ dispatchKeyEvent(document, Keys.G, "keydown", { ctrlKey: true });
469
642
  assert.strictEqual(specificCallback.calledCount, 1, "Specific callback should have been called");
470
643
  assert.strictEqual(globalCallback.calledCount, 0, "Global callback should NOT have been called");
471
644
  });
472
645
  it("should only trigger global callback when no specific context is active (or context doesn't match)", () => {
473
646
  keyManager.setContext(null); // No specific context
474
- dispatchKeyEvent(Keys.G, { ctrlKey: true });
647
+ dispatchKeyEvent(document, Keys.G, "keydown", { ctrlKey: true });
475
648
  assert.strictEqual(specificCallback.calledCount, 0, "Specific callback should NOT have been called");
476
649
  assert.strictEqual(globalCallback.calledCount, 1, "Global callback should have been called");
477
650
  globalCallback.mockClear();
478
651
  keyManager.setContext("anotherContext"); // Different specific context
479
- dispatchKeyEvent(Keys.G, { ctrlKey: true });
652
+ dispatchKeyEvent(document, Keys.G, "keydown", { ctrlKey: true });
480
653
  assert.strictEqual(specificCallback.calledCount, 0, `Specific callback should NOT have been called for "anotherContext"`);
481
654
  assert.strictEqual(globalCallback.calledCount, 1, `Global callback should have been called when in "anotherContext"`);
482
655
  });
@@ -491,31 +664,29 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
491
664
  keyManager.addSequence({
492
665
  id: "globalGI",
493
666
  sequence: testSequence,
494
- callback: globalSeqCallback,
495
667
  context: null // Global
496
- });
668
+ }).subscribe(globalSeqCallback);
497
669
  keyManager.addSequence({
498
670
  id: "editorGI",
499
671
  sequence: testSequence,
500
- callback: specificSeqCallback,
501
672
  context: "editor" // Specific
502
- });
673
+ }).subscribe(specificSeqCallback);
503
674
  });
504
675
  it("should only trigger specific context sequence callback when specific context is active", () => {
505
676
  keyManager.setContext("editor");
506
- testSequence.forEach(key => dispatchKeyEvent(key));
677
+ testSequence.forEach(key => dispatchKeyEvent(document, key));
507
678
  assert.strictEqual(specificSeqCallback.calledCount, 1, "Specific sequence callback should have been called");
508
679
  assert.strictEqual(globalSeqCallback.calledCount, 0, "Global sequence callback should NOT have been called");
509
680
  });
510
681
  it("should only trigger global sequence callback when no specific context is active (or context doesn't match)", () => {
511
682
  keyManager.setContext(null); // No specific context
512
- testSequence.forEach(key => dispatchKeyEvent(key));
683
+ testSequence.forEach(key => dispatchKeyEvent(document, key));
513
684
  assert.strictEqual(specificSeqCallback.calledCount, 0, "Specific sequence callback should NOT have been called");
514
685
  assert.strictEqual(globalSeqCallback.calledCount, 1, "Global sequence callback should have been called");
515
686
  globalSeqCallback.mockClear();
516
687
  specificSeqCallback.mockClear(); // Clear for next part of test
517
688
  keyManager.setContext("anotherContext"); // Different specific context
518
- testSequence.forEach(key => dispatchKeyEvent(key));
689
+ testSequence.forEach(key => dispatchKeyEvent(document, key));
519
690
  assert.strictEqual(specificSeqCallback.calledCount, 0, `Specific sequence callback should NOT have been called for "anotherContext"`);
520
691
  assert.strictEqual(globalSeqCallback.calledCount, 1, `Global sequence callback should have been called when in "anotherContext"`);
521
692
  });
@@ -532,47 +703,43 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
532
703
  keyManager.addCombination({
533
704
  id: "editorSave",
534
705
  keys: { key: Keys.S, ctrlKey: true },
535
- callback: specificContextCallback,
536
706
  context: "editor"
537
- });
707
+ }).subscribe(specificContextCallback);
538
708
  // 2. A "strictly global" shortcut, which only runs when context is null
539
709
  keyManager.addCombination({
540
710
  id: "strictGlobalOpen",
541
- keys: { key: Keys.O, ctrlKey: true }, // Using shorthand is now possible!
542
- callback: strictGlobalCallback,
543
- strict: true, // `strict` is at the top level
544
- });
711
+ keys: { key: Keys.O, ctrlKey: true },
712
+ strict: true,
713
+ }).subscribe(strictGlobalCallback);
545
714
  // 3. A default global shortcut, which runs in any context unless overridden
546
715
  keyManager.addCombination({
547
716
  id: "defaultGlobalSave",
548
717
  keys: { key: Keys.S, ctrlKey: true },
549
- callback: defaultGlobalCallback,
550
- // No `strict` flag here
551
- });
718
+ }).subscribe(defaultGlobalCallback);
552
719
  });
553
720
  it("should trigger both strict and default global shortcuts when context is null", () => {
554
721
  keyManager.setContext(null);
555
- dispatchKeyEvent(Keys.O, { ctrlKey: true });
722
+ dispatchKeyEvent(document, Keys.O, "keydown", { ctrlKey: true });
556
723
  assert.strictEqual(strictGlobalCallback.calledCount, 1, "Strictly global (Ctrl+O) should fire");
557
- dispatchKeyEvent(Keys.S, { ctrlKey: true });
724
+ dispatchKeyEvent(document, Keys.S, "keydown", { ctrlKey: true });
558
725
  assert.strictEqual(defaultGlobalCallback.calledCount, 1, "Default global (Ctrl+S) should fire");
559
726
  assert.strictEqual(specificContextCallback.calledCount, 0, "Specific context callback should not fire");
560
727
  });
561
728
  it("should suppress strict global but allow default global (which is then suppressed by priority)", () => {
562
729
  keyManager.setContext("editor");
563
- dispatchKeyEvent(Keys.O, { ctrlKey: true });
564
- assert.strictEqual(strictGlobalCallback.calledCount, 0, "Strictly global (Ctrl+O) should NOT fire in 'editor' context");
565
- dispatchKeyEvent(Keys.S, { ctrlKey: true });
730
+ dispatchKeyEvent(document, Keys.O, "keydown", { ctrlKey: true });
731
+ assert.strictEqual(strictGlobalCallback.calledCount, 0, `Strictly global (Ctrl+O) should NOT fire in "editor" context`);
732
+ dispatchKeyEvent(document, Keys.S, "keydown", { ctrlKey: true });
566
733
  assert.strictEqual(defaultGlobalCallback.calledCount, 0, "Default global (Ctrl+S) should be suppressed by the specific one");
567
- assert.strictEqual(specificContextCallback.calledCount, 1, "Specific 'editor' callback (Ctrl+S) should fire and take priority");
734
+ assert.strictEqual(specificContextCallback.calledCount, 1, `Specific "editor" callback (Ctrl+S) should fire and take priority`);
568
735
  });
569
736
  it("should suppress strict global but trigger default global in a non-conflicting context", () => {
570
737
  keyManager.setContext("someOtherContext");
571
- dispatchKeyEvent(Keys.O, { ctrlKey: true });
572
- assert.strictEqual(strictGlobalCallback.calledCount, 0, "Strictly global (Ctrl+O) should NOT fire in 'someOtherContext'");
573
- dispatchKeyEvent(Keys.S, { ctrlKey: true });
738
+ dispatchKeyEvent(document, Keys.O, "keydown", { ctrlKey: true });
739
+ assert.strictEqual(strictGlobalCallback.calledCount, 0, `Strictly global (Ctrl+O) should NOT fire in "someOtherContext"`);
740
+ dispatchKeyEvent(document, Keys.S, "keydown", { ctrlKey: true });
574
741
  assert.strictEqual(defaultGlobalCallback.calledCount, 1, "Default global (Ctrl+S) should fire since no override exists for this context");
575
- assert.strictEqual(specificContextCallback.calledCount, 0, "Specific 'editor' callback should not fire");
742
+ assert.strictEqual(specificContextCallback.calledCount, 0, `Specific "editor" callback should not fire`);
576
743
  });
577
744
  });
578
745
  describe("Sequence Context Behavior (`strict` flag)", () => {
@@ -585,116 +752,107 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
585
752
  strictSeqCallback = createMockFn();
586
753
  defaultSeqCallback = createMockFn();
587
754
  specificSeqCallback = createMockFn();
588
- keyManager.addSequence({ id: "strictSeq", sequence: [Keys.G, Keys.O], callback: strictSeqCallback, strict: true });
589
- keyManager.addSequence({ id: "defaultSeq", sequence: testSequence, callback: defaultSeqCallback });
590
- keyManager.addSequence({ id: "specificSeq", sequence: testSequence, callback: specificSeqCallback, context: "editor" });
755
+ keyManager.addSequence({ id: "strictSeq", sequence: [Keys.G, Keys.O], strict: true }).subscribe(strictSeqCallback);
756
+ keyManager.addSequence({ id: "defaultSeq", sequence: testSequence }).subscribe(defaultSeqCallback);
757
+ keyManager.addSequence({ id: "specificSeq", sequence: testSequence, context: "editor" }).subscribe(specificSeqCallback);
591
758
  });
592
759
  it("should trigger both strict and default global sequences when context is null", () => {
593
760
  keyManager.setContext(null);
594
- [Keys.G, Keys.O].forEach(key => dispatchKeyEvent(key));
761
+ [Keys.G, Keys.O].forEach(key => dispatchKeyEvent(document, key));
595
762
  assert.strictEqual(strictSeqCallback.calledCount, 1, "Strict sequence should fire");
596
- testSequence.forEach(key => dispatchKeyEvent(key));
763
+ testSequence.forEach(key => dispatchKeyEvent(document, key));
597
764
  assert.strictEqual(defaultSeqCallback.calledCount, 1, "Default sequence should fire");
598
765
  assert.strictEqual(specificSeqCallback.calledCount, 0, "Specific sequence should not fire");
599
766
  });
600
767
  it("should suppress strict sequence and prioritize specific sequence in a matching context", () => {
601
768
  keyManager.setContext("editor");
602
- [Keys.G, Keys.O].forEach(key => dispatchKeyEvent(key));
603
- assert.strictEqual(strictSeqCallback.calledCount, 0, "Strict sequence should NOT fire in 'editor' context");
604
- testSequence.forEach(key => dispatchKeyEvent(key));
769
+ [Keys.G, Keys.O].forEach(key => dispatchKeyEvent(document, key));
770
+ assert.strictEqual(strictSeqCallback.calledCount, 0, `Strict sequence should NOT fire in "editor" context`);
771
+ testSequence.forEach(key => dispatchKeyEvent(document, key));
605
772
  assert.strictEqual(defaultSeqCallback.calledCount, 0, "Default global sequence should be suppressed");
606
- assert.strictEqual(specificSeqCallback.calledCount, 1, "Specific 'editor' sequence should fire");
773
+ assert.strictEqual(specificSeqCallback.calledCount, 1, `Specific "editor" sequence should fire`);
607
774
  });
608
775
  it("should suppress strict sequence but trigger default global sequence in a non-conflicting context", () => {
609
776
  keyManager.setContext("someOtherContext");
610
- [Keys.G, Keys.O].forEach(key => dispatchKeyEvent(key));
777
+ [Keys.G, Keys.O].forEach(key => dispatchKeyEvent(document, key));
611
778
  assert.strictEqual(strictSeqCallback.calledCount, 0, "Strict sequence should NOT fire");
612
- testSequence.forEach(key => dispatchKeyEvent(key));
779
+ testSequence.forEach(key => dispatchKeyEvent(document, key));
613
780
  assert.strictEqual(defaultSeqCallback.calledCount, 1, "Default global sequence should fire");
614
- assert.strictEqual(specificSeqCallback.calledCount, 0, "Specific 'editor' sequence should not fire");
781
+ assert.strictEqual(specificSeqCallback.calledCount, 0, `Specific "editor" sequence should not fire`);
615
782
  });
616
783
  });
617
784
  describe("addSequence", () => {
618
- it("should trigger callback for a simple key sequence", () => {
619
- const config = { id: "seqGI", sequence: [Keys.G, Keys.I], callback: mockCallback };
620
- const result = keyManager.addSequence(config);
621
- assert.strictEqual(result, "seqGI");
622
- dispatchKeyEvent("g"); // Dispatch "g" (lowercase)
623
- dispatchKeyEvent("i"); // Dispatch "i" (lowercase)
785
+ it("should return an Observable that emits on a simple key sequence", () => {
786
+ const config = { id: "seqGI", sequence: [Keys.G, Keys.I] };
787
+ const seq$ = keyManager.addSequence(config);
788
+ assert(seq$ instanceof Observable, "Did not return an Observable");
789
+ seq$.subscribe(mockCallback);
790
+ dispatchKeyEvent(document, "g");
791
+ dispatchKeyEvent(document, "i");
624
792
  assert.strictEqual(mockCallback.calledCount, 1);
625
793
  });
626
- it("should trigger callback for Konami code using Keys", () => {
794
+ it("should emit on Konami code using Keys", () => {
627
795
  const konamiSequence = [
628
796
  Keys.ArrowUp, Keys.ArrowUp, Keys.ArrowDown, Keys.ArrowDown,
629
797
  Keys.ArrowLeft, Keys.ArrowRight, Keys.ArrowLeft, Keys.ArrowRight,
630
- Keys.B, Keys.A // Using "B" and "A" from Keys
798
+ Keys.B, Keys.A
631
799
  ];
632
- const config = { id: "konami", sequence: konamiSequence, callback: mockCallback };
633
- keyManager.addSequence(config);
634
- // Dispatch events using the string values that browser events would produce
635
- ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"].forEach(key => dispatchKeyEvent(key));
800
+ const config = { id: "konami", sequence: konamiSequence };
801
+ keyManager.addSequence(config).subscribe(mockCallback);
802
+ ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"].forEach(key => dispatchKeyEvent(document, key));
636
803
  assert.strictEqual(mockCallback.calledCount, 1, "Konami sequence callback not triggered");
637
804
  });
638
- it("should return undefined and warn if sequence is empty", () => {
639
- const config = { id: "emptySeq", sequence: [], callback: mockCallback };
640
- const result = keyManager.addSequence(config);
641
- assert.strictEqual(result, undefined);
805
+ it("should return an empty observable and warn if sequence is empty", () => {
806
+ const config = { id: "emptySeq", sequence: [] };
807
+ const seq$ = keyManager.addSequence(config);
808
+ seq$.subscribe(mockCallback);
642
809
  assert.strictEqual(consoleWarnMock.mock.calls.length, 1);
643
810
  assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Sequence for shortcut "emptySeq" is empty`));
811
+ dispatchKeyEvent(document, "a");
812
+ assert.strictEqual(mockCallback.calledCount, 0);
644
813
  });
645
- it(`should return undefined and warn if sequence contains an invalid key (runtime check with "as any")`, () => {
646
- // This test checks runtime robustness if `any` is used to bypass StandardKey[]
647
- const config = { id: "invalidKeyInSeq", sequence: [Keys.A, "", Keys.C], callback: mockCallback };
648
- const result = keyManager.addSequence(config);
649
- assert.strictEqual(result, undefined, "addSequence should return undefined for sequence with empty string");
814
+ it(`should return an empty observable and warn if sequence contains an invalid key`, () => {
815
+ const config = { id: "invalidKeyInSeq", sequence: [Keys.A, "", Keys.C] };
816
+ const seq$ = keyManager.addSequence(config);
817
+ seq$.subscribe(mockCallback);
650
818
  assert.strictEqual(consoleWarnMock.mock.calls.length, 1, "console.warn was not called for invalid key in sequence");
651
819
  assert.ok(consoleWarnMock.mock.calls[0].arguments[0].includes(`Invalid key in sequence for shortcut "invalidKeyInSeq"`));
652
820
  });
653
- it("should pass the last KeyboardEvent of the sequence to the callback", () => {
654
- const config = { id: "seqEventPass", sequence: [Keys.X, Keys.Y], callback: mockCallback };
655
- keyManager.addSequence(config);
656
- dispatchKeyEvent("x");
657
- const lastEvent = dispatchKeyEvent("y");
821
+ it("should pass the last KeyboardEvent of the sequence to the subscriber", () => {
822
+ const config = { id: "seqEventPass", sequence: [Keys.X, Keys.Y] };
823
+ keyManager.addSequence(config).subscribe(mockCallback);
824
+ dispatchKeyEvent(document, "x");
825
+ const lastEvent = dispatchKeyEvent(document, "y");
658
826
  assert.strictEqual(mockCallback.calledCount, 1);
659
827
  assert.deepStrictEqual(mockCallback.lastArgs, [lastEvent]);
660
828
  });
661
829
  it("should prevent default for the last key event in the sequence when preventDefault is true", () => {
662
- const config = { id: "seqPrevent", sequence: [Keys.M, Keys.N], callback: mockCallback, preventDefault: true };
663
- keyManager.addSequence(config);
664
- dispatchKeyEvent("m");
665
- const eventN = dispatchKeyEvent("n");
830
+ const config = { id: "seqPrevent", sequence: [Keys.M, Keys.N], preventDefault: true };
831
+ keyManager.addSequence(config).subscribe(mockCallback);
832
+ dispatchKeyEvent(document, "m");
833
+ const eventN = dispatchKeyEvent(document, "n");
666
834
  assert.strictEqual(mockCallback.calledCount, 1);
667
835
  assert.strictEqual(eventN.defaultPrevented, true);
668
836
  });
669
- it("should log an error via console.error if sequence callback throws", () => {
670
- const errorCallback = () => { throw new Error("Test sequence callback error"); };
671
- keyManager.addSequence({ id: "errorSeq", sequence: [Keys.E, Keys.S], callback: errorCallback });
672
- dispatchKeyEvent("e");
673
- dispatchKeyEvent("s");
674
- assert.strictEqual(consoleErrorMock.mock.calls.length, 1);
675
- assert.ok(consoleErrorMock.mock.calls[0].arguments[0].includes(`Error in user callback for sequence shortcut "errorSeq"`));
676
- });
677
- it("should trigger callback for a sequence including Keys.Space", () => {
837
+ it("should emit for a sequence including Keys.Space", () => {
678
838
  const config = {
679
839
  id: "seqWithSpace",
680
840
  sequence: [Keys.G, Keys.Space, Keys.I],
681
- callback: mockCallback
682
841
  };
683
- keyManager.addSequence(config);
684
- dispatchKeyEvent(Keys.G);
685
- dispatchKeyEvent(Keys.Space); // Dispatch " " for space
686
- dispatchKeyEvent(Keys.I);
842
+ keyManager.addSequence(config).subscribe(mockCallback);
843
+ dispatchKeyEvent(document, Keys.G);
844
+ dispatchKeyEvent(document, Keys.Space); // Dispatch " " for space
845
+ dispatchKeyEvent(document, Keys.I);
687
846
  assert.strictEqual(mockCallback.calledCount, 1, "Callback for sequence with Keys.Space not called");
688
847
  });
689
- it("should trigger callback for a sequence starting or ending with Keys.Space", () => {
848
+ it("should emit for a sequence starting or ending with Keys.Space", () => {
690
849
  const config = {
691
850
  id: "seqStartSpace",
692
851
  sequence: [Keys.Space, Keys.A],
693
- callback: mockCallback
694
852
  };
695
- keyManager.addSequence(config);
696
- dispatchKeyEvent(Keys.Space);
697
- dispatchKeyEvent(Keys.A);
853
+ keyManager.addSequence(config).subscribe(mockCallback);
854
+ dispatchKeyEvent(document, Keys.Space);
855
+ dispatchKeyEvent(document, Keys.A);
698
856
  assert.strictEqual(mockCallback.calledCount, 1, "Callback for sequence starting with Keys.Space not called");
699
857
  });
700
858
  describe("Sequence Contextual Triggering", () => {
@@ -703,14 +861,13 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
703
861
  editorSequenceConfig = {
704
862
  id: "sequenceInEditor",
705
863
  sequence: [Keys.C, Keys.O, Keys.D, Keys.E],
706
- callback: mockCallback,
707
864
  context: "editor",
708
865
  };
709
866
  });
710
- it("should trigger sequence in matching context", () => {
711
- keyManager.addSequence(editorSequenceConfig);
867
+ it("should emit sequence in matching context", () => {
868
+ keyManager.addSequence(editorSequenceConfig).subscribe(mockCallback);
712
869
  keyManager.setContext("editor");
713
- [Keys.C, Keys.O, Keys.D, Keys.E].forEach(k => dispatchKeyEvent(k));
870
+ [Keys.C, Keys.O, Keys.D, Keys.E].forEach(k => dispatchKeyEvent(document, k));
714
871
  assert.strictEqual(mockCallback.calledCount, 1);
715
872
  });
716
873
  });
@@ -737,48 +894,54 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
737
894
  }
738
895
  mock.timers.reset();
739
896
  });
740
- it("should trigger sequence if keys are pressed within specified timeout", () => {
741
- const config = { id: "seqTimeoutOk", sequence: [Keys.T, Keys.O, Keys.K], callback: mockCallback, sequenceTimeoutMs: TIMEOUT_MS };
742
- keyManager.addSequence(config);
743
- dispatchKeyEvent(Keys.T);
897
+ it("should emit if keys are pressed within specified timeout", () => {
898
+ const config = { id: "seqTimeoutOk", sequence: [Keys.T, Keys.O, Keys.K], sequenceTimeoutMs: TIMEOUT_MS };
899
+ keyManager.addSequence(config).subscribe(mockCallback);
900
+ dispatchKeyEvent(document, Keys.T);
744
901
  mock.timers.tick(TIMEOUT_MS / 2);
745
- dispatchKeyEvent(Keys.O);
902
+ dispatchKeyEvent(document, Keys.O);
746
903
  mock.timers.tick(TIMEOUT_MS / 2);
747
- const lastEvent = dispatchKeyEvent(Keys.K);
904
+ const lastEvent = dispatchKeyEvent(document, Keys.K);
748
905
  assert.strictEqual(mockCallback.calledCount, 1);
749
906
  assert.deepStrictEqual(mockCallback.lastArgs, [lastEvent]);
750
907
  });
751
- it("should NOT trigger sequence if a key press is delayed beyond timeout", () => {
752
- const config = { id: "seqTimeoutFail", sequence: [Keys.D, Keys.E, Keys.L], callback: mockCallback, sequenceTimeoutMs: TIMEOUT_MS };
753
- keyManager.addSequence(config);
754
- dispatchKeyEvent(Keys.D);
908
+ it("should NOT emit if a key press is delayed beyond timeout", () => {
909
+ const config = { id: "seqTimeoutFail", sequence: [Keys.D, Keys.E, Keys.L], sequenceTimeoutMs: TIMEOUT_MS };
910
+ keyManager.addSequence(config).subscribe(mockCallback);
911
+ dispatchKeyEvent(document, Keys.D);
755
912
  mock.timers.tick(TIMEOUT_MS / 2);
756
- dispatchKeyEvent(Keys.E);
913
+ dispatchKeyEvent(document, Keys.E);
757
914
  mock.timers.tick(TIMEOUT_MS + 1);
758
- dispatchKeyEvent(Keys.L);
915
+ dispatchKeyEvent(document, Keys.L);
759
916
  assert.strictEqual(mockCallback.calledCount, 0);
760
917
  });
761
918
  });
762
919
  });
763
920
  describe("remove", () => {
764
- it("should remove a combination shortcut", () => {
765
- keyManager.addCombination({ id: "remA", keys: { key: Keys.A }, callback: mockCallback });
921
+ it("should remove a combination shortcut and complete its observable", () => {
922
+ const completeCallback = createMockFn();
923
+ const combo$ = keyManager.addCombination({ id: "remA", keys: { key: Keys.A } });
924
+ combo$.subscribe({ next: mockCallback, complete: completeCallback });
766
925
  assert.strictEqual(keyManager.remove("remA"), true);
767
- dispatchKeyEvent(Keys.A);
926
+ assert.strictEqual(completeCallback.calledCount, 1, "Observable should have completed");
927
+ dispatchKeyEvent(document, Keys.A);
768
928
  assert.strictEqual(mockCallback.calledCount, 0);
769
929
  });
770
- it("should remove a sequence shortcut", () => {
771
- keyManager.addSequence({ id: "remSeq", sequence: [Keys.A, Keys.B], callback: mockCallback });
930
+ it("should remove a sequence shortcut and complete its observable", () => {
931
+ const completeCallback = createMockFn();
932
+ const seq$ = keyManager.addSequence({ id: "remSeq", sequence: [Keys.A, Keys.B] });
933
+ seq$.subscribe({ next: mockCallback, complete: completeCallback });
772
934
  assert.strictEqual(keyManager.remove("remSeq"), true);
773
- dispatchKeyEvent(Keys.A);
774
- dispatchKeyEvent(Keys.B);
935
+ assert.strictEqual(completeCallback.calledCount, 1, "Observable should have completed");
936
+ dispatchKeyEvent(document, Keys.A);
937
+ dispatchKeyEvent(document, Keys.B);
775
938
  assert.strictEqual(mockCallback.calledCount, 0);
776
939
  });
777
940
  });
778
941
  describe("getActiveShortcuts", () => {
779
942
  it("should return active combination and sequence shortcuts with enum types", () => {
780
- keyManager.addCombination({ id: "combo1", keys: { key: Keys.A }, callback: createMockFn(), description: "Test A" });
781
- keyManager.addSequence({ id: "seq1", sequence: [Keys.B, Keys.C], callback: createMockFn(), context: "modal", description: "Test BC" });
943
+ keyManager.addCombination({ id: "combo1", keys: { key: Keys.A }, description: "Test A" });
944
+ keyManager.addSequence({ id: "seq1", sequence: [Keys.B, Keys.C], context: "modal", description: "Test BC" });
782
945
  const active = keyManager.getActiveShortcuts();
783
946
  assert.strictEqual(active.length, 2);
784
947
  const combo = active.find(s => s.id === "combo1");
@@ -791,29 +954,30 @@ describe("Hotkeys Library (Node.js Test Runner)", () => {
791
954
  });
792
955
  describe("hasShortcut", () => {
793
956
  it("should return true for an existing combination shortcut", () => {
794
- keyManager.addCombination({ id: "existsCombo", keys: { key: Keys.E }, callback: mockCallback });
957
+ keyManager.addCombination({ id: "existsCombo", keys: { key: Keys.E } });
795
958
  assert.strictEqual(keyManager.hasShortcut("existsCombo"), true);
796
959
  });
797
960
  it("should return false for a shortcut that failed to add (e.g. invalid key object)", () => {
798
- // This test now relies on the runtime check for !keys.key, as TS would catch `key: null` directly.
799
- const config = { id: "invalidKeyCombo", keys: { key: null }, callback: mockCallback };
961
+ const config = { id: "invalidKeyCombo", keys: { key: null } };
800
962
  keyManager.addCombination(config);
801
963
  assert.strictEqual(keyManager.hasShortcut("invalidKeyCombo"), false);
802
964
  });
803
965
  });
804
966
  describe("destroy", () => {
805
- it("should clear active shortcuts and prevent further triggers", () => {
806
- keyManager.addCombination({ id: "destroyTestCombo", keys: { key: Keys.D }, callback: mockCallback });
807
- keyManager.addSequence({ id: "destroyTestSeq", sequence: [Keys.X, Keys.Y], callback: mockCallback });
967
+ it("should clear active shortcuts and complete all observables", () => {
968
+ const comboComplete = createMockFn();
969
+ const seqComplete = createMockFn();
970
+ const combo$ = keyManager.addCombination({ id: "destroyTestCombo", keys: { key: Keys.D } });
971
+ combo$.subscribe({ complete: comboComplete });
972
+ const seq$ = keyManager.addSequence({ id: "destroyTestSeq", sequence: [Keys.X, Keys.Y] });
973
+ seq$.subscribe({ complete: seqComplete });
808
974
  // @ts-ignore
809
975
  assert.strictEqual(keyManager["activeShortcuts"].size, 2);
810
976
  keyManager.destroy();
811
977
  // @ts-ignore
812
978
  assert.strictEqual(keyManager["activeShortcuts"].size, 0);
813
- dispatchKeyEvent(Keys.D);
814
- dispatchKeyEvent(Keys.X);
815
- dispatchKeyEvent(Keys.Y);
816
- assert.strictEqual(mockCallback.calledCount, 0);
979
+ assert.strictEqual(comboComplete.calledCount, 1, "Combination observable should have completed");
980
+ assert.strictEqual(seqComplete.calledCount, 1, "Sequence observable should have completed");
817
981
  });
818
982
  });
819
983
  });