ngx-keys 1.1.0 → 1.3.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.
@@ -1,6 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, inject, PLATFORM_ID, DestroyRef, Injectable } from '@angular/core';
3
- import { isPlatformBrowser } from '@angular/common';
2
+ import { InjectionToken, DestroyRef, inject, DOCUMENT, signal, computed, afterNextRender, Injectable, ElementRef, output, HostBinding, Input, Directive } from '@angular/core';
4
3
  import { Observable, take } from 'rxjs';
5
4
 
6
5
  /**
@@ -12,6 +11,9 @@ const KeyboardShortcutsErrors = {
12
11
  SHORTCUT_ALREADY_REGISTERED: (id) => `Shortcut "${id}" already registered`,
13
12
  GROUP_ALREADY_REGISTERED: (id) => `Group "${id}" already registered`,
14
13
  KEY_CONFLICT: (conflictId) => `Key conflict with "${conflictId}"`,
14
+ ACTIVE_KEY_CONFLICT: (conflictId) => `Key conflict with active shortcut "${conflictId}"`,
15
+ ACTIVATION_KEY_CONFLICT: (shortcutId, conflictIds) => `Cannot activate "${shortcutId}": would conflict with active shortcuts: ${conflictIds.join(', ')}`,
16
+ GROUP_ACTIVATION_KEY_CONFLICT: (groupId, conflictIds) => `Cannot activate group "${groupId}": would conflict with active shortcuts: ${conflictIds.join(', ')}`,
15
17
  SHORTCUT_IDS_ALREADY_REGISTERED: (ids) => `Shortcut IDs already registered: ${ids.join(', ')}`,
16
18
  DUPLICATE_SHORTCUTS_IN_GROUP: (ids) => `Duplicate shortcuts in group: ${ids.join(', ')}`,
17
19
  KEY_CONFLICTS_IN_GROUP: (conflicts) => `Key conflicts: ${conflicts.join(', ')}`,
@@ -53,6 +55,15 @@ class KeyboardShortcutsErrorFactory {
53
55
  static keyConflict(conflictId) {
54
56
  return new KeyboardShortcutError('KEY_CONFLICT', KeyboardShortcutsErrors.KEY_CONFLICT(conflictId), { conflictId });
55
57
  }
58
+ static activeKeyConflict(conflictId) {
59
+ return new KeyboardShortcutError('ACTIVE_KEY_CONFLICT', KeyboardShortcutsErrors.ACTIVE_KEY_CONFLICT(conflictId), { conflictId });
60
+ }
61
+ static activationKeyConflict(shortcutId, conflictIds) {
62
+ return new KeyboardShortcutError('ACTIVATION_KEY_CONFLICT', KeyboardShortcutsErrors.ACTIVATION_KEY_CONFLICT(shortcutId, conflictIds), { shortcutId, conflictIds });
63
+ }
64
+ static groupActivationKeyConflict(groupId, conflictIds) {
65
+ return new KeyboardShortcutError('GROUP_ACTIVATION_KEY_CONFLICT', KeyboardShortcutsErrors.GROUP_ACTIVATION_KEY_CONFLICT(groupId, conflictIds), { groupId, conflictIds });
66
+ }
56
67
  static shortcutIdsAlreadyRegistered(ids) {
57
68
  return new KeyboardShortcutError('SHORTCUT_IDS_ALREADY_REGISTERED', KeyboardShortcutsErrors.SHORTCUT_IDS_ALREADY_REGISTERED(ids), { duplicateIds: ids });
58
69
  }
@@ -88,19 +99,256 @@ class KeyboardShortcutsErrorFactory {
88
99
  }
89
100
  }
90
101
 
102
+ /**
103
+ * Injection token for providing KeyboardShortcuts configuration.
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * providers: [
108
+ * provideKeyboardShortcutsConfig({ sequenceTimeoutMs: Infinity })
109
+ * ]
110
+ * ```
111
+ */
112
+ const KEYBOARD_SHORTCUTS_CONFIG = new InjectionToken('KEYBOARD_SHORTCUTS_CONFIG', {
113
+ providedIn: 'root',
114
+ factory: () => ({})
115
+ });
116
+ /**
117
+ * Provides KeyboardShortcuts configuration using Angular's modern provider pattern.
118
+ *
119
+ * @param config - Configuration options for KeyboardShortcuts service
120
+ * @returns Provider array for use in Angular dependency injection
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * bootstrapApplication(AppComponent, {
125
+ * providers: [
126
+ * provideKeyboardShortcutsConfig({ sequenceTimeoutMs: Infinity })
127
+ * ]
128
+ * });
129
+ * ```
130
+ */
131
+ function provideKeyboardShortcutsConfig(config) {
132
+ return [{ provide: KEYBOARD_SHORTCUTS_CONFIG, useValue: config }];
133
+ }
134
+ /**
135
+ * Initial version number for state change detection.
136
+ * Used internally to track state updates efficiently.
137
+ * @internal
138
+ */
139
+ const INITIAL_STATE_VERSION = 0;
140
+ /**
141
+ * Increment value for state version updates.
142
+ * @internal
143
+ */
144
+ const STATE_VERSION_INCREMENT = 1;
145
+ /**
146
+ * Index of the first element in an array.
147
+ * Used for readability when checking array structure.
148
+ * @internal
149
+ */
150
+ const FIRST_INDEX = 0;
151
+ /**
152
+ * Index representing the first step in a multi-step sequence.
153
+ * @internal
154
+ */
155
+ const FIRST_STEP_INDEX = 0;
156
+ /**
157
+ * Index representing the second step in a multi-step sequence (after first step completes).
158
+ * @internal
159
+ */
160
+ const SECOND_STEP_INDEX = 1;
161
+ /**
162
+ * Threshold for determining if a shortcut is a single-step sequence.
163
+ * @internal
164
+ */
165
+ const SINGLE_STEP_LENGTH = 1;
166
+ /**
167
+ * Minimum count indicating that at least one item exists.
168
+ * @internal
169
+ */
170
+ const MIN_COUNT_ONE = 1;
171
+ /**
172
+ * Minimum length for a valid key string.
173
+ * @internal
174
+ */
175
+ const MIN_KEY_LENGTH = 0;
176
+
177
+ /**
178
+ * Utility class for keyboard shortcut key matching and normalization.
179
+ * Provides methods for comparing key combinations and normalizing key input.
180
+ */
181
+ class KeyMatcher {
182
+ static MODIFIER_KEYS = new Set(['ctrl', 'control', 'alt', 'shift', 'meta']);
183
+ /**
184
+ * Normalize a key string to lowercase for consistent comparison.
185
+ *
186
+ * @param key - The key string to normalize
187
+ * @returns The normalized (lowercase) key string
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * KeyMatcher.normalizeKey('Ctrl'); // returns 'ctrl'
192
+ * KeyMatcher.normalizeKey('A'); // returns 'a'
193
+ * ```
194
+ */
195
+ static normalizeKey(key) {
196
+ return key.toLowerCase();
197
+ }
198
+ /**
199
+ * Check if a key is a modifier key (ctrl, alt, shift, meta).
200
+ *
201
+ * @param key - The key to check
202
+ * @returns true if the key is a modifier key
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * KeyMatcher.isModifierKey('ctrl'); // returns true
207
+ * KeyMatcher.isModifierKey('a'); // returns false
208
+ * ```
209
+ */
210
+ static isModifierKey(key) {
211
+ return this.MODIFIER_KEYS.has(key.toLowerCase());
212
+ }
213
+ /**
214
+ * Compare two key combinations for equality.
215
+ * Supports both Set<string> and string[] as input.
216
+ * Keys are normalized (lowercased) before comparison.
217
+ *
218
+ * @param a - First key combination (Set or array)
219
+ * @param b - Second key combination (array)
220
+ * @returns true if both combinations contain the same keys
221
+ *
222
+ * @example
223
+ * ```typescript
224
+ * KeyMatcher.keysMatch(['ctrl', 's'], ['ctrl', 's']); // returns true
225
+ * KeyMatcher.keysMatch(['ctrl', 's'], ['Ctrl', 'S']); // returns true (normalized)
226
+ * KeyMatcher.keysMatch(['ctrl', 's'], ['alt', 's']); // returns false
227
+ * ```
228
+ */
229
+ static keysMatch(a, b) {
230
+ const setA = a instanceof Set ? a : this.normalizeKeysToSet(a);
231
+ const setB = this.normalizeKeysToSet(b);
232
+ if (setA.size !== setB.size) {
233
+ return false;
234
+ }
235
+ for (const key of setA) {
236
+ if (!setB.has(key)) {
237
+ return false;
238
+ }
239
+ }
240
+ return true;
241
+ }
242
+ /**
243
+ * Compare two multi-step sequences for equality.
244
+ * Each sequence is an array of steps, where each step is an array of keys.
245
+ *
246
+ * @param a - First multi-step sequence
247
+ * @param b - Second multi-step sequence
248
+ * @returns true if both sequences are identical
249
+ *
250
+ * @example
251
+ * ```typescript
252
+ * const seq1 = [['ctrl', 'k'], ['s']];
253
+ * const seq2 = [['ctrl', 'k'], ['s']];
254
+ * KeyMatcher.stepsMatch(seq1, seq2); // returns true
255
+ *
256
+ * const seq3 = [['ctrl', 'k'], ['t']];
257
+ * KeyMatcher.stepsMatch(seq1, seq3); // returns false (different final step)
258
+ * ```
259
+ */
260
+ static stepsMatch(a, b) {
261
+ if (a.length !== b.length) {
262
+ return false;
263
+ }
264
+ for (let i = 0; i < a.length; i++) {
265
+ if (!this.keysMatch(a[i], b[i])) {
266
+ return false;
267
+ }
268
+ }
269
+ return true;
270
+ }
271
+ /**
272
+ * Normalize an array of keys to a Set of lowercase keys.
273
+ *
274
+ * @param keys - Array of keys to normalize
275
+ * @returns Set of normalized (lowercase) keys
276
+ *
277
+ * @internal
278
+ */
279
+ static normalizeKeysToSet(keys) {
280
+ return new Set(keys.map(k => k.toLowerCase()));
281
+ }
282
+ /**
283
+ * Get the set of modifier key names.
284
+ * Useful for filtering or checking if a key is a modifier.
285
+ *
286
+ * @returns ReadonlySet of modifier key names
287
+ *
288
+ * @example
289
+ * ```typescript
290
+ * const modifiers = KeyMatcher.getModifierKeys();
291
+ * console.log(modifiers.has('ctrl')); // true
292
+ * console.log(modifiers.has('a')); // false
293
+ * ```
294
+ */
295
+ static getModifierKeys() {
296
+ return this.MODIFIER_KEYS;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Type guard to detect KeyboardShortcutGroupOptions at runtime.
302
+ * Centralising this logic keeps registerGroup simpler and less fragile.
303
+ */
304
+ function isGroupOptions(param) {
305
+ if (!param || typeof param !== 'object')
306
+ return false;
307
+ // Narrow to object for property checks
308
+ const obj = param;
309
+ return ('filter' in obj) || ('activeUntil' in obj);
310
+ }
311
+ /**
312
+ * Detect real DestroyRef instances or duck-typed objects exposing onDestroy(fn).
313
+ * Returns true for either an actual DestroyRef or an object with an onDestroy method.
314
+ */
315
+ function isDestroyRefLike(obj) {
316
+ if (!obj || typeof obj !== 'object')
317
+ return false;
318
+ try {
319
+ // Prefer instanceof when available (real DestroyRef)
320
+ if (obj instanceof DestroyRef)
321
+ return true;
322
+ }
323
+ catch {
324
+ // instanceof may throw if DestroyRef is not constructable in certain runtimes/tests
325
+ }
326
+ const o = obj;
327
+ return typeof o['onDestroy'] === 'function';
328
+ }
91
329
  class KeyboardShortcuts {
330
+ document = inject(DOCUMENT);
331
+ window = this.document.defaultView;
332
+ config = inject(KEYBOARD_SHORTCUTS_CONFIG);
92
333
  shortcuts = new Map();
93
334
  groups = new Map();
94
335
  activeShortcuts = new Set();
95
336
  activeGroups = new Set();
96
337
  currentlyDownKeys = new Set();
338
+ // O(1) lookup from shortcutId to its groupId to avoid scanning all groups per event
339
+ shortcutToGroup = new Map();
340
+ /**
341
+ * Named global filters that apply to all shortcuts.
342
+ * All global filters must return `true` for a shortcut to be processed.
343
+ */
344
+ globalFilters = new Map();
97
345
  // Single consolidated state signal - reduces memory overhead
98
346
  state = signal({
99
347
  shortcuts: new Map(),
100
348
  groups: new Map(),
101
349
  activeShortcuts: new Set(),
102
350
  activeGroups: new Set(),
103
- version: 0 // for change detection optimization
351
+ version: INITIAL_STATE_VERSION // for change detection optimization
104
352
  }, ...(ngDevMode ? [{ debugName: "state" }] : []));
105
353
  // Primary computed signal - consumers derive what they need from this
106
354
  shortcuts$ = computed(() => {
@@ -135,24 +383,12 @@ class KeyboardShortcuts {
135
383
  blurListener = this.handleWindowBlur.bind(this);
136
384
  visibilityListener = this.handleVisibilityChange.bind(this);
137
385
  isListening = false;
138
- isBrowser;
139
- /** Default timeout (ms) for completing a multi-step sequence */
140
- sequenceTimeout = 2000;
141
386
  /** Runtime state for multi-step sequences */
142
387
  pendingSequence = null;
143
388
  constructor() {
144
- // Use try-catch to handle injection context for better testability
145
- try {
146
- const platformId = inject(PLATFORM_ID);
147
- this.isBrowser = isPlatformBrowser(platformId);
148
- }
149
- catch {
150
- // Fallback for testing - assume browser environment
151
- this.isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
152
- }
153
- if (this.isBrowser) {
389
+ afterNextRender(() => {
154
390
  this.startListening();
155
- }
391
+ });
156
392
  }
157
393
  ngOnDestroy() {
158
394
  this.stopListening();
@@ -166,7 +402,7 @@ class KeyboardShortcuts {
166
402
  groups: new Map(this.groups),
167
403
  activeShortcuts: new Set(this.activeShortcuts),
168
404
  activeGroups: new Set(this.activeGroups),
169
- version: current.version + 1
405
+ version: current.version + STATE_VERSION_INCREMENT
170
406
  }));
171
407
  }
172
408
  /**
@@ -213,28 +449,32 @@ class KeyboardShortcuts {
213
449
  return '';
214
450
  // If the first element is an array, assume steps is string[][]
215
451
  const normalized = this.normalizeToSteps(steps);
216
- if (normalized.length === 0)
452
+ if (normalized.length === MIN_KEY_LENGTH)
217
453
  return '';
218
- if (normalized.length === 1)
219
- return this.formatKeysForDisplay(normalized[0], isMac);
454
+ if (normalized.length === SINGLE_STEP_LENGTH)
455
+ return this.formatKeysForDisplay(normalized[FIRST_INDEX], isMac);
220
456
  return normalized.map(step => this.formatKeysForDisplay(step, isMac)).join(', ');
221
457
  }
222
458
  normalizeToSteps(input) {
223
459
  if (!input)
224
460
  return [];
225
461
  // If first element is an array, assume already KeyStep[]
226
- if (Array.isArray(input[0])) {
462
+ if (Array.isArray(input[FIRST_INDEX])) {
227
463
  return input;
228
464
  }
229
465
  // Single step array
230
466
  return [input];
231
467
  }
232
468
  /**
233
- * Check if a key combination is already registered
234
- * @returns The ID of the conflicting shortcut, or null if no conflict
469
+ * Check if a key combination is already registered by an active shortcut
470
+ * @returns The ID of the conflicting active shortcut, or null if no active conflict
235
471
  */
236
- findConflict(newShortcut) {
472
+ findActiveConflict(newShortcut) {
237
473
  for (const existing of this.shortcuts.values()) {
474
+ // Only check conflicts with active shortcuts
475
+ if (!this.activeShortcuts.has(existing.id)) {
476
+ continue;
477
+ }
238
478
  // Compare single-step shapes if provided
239
479
  if (newShortcut.keys && existing.keys && this.keysMatch(newShortcut.keys, existing.keys)) {
240
480
  return existing.id;
@@ -252,48 +492,79 @@ class KeyboardShortcuts {
252
492
  }
253
493
  return null;
254
494
  }
495
+ /**
496
+ * Check if activating a shortcut would create key conflicts with other active shortcuts
497
+ * @returns Array of conflicting shortcut IDs that would be created by activation
498
+ */
499
+ findActivationConflicts(shortcutId) {
500
+ const shortcut = this.shortcuts.get(shortcutId);
501
+ if (!shortcut)
502
+ return [];
503
+ const conflicts = [];
504
+ for (const existing of this.shortcuts.values()) {
505
+ // Skip self and inactive shortcuts
506
+ if (existing.id === shortcutId || !this.activeShortcuts.has(existing.id)) {
507
+ continue;
508
+ }
509
+ // Check for key conflicts
510
+ if ((shortcut.keys && existing.keys && this.keysMatch(shortcut.keys, existing.keys)) ||
511
+ (shortcut.macKeys && existing.macKeys && this.keysMatch(shortcut.macKeys, existing.macKeys)) ||
512
+ (shortcut.steps && existing.steps && this.stepsMatch(shortcut.steps, existing.steps)) ||
513
+ (shortcut.macSteps && existing.macSteps && this.stepsMatch(shortcut.macSteps, existing.macSteps))) {
514
+ conflicts.push(existing.id);
515
+ }
516
+ }
517
+ return conflicts;
518
+ }
255
519
  /**
256
520
  * Register a single keyboard shortcut
257
- * @throws KeyboardShortcutError if shortcut ID is already registered or key combination is in use
521
+ * @throws KeyboardShortcutError if shortcut ID is already registered or if the shortcut would conflict with currently active shortcuts
258
522
  */
259
523
  register(shortcut) {
260
524
  if (this.shortcuts.has(shortcut.id)) {
261
525
  throw KeyboardShortcutsErrorFactory.shortcutAlreadyRegistered(shortcut.id);
262
526
  }
263
- const conflictId = this.findConflict(shortcut);
527
+ // Check for conflicts only with currently active shortcuts
528
+ const conflictId = this.findActiveConflict(shortcut);
264
529
  if (conflictId) {
265
- throw KeyboardShortcutsErrorFactory.keyConflict(conflictId);
530
+ throw KeyboardShortcutsErrorFactory.activeKeyConflict(conflictId);
266
531
  }
267
532
  this.shortcuts.set(shortcut.id, shortcut);
268
533
  this.activeShortcuts.add(shortcut.id);
269
534
  this.updateState();
270
535
  this.setupActiveUntil(shortcut.activeUntil, this.unregister.bind(this, shortcut.id));
271
536
  }
272
- /**
273
- * Register multiple keyboard shortcuts as a group
274
- * @throws KeyboardShortcutError if group ID is already registered or if any shortcut ID or key combination conflicts
275
- */
276
- registerGroup(groupId, shortcuts, activeUntil) {
537
+ registerGroup(groupId, shortcuts, optionsOrActiveUntil) {
538
+ // Parse parameters - support both old (activeUntil) and new (options) formats
539
+ let options;
540
+ if (isGroupOptions(optionsOrActiveUntil)) {
541
+ // New format with options object
542
+ options = optionsOrActiveUntil;
543
+ }
544
+ else {
545
+ // Old format with just activeUntil parameter
546
+ options = { activeUntil: optionsOrActiveUntil };
547
+ }
277
548
  // Check if group ID already exists
278
549
  if (this.groups.has(groupId)) {
279
550
  throw KeyboardShortcutsErrorFactory.groupAlreadyRegistered(groupId);
280
551
  }
281
- // Check for duplicate shortcut IDs and key combination conflicts
552
+ // Check for duplicate shortcut IDs and key combination conflicts with active shortcuts
282
553
  const duplicateIds = [];
283
554
  const keyConflicts = [];
284
555
  shortcuts.forEach(shortcut => {
285
556
  if (this.shortcuts.has(shortcut.id)) {
286
557
  duplicateIds.push(shortcut.id);
287
558
  }
288
- const conflictId = this.findConflict(shortcut);
559
+ const conflictId = this.findActiveConflict(shortcut);
289
560
  if (conflictId) {
290
- keyConflicts.push(`"${shortcut.id}" conflicts with "${conflictId}"`);
561
+ keyConflicts.push(`"${shortcut.id}" conflicts with active shortcut "${conflictId}"`);
291
562
  }
292
563
  });
293
- if (duplicateIds.length > 0) {
564
+ if (duplicateIds.length > MIN_KEY_LENGTH) {
294
565
  throw KeyboardShortcutsErrorFactory.shortcutIdsAlreadyRegistered(duplicateIds);
295
566
  }
296
- if (keyConflicts.length > 0) {
567
+ if (keyConflicts.length > MIN_KEY_LENGTH) {
297
568
  throw KeyboardShortcutsErrorFactory.keyConflictsInGroup(keyConflicts);
298
569
  }
299
570
  // Validate that all shortcuts have unique IDs within the group
@@ -307,7 +578,7 @@ class KeyboardShortcuts {
307
578
  groupIds.add(shortcut.id);
308
579
  }
309
580
  });
310
- if (duplicatesInGroup.length > 0) {
581
+ if (duplicatesInGroup.length > MIN_KEY_LENGTH) {
311
582
  throw KeyboardShortcutsErrorFactory.duplicateShortcutsInGroup(duplicatesInGroup);
312
583
  }
313
584
  // Use batch update to reduce signal updates
@@ -315,7 +586,8 @@ class KeyboardShortcuts {
315
586
  const group = {
316
587
  id: groupId,
317
588
  shortcuts,
318
- active: true
589
+ active: true,
590
+ filter: options.filter
319
591
  };
320
592
  this.groups.set(groupId, group);
321
593
  this.activeGroups.add(groupId);
@@ -323,9 +595,10 @@ class KeyboardShortcuts {
323
595
  shortcuts.forEach(shortcut => {
324
596
  this.shortcuts.set(shortcut.id, shortcut);
325
597
  this.activeShortcuts.add(shortcut.id);
598
+ this.shortcutToGroup.set(shortcut.id, groupId);
326
599
  });
327
600
  });
328
- this.setupActiveUntil(activeUntil, this.unregisterGroup.bind(this, groupId));
601
+ this.setupActiveUntil(options.activeUntil, this.unregisterGroup.bind(this, groupId));
329
602
  }
330
603
  /**
331
604
  * Unregister a single keyboard shortcut
@@ -337,6 +610,7 @@ class KeyboardShortcuts {
337
610
  }
338
611
  this.shortcuts.delete(shortcutId);
339
612
  this.activeShortcuts.delete(shortcutId);
613
+ this.shortcutToGroup.delete(shortcutId);
340
614
  this.updateState();
341
615
  }
342
616
  /**
@@ -352,6 +626,7 @@ class KeyboardShortcuts {
352
626
  group.shortcuts.forEach(shortcut => {
353
627
  this.shortcuts.delete(shortcut.id);
354
628
  this.activeShortcuts.delete(shortcut.id);
629
+ this.shortcutToGroup.delete(shortcut.id);
355
630
  });
356
631
  this.groups.delete(groupId);
357
632
  this.activeGroups.delete(groupId);
@@ -359,12 +634,17 @@ class KeyboardShortcuts {
359
634
  }
360
635
  /**
361
636
  * Activate a single keyboard shortcut
362
- * @throws KeyboardShortcutError if shortcut ID doesn't exist
637
+ * @throws KeyboardShortcutError if shortcut ID doesn't exist or if activation would create key conflicts
363
638
  */
364
639
  activate(shortcutId) {
365
640
  if (!this.shortcuts.has(shortcutId)) {
366
641
  throw KeyboardShortcutsErrorFactory.cannotActivateShortcut(shortcutId);
367
642
  }
643
+ // Check for conflicts that would be created by activation
644
+ const conflicts = this.findActivationConflicts(shortcutId);
645
+ if (conflicts.length > MIN_KEY_LENGTH) {
646
+ throw KeyboardShortcutsErrorFactory.activationKeyConflict(shortcutId, conflicts);
647
+ }
368
648
  this.activeShortcuts.add(shortcutId);
369
649
  this.updateState();
370
650
  }
@@ -381,13 +661,22 @@ class KeyboardShortcuts {
381
661
  }
382
662
  /**
383
663
  * Activate a group of keyboard shortcuts
384
- * @throws KeyboardShortcutError if group ID doesn't exist
664
+ * @throws KeyboardShortcutError if group ID doesn't exist or if activation would create key conflicts
385
665
  */
386
666
  activateGroup(groupId) {
387
667
  const group = this.groups.get(groupId);
388
668
  if (!group) {
389
669
  throw KeyboardShortcutsErrorFactory.cannotActivateGroup(groupId);
390
670
  }
671
+ // Check for conflicts that would be created by activating all shortcuts in the group
672
+ const allConflicts = [];
673
+ group.shortcuts.forEach(shortcut => {
674
+ const conflicts = this.findActivationConflicts(shortcut.id);
675
+ allConflicts.push(...conflicts);
676
+ });
677
+ if (allConflicts.length > MIN_KEY_LENGTH) {
678
+ throw KeyboardShortcutsErrorFactory.groupActivationKeyConflict(groupId, allConflicts);
679
+ }
391
680
  this.batchUpdate(() => {
392
681
  group.active = true;
393
682
  this.activeGroups.add(groupId);
@@ -449,45 +738,306 @@ class KeyboardShortcuts {
449
738
  getGroups() {
450
739
  return this.groups;
451
740
  }
741
+ /**
742
+ * Add a named global filter that applies to all shortcuts.
743
+ * All global filters must return `true` for a shortcut to execute.
744
+ *
745
+ * @param name - Unique name for this filter
746
+ * @param filter - Function that returns `true` to allow shortcuts, `false` to block them
747
+ *
748
+ * @example
749
+ * ```typescript
750
+ * // Block shortcuts in form elements
751
+ * keyboardService.addFilter('forms', (event) => {
752
+ * const target = event.target as HTMLElement;
753
+ * const tagName = target?.tagName?.toLowerCase();
754
+ * return !['input', 'textarea', 'select'].includes(tagName) && !target?.isContentEditable;
755
+ * });
756
+ *
757
+ * // Block shortcuts when modal is open
758
+ * keyboardService.addFilter('modal', (event) => {
759
+ * return !document.querySelector('.modal.active');
760
+ * });
761
+ * ```
762
+ */
763
+ addFilter(name, filter) {
764
+ this.globalFilters.set(name, filter);
765
+ }
766
+ /**
767
+ * Remove a named global filter.
768
+ *
769
+ * @param name - Name of the filter to remove
770
+ * @returns `true` if filter was removed, `false` if it didn't exist
771
+ */
772
+ removeFilter(name) {
773
+ return this.globalFilters.delete(name);
774
+ }
775
+ /**
776
+ * Get a named global filter.
777
+ *
778
+ * @param name - Name of the filter to retrieve
779
+ * @returns The filter function, or undefined if not found
780
+ */
781
+ getFilter(name) {
782
+ return this.globalFilters.get(name);
783
+ }
784
+ /**
785
+ * Get all global filter names.
786
+ *
787
+ * @returns Array of filter names
788
+ */
789
+ getFilterNames() {
790
+ return Array.from(this.globalFilters.keys());
791
+ }
792
+ /**
793
+ * Remove all global filters.
794
+ */
795
+ clearFilters() {
796
+ this.globalFilters.clear();
797
+ }
798
+ /**
799
+ * Check if a named filter exists.
800
+ *
801
+ * @param name - Name of the filter to check
802
+ * @returns `true` if filter exists, `false` otherwise
803
+ */
804
+ hasFilter(name) {
805
+ return this.globalFilters.has(name);
806
+ }
807
+ /**
808
+ * Remove the filter from a specific group
809
+ * @param groupId - The group ID
810
+ * @throws KeyboardShortcutError if group doesn't exist
811
+ */
812
+ removeGroupFilter(groupId) {
813
+ const group = this.groups.get(groupId);
814
+ if (!group) {
815
+ throw KeyboardShortcutsErrorFactory.cannotDeactivateGroup(groupId);
816
+ }
817
+ if (!group.filter) {
818
+ // No filter to remove, silently succeed
819
+ return;
820
+ }
821
+ this.groups.set(groupId, {
822
+ ...group,
823
+ filter: undefined
824
+ });
825
+ this.updateState();
826
+ }
827
+ /**
828
+ * Remove the filter from a specific shortcut
829
+ * @param shortcutId - The shortcut ID
830
+ * @throws KeyboardShortcutError if shortcut doesn't exist
831
+ */
832
+ removeShortcutFilter(shortcutId) {
833
+ const shortcut = this.shortcuts.get(shortcutId);
834
+ if (!shortcut) {
835
+ throw KeyboardShortcutsErrorFactory.cannotDeactivateShortcut(shortcutId);
836
+ }
837
+ if (!shortcut.filter) {
838
+ // No filter to remove, silently succeed
839
+ return;
840
+ }
841
+ this.shortcuts.set(shortcutId, {
842
+ ...shortcut,
843
+ filter: undefined
844
+ });
845
+ this.updateState();
846
+ }
847
+ /**
848
+ * Remove all filters from all groups
849
+ */
850
+ clearAllGroupFilters() {
851
+ this.batchUpdate(() => {
852
+ this.groups.forEach((group, id) => {
853
+ if (group.filter) {
854
+ this.removeGroupFilter(id);
855
+ }
856
+ });
857
+ });
858
+ }
859
+ /**
860
+ * Remove all filters from all shortcuts
861
+ */
862
+ clearAllShortcutFilters() {
863
+ this.batchUpdate(() => {
864
+ this.shortcuts.forEach((shortcut, id) => {
865
+ if (shortcut.filter) {
866
+ this.removeShortcutFilter(id);
867
+ }
868
+ });
869
+ });
870
+ }
871
+ /**
872
+ * Check if a group has a filter
873
+ * @param groupId - The group ID
874
+ * @returns True if the group has a filter
875
+ */
876
+ hasGroupFilter(groupId) {
877
+ const group = this.groups.get(groupId);
878
+ return !!group?.filter;
879
+ }
880
+ /**
881
+ * Check if a shortcut has a filter
882
+ * @param shortcutId - The shortcut ID
883
+ * @returns True if the shortcut has a filter
884
+ */
885
+ hasShortcutFilter(shortcutId) {
886
+ const shortcut = this.shortcuts.get(shortcutId);
887
+ return !!shortcut?.filter;
888
+ }
889
+ /**
890
+ * Register multiple shortcuts in a single batch update
891
+ * @param shortcuts - Array of shortcuts to register
892
+ */
893
+ registerMany(shortcuts) {
894
+ this.batchUpdate(() => {
895
+ shortcuts.forEach(shortcut => this.register(shortcut));
896
+ });
897
+ }
898
+ /**
899
+ * Unregister multiple shortcuts in a single batch update
900
+ * @param ids - Array of shortcut IDs to unregister
901
+ */
902
+ unregisterMany(ids) {
903
+ this.batchUpdate(() => {
904
+ ids.forEach(id => this.unregister(id));
905
+ });
906
+ }
907
+ /**
908
+ * Unregister multiple groups in a single batch update
909
+ * @param ids - Array of group IDs to unregister
910
+ */
911
+ unregisterGroups(ids) {
912
+ this.batchUpdate(() => {
913
+ ids.forEach(id => this.unregisterGroup(id));
914
+ });
915
+ }
916
+ /**
917
+ * Clear all shortcuts and groups (nuclear reset)
918
+ */
919
+ clearAll() {
920
+ this.batchUpdate(() => {
921
+ this.shortcuts.clear();
922
+ this.groups.clear();
923
+ this.activeShortcuts.clear();
924
+ this.activeGroups.clear();
925
+ this.shortcutToGroup.clear();
926
+ this.globalFilters.clear();
927
+ this.clearCurrentlyDownKeys();
928
+ this.clearPendingSequence();
929
+ });
930
+ }
931
+ /**
932
+ * Get all shortcuts belonging to a specific group
933
+ * @param groupId - The group ID
934
+ * @returns Array of shortcuts in the group
935
+ */
936
+ getGroupShortcuts(groupId) {
937
+ const group = this.groups.get(groupId);
938
+ if (!group)
939
+ return [];
940
+ return [...group.shortcuts];
941
+ }
942
+ /**
943
+ * Normalize a key to lowercase for consistent comparison
944
+ */
945
+ normalizeKey(key) {
946
+ return key.toLowerCase();
947
+ }
948
+ /**
949
+ * Find the group that contains a specific shortcut.
950
+ *
951
+ * @param shortcutId - The ID of the shortcut to find
952
+ * @returns The group containing the shortcut, or undefined if not found in any group
953
+ */
954
+ findGroupForShortcut(shortcutId) {
955
+ const groupId = this.shortcutToGroup.get(shortcutId);
956
+ return groupId ? this.groups.get(groupId) : undefined;
957
+ }
958
+ /**
959
+ * Check if a keyboard event should be processed based on global, group, and per-shortcut filters.
960
+ * Filter hierarchy: Global filters → Group filter → Individual shortcut filter
961
+ *
962
+ * @param event - The keyboard event to evaluate
963
+ * @param shortcut - The shortcut being evaluated (for per-shortcut filter)
964
+ * @returns `true` if event should be processed, `false` if it should be ignored
965
+ */
966
+ shouldProcessEvent(event, shortcut) {
967
+ // First, check all global filters - ALL must return true
968
+ // Note: handleKeydown pre-checks these once per event for early exit,
969
+ // but we keep this for direct calls and completeness.
970
+ for (const globalFilter of this.globalFilters.values()) {
971
+ if (!globalFilter(event)) {
972
+ return false;
973
+ }
974
+ }
975
+ // Then check group filter if shortcut belongs to a group
976
+ const group = this.findGroupForShortcut(shortcut.id);
977
+ if (group?.filter && !group.filter(event)) {
978
+ return false;
979
+ }
980
+ // Finally check per-shortcut filter if it exists
981
+ if (shortcut.filter && !shortcut.filter(event)) {
982
+ return false;
983
+ }
984
+ return true;
985
+ }
452
986
  startListening() {
453
- if (!this.isBrowser || this.isListening) {
987
+ if (this.isListening) {
454
988
  return;
455
989
  }
456
990
  // Listen to both keydown and keyup so we can maintain a Set of currently
457
991
  // pressed physical keys. We avoid passive:true because we may call
458
992
  // preventDefault() when matching shortcuts.
459
- document.addEventListener('keydown', this.keydownListener, { passive: false });
460
- document.addEventListener('keyup', this.keyupListener, { passive: false });
993
+ this.document.addEventListener('keydown', this.keydownListener, { passive: false });
994
+ this.document.addEventListener('keyup', this.keyupListener, { passive: false });
461
995
  // Listen for blur/visibility changes so we can clear the currently-down keys
462
996
  // and avoid stale state when the browser or tab loses focus.
463
- window.addEventListener('blur', this.blurListener);
464
- document.addEventListener('visibilitychange', this.visibilityListener);
997
+ this.window.addEventListener('blur', this.blurListener);
998
+ this.document.addEventListener('visibilitychange', this.visibilityListener);
465
999
  this.isListening = true;
466
1000
  }
467
1001
  stopListening() {
468
- if (!this.isBrowser || !this.isListening) {
1002
+ if (!this.isListening) {
469
1003
  return;
470
1004
  }
471
- document.removeEventListener('keydown', this.keydownListener);
472
- document.removeEventListener('keyup', this.keyupListener);
473
- window.removeEventListener('blur', this.blurListener);
474
- document.removeEventListener('visibilitychange', this.visibilityListener);
1005
+ this.document.removeEventListener('keydown', this.keydownListener);
1006
+ this.document.removeEventListener('keyup', this.keyupListener);
1007
+ this.window.removeEventListener('blur', this.blurListener);
1008
+ this.document.removeEventListener('visibilitychange', this.visibilityListener);
475
1009
  this.isListening = false;
476
1010
  }
477
1011
  handleKeydown(event) {
478
1012
  // Update the currently down keys with this event's key
479
1013
  this.updateCurrentlyDownKeysOnKeydown(event);
480
- // Build the pressed keys set used for matching. Prefer the currentlyDownKeys
481
- // if it contains more than one non-modifier key; otherwise fall back to the
482
- // traditional per-event pressed keys calculation for compatibility.
483
- // Use a Set for matching to avoid allocations and sorting on every event
484
- const pressedKeys = this.buildPressedKeysForMatch(event);
1014
+ // Fast path: if any global filter blocks this event, bail out before
1015
+ // scanning all active shortcuts. This drastically reduces per-event work
1016
+ // when filters are commonly blocking (e.g., while typing in inputs).
1017
+ if (this.globalFilters.size > MIN_KEY_LENGTH) {
1018
+ for (const f of this.globalFilters.values()) {
1019
+ if (!f(event)) {
1020
+ // Also clear any pending multi-step sequence – entering a globally
1021
+ // filtered context should not allow sequences to continue.
1022
+ this.clearPendingSequence();
1023
+ return;
1024
+ }
1025
+ }
1026
+ }
485
1027
  const isMac = this.isMacPlatform();
1028
+ // Evaluate active group-level filters once per event and cache blocked groups
1029
+ const blockedGroups = this.precomputeBlockedGroups(event);
486
1030
  // If there is a pending multi-step sequence, try to advance it first
487
1031
  if (this.pendingSequence) {
488
1032
  const pending = this.pendingSequence;
489
1033
  const shortcut = this.shortcuts.get(pending.shortcutId);
490
1034
  if (shortcut) {
1035
+ // If the pending shortcut belongs to a blocked group, cancel sequence
1036
+ const g = this.findGroupForShortcut(shortcut.id);
1037
+ if (g && blockedGroups.has(g.id)) {
1038
+ this.clearPendingSequence();
1039
+ return;
1040
+ }
491
1041
  const steps = isMac
492
1042
  ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
493
1043
  : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
@@ -498,14 +1048,18 @@ class KeyboardShortcuts {
498
1048
  // from previous steps (if tests or callers don't emit keyup), which
499
1049
  // would prevent matching a simple single-key step like ['s'] after
500
1050
  // a prior ['k'] step. Use getPressedKeys(event) which reflects the
501
- // actual modifier/main-key state for this event.
1051
+ // actual modifier/main-key state for this event as a Set<string>.
502
1052
  const stepPressed = this.getPressedKeys(event);
503
1053
  if (expected && this.keysMatch(stepPressed, expected)) {
504
1054
  // Advance sequence
505
1055
  clearTimeout(pending.timerId);
506
- pending.stepIndex += 1;
1056
+ pending.stepIndex += STATE_VERSION_INCREMENT;
507
1057
  if (pending.stepIndex >= normalizedSteps.length) {
508
- // Completed
1058
+ // Completed - check filters before executing
1059
+ if (!this.shouldProcessEvent(event, shortcut)) {
1060
+ this.pendingSequence = null;
1061
+ return; // Skip execution due to filters
1062
+ }
509
1063
  event.preventDefault();
510
1064
  event.stopPropagation();
511
1065
  try {
@@ -517,8 +1071,10 @@ class KeyboardShortcuts {
517
1071
  this.pendingSequence = null;
518
1072
  return;
519
1073
  }
520
- // Reset timer for next step
521
- pending.timerId = setTimeout(() => { this.pendingSequence = null; }, this.sequenceTimeout);
1074
+ // Reset timer for next step (only if shortcut has timeout configured)
1075
+ if (shortcut.sequenceTimeout !== undefined) {
1076
+ pending.timerId = setTimeout(() => { this.pendingSequence = null; }, shortcut.sequenceTimeout);
1077
+ }
522
1078
  return;
523
1079
  }
524
1080
  else {
@@ -536,13 +1092,31 @@ class KeyboardShortcuts {
536
1092
  const shortcut = this.shortcuts.get(shortcutId);
537
1093
  if (!shortcut)
538
1094
  continue;
1095
+ // Skip expensive matching entirely when the shortcut's group is blocked
1096
+ const g = this.findGroupForShortcut(shortcut.id);
1097
+ if (g && blockedGroups.has(g.id)) {
1098
+ continue;
1099
+ }
539
1100
  const steps = isMac
540
1101
  ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
541
1102
  : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
542
1103
  const normalizedSteps = this.normalizeToSteps(steps);
543
- const firstStep = normalizedSteps[0];
544
- if (this.keysMatch(pressedKeys, firstStep)) {
545
- if (normalizedSteps.length === 1) {
1104
+ const firstStep = normalizedSteps[FIRST_INDEX];
1105
+ // Decide which pressed-keys representation to use for this shortcut's
1106
+ // expected step: if it requires multiple non-modifier keys, treat it as
1107
+ // a chord and use accumulated keys; otherwise use per-event keys to avoid
1108
+ // interference from previously pressed non-modifier keys.
1109
+ const nonModifierCount = firstStep.filter(k => !KeyMatcher.isModifierKey(k)).length;
1110
+ // Normalize pressed keys to a Set<string> for consistent typing
1111
+ const pressedForStep = nonModifierCount > MIN_COUNT_ONE
1112
+ ? this.buildPressedKeysForMatch(event)
1113
+ : this.getPressedKeys(event);
1114
+ if (this.keysMatch(pressedForStep, firstStep)) {
1115
+ // Check if this event should be processed based on filters
1116
+ if (!this.shouldProcessEvent(event, shortcut)) {
1117
+ continue; // Skip this shortcut due to filters
1118
+ }
1119
+ if (normalizedSteps.length === SINGLE_STEP_LENGTH) {
546
1120
  // single-step
547
1121
  event.preventDefault();
548
1122
  event.stopPropagation();
@@ -559,10 +1133,13 @@ class KeyboardShortcuts {
559
1133
  if (this.pendingSequence) {
560
1134
  this.clearPendingSequence();
561
1135
  }
1136
+ const timerId = shortcut.sequenceTimeout !== undefined
1137
+ ? setTimeout(() => { this.pendingSequence = null; }, shortcut.sequenceTimeout)
1138
+ : null;
562
1139
  this.pendingSequence = {
563
1140
  shortcutId: shortcut.id,
564
- stepIndex: 1,
565
- timerId: setTimeout(() => { this.pendingSequence = null; }, this.sequenceTimeout)
1141
+ stepIndex: SECOND_STEP_INDEX,
1142
+ timerId
566
1143
  };
567
1144
  event.preventDefault();
568
1145
  event.stopPropagation();
@@ -572,9 +1149,9 @@ class KeyboardShortcuts {
572
1149
  }
573
1150
  }
574
1151
  handleKeyup(event) {
575
- // Remove the key from currentlyDownKeys on keyup
1152
+ // Remove the key from currently DownKeys on keyup
576
1153
  const key = event.key ? event.key.toLowerCase() : '';
577
- if (key && !['control', 'alt', 'shift', 'meta'].includes(key)) {
1154
+ if (key && !KeyMatcher.isModifierKey(key)) {
578
1155
  this.currentlyDownKeys.delete(key);
579
1156
  }
580
1157
  }
@@ -592,13 +1169,18 @@ class KeyboardShortcuts {
592
1169
  this.clearPendingSequence();
593
1170
  }
594
1171
  handleVisibilityChange() {
595
- if (document.visibilityState === 'hidden') {
1172
+ if (this.document.visibilityState === 'hidden') {
596
1173
  // When the document becomes hidden, clear both pressed keys and any
597
1174
  // pending multi-step sequence. This prevents sequences from remaining
598
1175
  // active when the user switches tabs or minimizes the window.
599
1176
  this.clearCurrentlyDownKeys();
600
1177
  this.clearPendingSequence();
601
1178
  }
1179
+ else if (this.document.visibilityState === 'visible') {
1180
+ // When returning to visibility, clear keys to avoid stale state
1181
+ // from keys that may have been released while document was hidden
1182
+ this.clearCurrentlyDownKeys();
1183
+ }
602
1184
  }
603
1185
  /**
604
1186
  * Update the currentlyDownKeys set when keydown events happen.
@@ -608,7 +1190,7 @@ class KeyboardShortcuts {
608
1190
  updateCurrentlyDownKeysOnKeydown(event) {
609
1191
  const key = event.key ? event.key.toLowerCase() : '';
610
1192
  // Ignore modifier-only keydown entries
611
- if (['control', 'alt', 'shift', 'meta'].includes(key)) {
1193
+ if (KeyMatcher.isModifierKey(key)) {
612
1194
  return;
613
1195
  }
614
1196
  // Normalize some special cases similar to the demo component's recording logic
@@ -628,15 +1210,10 @@ class KeyboardShortcuts {
628
1210
  this.currentlyDownKeys.add('enter');
629
1211
  return;
630
1212
  }
631
- if (key && key.length > 0) {
1213
+ if (key && key.length > MIN_KEY_LENGTH) {
632
1214
  this.currentlyDownKeys.add(key);
633
1215
  }
634
1216
  }
635
- /**
636
- * Build the pressed keys array used for matching against registered shortcuts.
637
- * If multiple non-modifier keys are currently down, include them (chord support).
638
- * Otherwise fall back to single main-key detection from the event for compatibility.
639
- */
640
1217
  /**
641
1218
  * Build the pressed keys set used for matching against registered shortcuts.
642
1219
  * If multiple non-modifier keys are currently down, include them (chord support).
@@ -656,83 +1233,75 @@ class KeyboardShortcuts {
656
1233
  if (event.metaKey)
657
1234
  modifiers.add('meta');
658
1235
  // Collect non-modifier keys from currentlyDownKeys (excluding modifiers)
659
- const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !['control', 'alt', 'shift', 'meta'].includes(k));
1236
+ const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyMatcher.isModifierKey(k));
660
1237
  const result = new Set();
661
1238
  // Add modifiers first
662
1239
  modifiers.forEach(m => result.add(m));
663
- if (nonModifierKeys.length > 0) {
1240
+ if (nonModifierKeys.length > MIN_KEY_LENGTH) {
664
1241
  nonModifierKeys.forEach(k => result.add(k.toLowerCase()));
665
1242
  return result;
666
1243
  }
667
1244
  // Fallback: single main key from the event (existing behavior)
668
1245
  const key = event.key.toLowerCase();
669
- if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
1246
+ if (!KeyMatcher.isModifierKey(key)) {
670
1247
  result.add(key);
671
1248
  }
672
1249
  return result;
673
1250
  }
1251
+ /**
1252
+ * Return the pressed keys for this event as a Set<string>.
1253
+ * This is the canonical internal API used for matching.
1254
+ */
674
1255
  getPressedKeys(event) {
675
- const keys = [];
1256
+ const result = new Set();
676
1257
  if (event.ctrlKey)
677
- keys.push('ctrl');
1258
+ result.add('ctrl');
678
1259
  if (event.altKey)
679
- keys.push('alt');
1260
+ result.add('alt');
680
1261
  if (event.shiftKey)
681
- keys.push('shift');
1262
+ result.add('shift');
682
1263
  if (event.metaKey)
683
- keys.push('meta');
684
- // Add the main key (normalize to lowercase)
685
- const key = event.key.toLowerCase();
686
- if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
687
- keys.push(key);
1264
+ result.add('meta');
1265
+ // Add the main key (normalize to lowercase) if it's not a modifier
1266
+ const key = (event.key ?? '').toLowerCase();
1267
+ if (key && !KeyMatcher.isModifierKey(key)) {
1268
+ result.add(key);
688
1269
  }
689
- return keys;
1270
+ return result;
690
1271
  }
691
1272
  /**
692
1273
  * Compare pressed keys against a target key combination.
693
1274
  * Accepts either a Set<string> (preferred) or an array for backwards compatibility.
694
1275
  * Uses Set-based comparison: sizes must match and every element in target must exist in pressed.
695
1276
  */
1277
+ /**
1278
+ * @deprecated Use KeyMatcher.keysMatch() instead
1279
+ */
696
1280
  keysMatch(pressedKeys, targetKeys) {
697
- // Normalize targetKeys into a Set<string> (lowercased)
698
- const normalizedTarget = new Set(targetKeys.map(k => k.toLowerCase()));
699
- // Normalize pressedKeys into a Set<string> if it's an array
700
- const pressedSet = Array.isArray(pressedKeys)
701
- ? new Set(pressedKeys.map(k => k.toLowerCase()))
702
- : new Set(Array.from(pressedKeys).map(k => k.toLowerCase()));
703
- if (pressedSet.size !== normalizedTarget.size) {
704
- return false;
705
- }
706
- // Check if every element in normalizedTarget exists in pressedSet
707
- for (const key of normalizedTarget) {
708
- if (!pressedSet.has(key)) {
709
- return false;
710
- }
711
- }
712
- return true;
1281
+ return KeyMatcher.keysMatch(pressedKeys, targetKeys);
713
1282
  }
714
- /** Compare two multi-step sequences for equality */
1283
+ /**
1284
+ * Compare two multi-step sequences for equality
1285
+ * @deprecated Use KeyMatcher.stepsMatch() instead
1286
+ */
715
1287
  stepsMatch(a, b) {
716
- if (a.length !== b.length)
717
- return false;
718
- for (let i = 0; i < a.length; i++) {
719
- if (!this.keysMatch(a[i], b[i]))
720
- return false;
721
- }
722
- return true;
1288
+ return KeyMatcher.stepsMatch(a, b);
723
1289
  }
724
1290
  /** Safely clear any pending multi-step sequence */
725
1291
  clearPendingSequence() {
726
1292
  if (!this.pendingSequence)
727
1293
  return;
728
1294
  try {
729
- clearTimeout(this.pendingSequence.timerId);
1295
+ // Only clear timeout if one was set
1296
+ if (this.pendingSequence.timerId !== null) {
1297
+ clearTimeout(this.pendingSequence.timerId);
1298
+ }
730
1299
  }
731
1300
  catch { /* ignore */ }
732
1301
  this.pendingSequence = null;
733
1302
  }
734
1303
  isMacPlatform() {
735
- return this.isBrowser && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
1304
+ return /Mac|iPod|iPhone|iPad/.test(this.window.navigator.platform ?? '');
736
1305
  }
737
1306
  setupActiveUntil(activeUntil, unregister) {
738
1307
  if (!activeUntil) {
@@ -742,7 +1311,10 @@ class KeyboardShortcuts {
742
1311
  inject(DestroyRef).onDestroy(unregister);
743
1312
  return;
744
1313
  }
745
- if (activeUntil instanceof DestroyRef) {
1314
+ // Support both real DestroyRef instances and duck-typed objects (e.g.,
1315
+ // Jasmine spies) that expose an onDestroy(fn) method for backwards
1316
+ // compatibility with earlier APIs and tests.
1317
+ if (isDestroyRefLike(activeUntil)) {
746
1318
  activeUntil.onDestroy(unregister);
747
1319
  return;
748
1320
  }
@@ -751,6 +1323,21 @@ class KeyboardShortcuts {
751
1323
  return;
752
1324
  }
753
1325
  }
1326
+ /**
1327
+ * Evaluate group filters once per event and return the set of blocked group IDs.
1328
+ */
1329
+ precomputeBlockedGroups(event) {
1330
+ const blocked = new Set();
1331
+ if (this.activeGroups.size === MIN_KEY_LENGTH)
1332
+ return blocked;
1333
+ for (const groupId of this.activeGroups) {
1334
+ const group = this.groups.get(groupId);
1335
+ if (group && group.filter && !group.filter(event)) {
1336
+ blocked.add(groupId);
1337
+ }
1338
+ }
1339
+ return blocked;
1340
+ }
754
1341
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
755
1342
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcuts, providedIn: 'root' });
756
1343
  }
@@ -761,6 +1348,235 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
761
1348
  }]
762
1349
  }], ctorParameters: () => [] });
763
1350
 
1351
+ /**
1352
+ * Directive for registering keyboard shortcuts directly on elements in templates.
1353
+ *
1354
+ * This directive automatically:
1355
+ * - Registers the shortcut when the directive initializes
1356
+ * - Triggers the host element's click event (default) or executes a custom action
1357
+ * - Unregisters the shortcut when the component is destroyed
1358
+ *
1359
+ * @example
1360
+ * Basic usage with click trigger:
1361
+ * ```html
1362
+ * <button ngxKeys
1363
+ * keys="ctrl,s"
1364
+ * description="Save document"
1365
+ * (click)="save()">
1366
+ * Save
1367
+ * </button>
1368
+ * ```
1369
+ *
1370
+ * @example
1371
+ * With custom action:
1372
+ * ```html
1373
+ * <button ngxKeys
1374
+ * keys="ctrl,s"
1375
+ * description="Save document"
1376
+ * [action]="customSaveAction">
1377
+ * Save
1378
+ * </button>
1379
+ * ```
1380
+ *
1381
+ * @example
1382
+ * Multi-step shortcuts:
1383
+ * ```html
1384
+ * <button ngxKeys
1385
+ * [steps]="[['ctrl', 'k'], ['ctrl', 's']]"
1386
+ * description="Format document"
1387
+ * (click)="format()">
1388
+ * Format
1389
+ * </button>
1390
+ * ```
1391
+ *
1392
+ * @example
1393
+ * Mac-specific keys:
1394
+ * ```html
1395
+ * <button ngxKeys
1396
+ * keys="ctrl,s"
1397
+ * macKeys="cmd,s"
1398
+ * description="Save document"
1399
+ * (click)="save()">
1400
+ * Save
1401
+ * </button>
1402
+ * ```
1403
+ *
1404
+ * @example
1405
+ * On non-interactive elements:
1406
+ * ```html
1407
+ * <div ngxKeys
1408
+ * keys="?"
1409
+ * description="Show help"
1410
+ * [action]="showHelp">
1411
+ * Help content
1412
+ * </div>
1413
+ * ```
1414
+ */
1415
+ class KeyboardShortcutDirective {
1416
+ keyboardShortcuts = inject(KeyboardShortcuts);
1417
+ elementRef = inject((ElementRef));
1418
+ /**
1419
+ * Comma-separated string of keys for the shortcut (e.g., "ctrl,s" or "alt,shift,k")
1420
+ * Use either `keys` for single-step shortcuts or `steps` for multi-step shortcuts.
1421
+ */
1422
+ keys;
1423
+ /**
1424
+ * Comma-separated string of keys for Mac users (e.g., "cmd,s")
1425
+ * If not provided, `keys` will be used for all platforms.
1426
+ */
1427
+ macKeys;
1428
+ /**
1429
+ * Multi-step shortcut as an array of key arrays.
1430
+ * Each inner array represents one step in the sequence.
1431
+ * Example: [['ctrl', 'k'], ['ctrl', 's']] for Ctrl+K followed by Ctrl+S
1432
+ */
1433
+ steps;
1434
+ /**
1435
+ * Multi-step shortcut for Mac users.
1436
+ * Example: [['cmd', 'k'], ['cmd', 's']]
1437
+ */
1438
+ macSteps;
1439
+ /**
1440
+ * Description of what the shortcut does (displayed in help/documentation)
1441
+ */
1442
+ description;
1443
+ /**
1444
+ * Optional custom action to execute when the shortcut is triggered.
1445
+ * If not provided, the directive will trigger a click event on the host element.
1446
+ */
1447
+ action;
1448
+ /**
1449
+ * Optional custom ID for the shortcut. If not provided, a unique ID will be generated.
1450
+ * Useful for programmatically referencing the shortcut or for debugging.
1451
+ */
1452
+ shortcutId;
1453
+ /**
1454
+ * Event emitted when the keyboard shortcut is triggered.
1455
+ * This fires in addition to the action or click trigger.
1456
+ */
1457
+ triggered = output();
1458
+ /**
1459
+ * Adds a data attribute to the host element for styling or testing purposes
1460
+ */
1461
+ get dataAttribute() {
1462
+ return this.generatedId;
1463
+ }
1464
+ generatedId = '';
1465
+ isRegistered = false;
1466
+ ngOnInit() {
1467
+ // Generate unique ID if not provided
1468
+ this.generatedId = this.shortcutId || this.generateUniqueId();
1469
+ // Validate inputs
1470
+ this.validateInputs();
1471
+ // Parse keys from comma-separated strings
1472
+ const parsedKeys = this.keys ? this.parseKeys(this.keys) : undefined;
1473
+ const parsedMacKeys = this.macKeys ? this.parseKeys(this.macKeys) : undefined;
1474
+ // Define the action: custom action or default click behavior
1475
+ const shortcutAction = () => {
1476
+ if (this.action) {
1477
+ this.action();
1478
+ }
1479
+ else {
1480
+ // Trigger click on the host element
1481
+ this.elementRef.nativeElement.click();
1482
+ }
1483
+ // Emit the triggered event
1484
+ this.triggered.emit();
1485
+ };
1486
+ // Register the shortcut
1487
+ try {
1488
+ this.keyboardShortcuts.register({
1489
+ id: this.generatedId,
1490
+ keys: parsedKeys,
1491
+ macKeys: parsedMacKeys,
1492
+ steps: this.steps,
1493
+ macSteps: this.macSteps,
1494
+ action: shortcutAction,
1495
+ description: this.description,
1496
+ });
1497
+ this.isRegistered = true;
1498
+ }
1499
+ catch (error) {
1500
+ console.error(`[ngxKeys] Failed to register shortcut:`, error);
1501
+ throw error;
1502
+ }
1503
+ }
1504
+ ngOnDestroy() {
1505
+ // Automatically unregister the shortcut when the directive is destroyed
1506
+ if (this.isRegistered) {
1507
+ try {
1508
+ this.keyboardShortcuts.unregister(this.generatedId);
1509
+ }
1510
+ catch (error) {
1511
+ // Silently handle unregister errors (shortcut might have been manually removed)
1512
+ console.warn(`[ngxKeys] Failed to unregister shortcut ${this.generatedId}:`, error);
1513
+ }
1514
+ }
1515
+ }
1516
+ /**
1517
+ * Parse comma-separated key string into an array
1518
+ * Example: "ctrl,s" -> ["ctrl", "s"]
1519
+ */
1520
+ parseKeys(keysString) {
1521
+ return keysString
1522
+ .split(',')
1523
+ .map(key => key.trim())
1524
+ .filter(key => key.length > 0);
1525
+ }
1526
+ /**
1527
+ * Generate a unique ID for the shortcut based on the element and keys
1528
+ */
1529
+ generateUniqueId() {
1530
+ const timestamp = Date.now();
1531
+ const random = Math.random().toString(36).substring(2, 9);
1532
+ const keysStr = this.keys || this.steps?.flat().join('-') || 'unknown';
1533
+ return `ngx-shortcut-${keysStr}-${timestamp}-${random}`;
1534
+ }
1535
+ /**
1536
+ * Validate that required inputs are provided correctly
1537
+ */
1538
+ validateInputs() {
1539
+ const hasSingleStep = this.keys || this.macKeys;
1540
+ const hasMultiStep = this.steps || this.macSteps;
1541
+ if (!hasSingleStep && !hasMultiStep) {
1542
+ throw new Error(`[ngxKeys] Must provide either 'keys'/'macKeys' for single-step shortcuts or 'steps'/'macSteps' for multi-step shortcuts.`);
1543
+ }
1544
+ if (hasSingleStep && hasMultiStep) {
1545
+ throw new Error(`[ngxKeys] Cannot use both single-step ('keys'/'macKeys') and multi-step ('steps'/'macSteps') inputs simultaneously. Choose one approach.`);
1546
+ }
1547
+ if (!this.description) {
1548
+ throw new Error(`[ngxKeys] 'description' input is required.`);
1549
+ }
1550
+ }
1551
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcutDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1552
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.2.4", type: KeyboardShortcutDirective, isStandalone: true, selector: "[ngxKeys]", inputs: { keys: "keys", macKeys: "macKeys", steps: "steps", macSteps: "macSteps", description: "description", action: "action", shortcutId: "shortcutId" }, outputs: { triggered: "triggered" }, host: { properties: { "attr.data-keyboard-shortcut": "this.dataAttribute" } }, ngImport: i0 });
1553
+ }
1554
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcutDirective, decorators: [{
1555
+ type: Directive,
1556
+ args: [{
1557
+ selector: '[ngxKeys]',
1558
+ standalone: true,
1559
+ }]
1560
+ }], propDecorators: { keys: [{
1561
+ type: Input
1562
+ }], macKeys: [{
1563
+ type: Input
1564
+ }], steps: [{
1565
+ type: Input
1566
+ }], macSteps: [{
1567
+ type: Input
1568
+ }], description: [{
1569
+ type: Input,
1570
+ args: [{ required: true }]
1571
+ }], action: [{
1572
+ type: Input
1573
+ }], shortcutId: [{
1574
+ type: Input
1575
+ }], dataAttribute: [{
1576
+ type: HostBinding,
1577
+ args: ['attr.data-keyboard-shortcut']
1578
+ }] } });
1579
+
764
1580
  /*
765
1581
  * Public API Surface of ngx-keys
766
1582
  */
@@ -769,5 +1585,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
769
1585
  * Generated bundle index. Do not edit.
770
1586
  */
771
1587
 
772
- export { KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors };
1588
+ export { KEYBOARD_SHORTCUTS_CONFIG, KeyboardShortcutDirective, KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors, provideKeyboardShortcutsConfig };
773
1589
  //# sourceMappingURL=ngx-keys.mjs.map