ngx-keys 1.2.0 → 1.3.1

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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { DestroyRef, inject, DOCUMENT, signal, computed, afterNextRender, Injectable } from '@angular/core';
2
+ import { InjectionToken, DestroyRef, inject, DOCUMENT, signal, computed, afterNextRender, Injectable, ElementRef, output, HostBinding, Input, Directive } from '@angular/core';
3
3
  import { Observable, take } from 'rxjs';
4
4
 
5
5
  /**
@@ -99,6 +99,204 @@ class KeyboardShortcutsErrorFactory {
99
99
  }
100
100
  }
101
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
+
102
300
  /**
103
301
  * Type guard to detect KeyboardShortcutGroupOptions at runtime.
104
302
  * Centralising this logic keeps registerGroup simpler and less fragile.
@@ -129,9 +327,9 @@ function isDestroyRefLike(obj) {
129
327
  return typeof o['onDestroy'] === 'function';
130
328
  }
131
329
  class KeyboardShortcuts {
132
- static MODIFIER_KEYS = new Set(['control', 'alt', 'shift', 'meta']);
133
330
  document = inject(DOCUMENT);
134
331
  window = this.document.defaultView;
332
+ config = inject(KEYBOARD_SHORTCUTS_CONFIG);
135
333
  shortcuts = new Map();
136
334
  groups = new Map();
137
335
  activeShortcuts = new Set();
@@ -150,7 +348,7 @@ class KeyboardShortcuts {
150
348
  groups: new Map(),
151
349
  activeShortcuts: new Set(),
152
350
  activeGroups: new Set(),
153
- version: 0 // for change detection optimization
351
+ version: INITIAL_STATE_VERSION // for change detection optimization
154
352
  }, ...(ngDevMode ? [{ debugName: "state" }] : []));
155
353
  // Primary computed signal - consumers derive what they need from this
156
354
  shortcuts$ = computed(() => {
@@ -185,8 +383,6 @@ class KeyboardShortcuts {
185
383
  blurListener = this.handleWindowBlur.bind(this);
186
384
  visibilityListener = this.handleVisibilityChange.bind(this);
187
385
  isListening = false;
188
- /** Default timeout (ms) for completing a multi-step sequence */
189
- sequenceTimeout = 2000;
190
386
  /** Runtime state for multi-step sequences */
191
387
  pendingSequence = null;
192
388
  constructor() {
@@ -206,7 +402,7 @@ class KeyboardShortcuts {
206
402
  groups: new Map(this.groups),
207
403
  activeShortcuts: new Set(this.activeShortcuts),
208
404
  activeGroups: new Set(this.activeGroups),
209
- version: current.version + 1
405
+ version: current.version + STATE_VERSION_INCREMENT
210
406
  }));
211
407
  }
212
408
  /**
@@ -253,17 +449,17 @@ class KeyboardShortcuts {
253
449
  return '';
254
450
  // If the first element is an array, assume steps is string[][]
255
451
  const normalized = this.normalizeToSteps(steps);
256
- if (normalized.length === 0)
452
+ if (normalized.length === MIN_KEY_LENGTH)
257
453
  return '';
258
- if (normalized.length === 1)
259
- return this.formatKeysForDisplay(normalized[0], isMac);
454
+ if (normalized.length === SINGLE_STEP_LENGTH)
455
+ return this.formatKeysForDisplay(normalized[FIRST_INDEX], isMac);
260
456
  return normalized.map(step => this.formatKeysForDisplay(step, isMac)).join(', ');
261
457
  }
262
458
  normalizeToSteps(input) {
263
459
  if (!input)
264
460
  return [];
265
461
  // If first element is an array, assume already KeyStep[]
266
- if (Array.isArray(input[0])) {
462
+ if (Array.isArray(input[FIRST_INDEX])) {
267
463
  return input;
268
464
  }
269
465
  // Single step array
@@ -293,6 +489,32 @@ class KeyboardShortcuts {
293
489
  if (newShortcut.macSteps && existing.macSteps && this.stepsMatch(newShortcut.macSteps, existing.macSteps)) {
294
490
  return existing.id;
295
491
  }
492
+ // Check if new single-step shortcut conflicts with first step of existing multi-step shortcut
493
+ if (newShortcut.keys && existing.steps) {
494
+ const firstStep = existing.steps[FIRST_INDEX];
495
+ if (firstStep && this.keysMatch(newShortcut.keys, firstStep)) {
496
+ return existing.id;
497
+ }
498
+ }
499
+ if (newShortcut.macKeys && existing.macSteps) {
500
+ const firstStep = existing.macSteps[FIRST_INDEX];
501
+ if (firstStep && this.keysMatch(newShortcut.macKeys, firstStep)) {
502
+ return existing.id;
503
+ }
504
+ }
505
+ // Check if new multi-step shortcut's first step conflicts with existing single-step shortcut
506
+ if (newShortcut.steps && existing.keys) {
507
+ const firstStep = newShortcut.steps[FIRST_INDEX];
508
+ if (firstStep && this.keysMatch(firstStep, existing.keys)) {
509
+ return existing.id;
510
+ }
511
+ }
512
+ if (newShortcut.macSteps && existing.macKeys) {
513
+ const firstStep = newShortcut.macSteps[FIRST_INDEX];
514
+ if (firstStep && this.keysMatch(firstStep, existing.macKeys)) {
515
+ return existing.id;
516
+ }
517
+ }
296
518
  }
297
519
  return null;
298
520
  }
@@ -304,7 +526,7 @@ class KeyboardShortcuts {
304
526
  const shortcut = this.shortcuts.get(shortcutId);
305
527
  if (!shortcut)
306
528
  return [];
307
- const conflicts = [];
529
+ const conflictsSet = new Set();
308
530
  for (const existing of this.shortcuts.values()) {
309
531
  // Skip self and inactive shortcuts
310
532
  if (existing.id === shortcutId || !this.activeShortcuts.has(existing.id)) {
@@ -315,10 +537,41 @@ class KeyboardShortcuts {
315
537
  (shortcut.macKeys && existing.macKeys && this.keysMatch(shortcut.macKeys, existing.macKeys)) ||
316
538
  (shortcut.steps && existing.steps && this.stepsMatch(shortcut.steps, existing.steps)) ||
317
539
  (shortcut.macSteps && existing.macSteps && this.stepsMatch(shortcut.macSteps, existing.macSteps))) {
318
- conflicts.push(existing.id);
540
+ conflictsSet.add(existing.id);
541
+ continue; // Skip further checks for this shortcut to avoid duplicate adds
542
+ }
543
+ // Check if shortcut's single-step conflicts with first step of existing multi-step shortcut
544
+ if (shortcut.keys && existing.steps) {
545
+ const firstStep = existing.steps[FIRST_INDEX];
546
+ if (firstStep && this.keysMatch(shortcut.keys, firstStep)) {
547
+ conflictsSet.add(existing.id);
548
+ continue;
549
+ }
550
+ }
551
+ if (shortcut.macKeys && existing.macSteps) {
552
+ const firstStep = existing.macSteps[FIRST_INDEX];
553
+ if (firstStep && this.keysMatch(shortcut.macKeys, firstStep)) {
554
+ conflictsSet.add(existing.id);
555
+ continue;
556
+ }
557
+ }
558
+ // Check if shortcut's multi-step first step conflicts with existing single-step shortcut
559
+ if (shortcut.steps && existing.keys) {
560
+ const firstStep = shortcut.steps[FIRST_INDEX];
561
+ if (firstStep && this.keysMatch(firstStep, existing.keys)) {
562
+ conflictsSet.add(existing.id);
563
+ continue;
564
+ }
565
+ }
566
+ if (shortcut.macSteps && existing.macKeys) {
567
+ const firstStep = shortcut.macSteps[FIRST_INDEX];
568
+ if (firstStep && this.keysMatch(firstStep, existing.macKeys)) {
569
+ conflictsSet.add(existing.id);
570
+ continue;
571
+ }
319
572
  }
320
573
  }
321
- return conflicts;
574
+ return Array.from(conflictsSet);
322
575
  }
323
576
  /**
324
577
  * Register a single keyboard shortcut
@@ -365,10 +618,10 @@ class KeyboardShortcuts {
365
618
  keyConflicts.push(`"${shortcut.id}" conflicts with active shortcut "${conflictId}"`);
366
619
  }
367
620
  });
368
- if (duplicateIds.length > 0) {
621
+ if (duplicateIds.length > MIN_KEY_LENGTH) {
369
622
  throw KeyboardShortcutsErrorFactory.shortcutIdsAlreadyRegistered(duplicateIds);
370
623
  }
371
- if (keyConflicts.length > 0) {
624
+ if (keyConflicts.length > MIN_KEY_LENGTH) {
372
625
  throw KeyboardShortcutsErrorFactory.keyConflictsInGroup(keyConflicts);
373
626
  }
374
627
  // Validate that all shortcuts have unique IDs within the group
@@ -382,7 +635,7 @@ class KeyboardShortcuts {
382
635
  groupIds.add(shortcut.id);
383
636
  }
384
637
  });
385
- if (duplicatesInGroup.length > 0) {
638
+ if (duplicatesInGroup.length > MIN_KEY_LENGTH) {
386
639
  throw KeyboardShortcutsErrorFactory.duplicateShortcutsInGroup(duplicatesInGroup);
387
640
  }
388
641
  // Use batch update to reduce signal updates
@@ -446,7 +699,7 @@ class KeyboardShortcuts {
446
699
  }
447
700
  // Check for conflicts that would be created by activation
448
701
  const conflicts = this.findActivationConflicts(shortcutId);
449
- if (conflicts.length > 0) {
702
+ if (conflicts.length > MIN_KEY_LENGTH) {
450
703
  throw KeyboardShortcutsErrorFactory.activationKeyConflict(shortcutId, conflicts);
451
704
  }
452
705
  this.activeShortcuts.add(shortcutId);
@@ -478,7 +731,7 @@ class KeyboardShortcuts {
478
731
  const conflicts = this.findActivationConflicts(shortcut.id);
479
732
  allConflicts.push(...conflicts);
480
733
  });
481
- if (allConflicts.length > 0) {
734
+ if (allConflicts.length > MIN_KEY_LENGTH) {
482
735
  throw KeyboardShortcutsErrorFactory.groupActivationKeyConflict(groupId, allConflicts);
483
736
  }
484
737
  this.batchUpdate(() => {
@@ -608,6 +861,147 @@ class KeyboardShortcuts {
608
861
  hasFilter(name) {
609
862
  return this.globalFilters.has(name);
610
863
  }
864
+ /**
865
+ * Remove the filter from a specific group
866
+ * @param groupId - The group ID
867
+ * @throws KeyboardShortcutError if group doesn't exist
868
+ */
869
+ removeGroupFilter(groupId) {
870
+ const group = this.groups.get(groupId);
871
+ if (!group) {
872
+ throw KeyboardShortcutsErrorFactory.cannotDeactivateGroup(groupId);
873
+ }
874
+ if (!group.filter) {
875
+ // No filter to remove, silently succeed
876
+ return;
877
+ }
878
+ this.groups.set(groupId, {
879
+ ...group,
880
+ filter: undefined
881
+ });
882
+ this.updateState();
883
+ }
884
+ /**
885
+ * Remove the filter from a specific shortcut
886
+ * @param shortcutId - The shortcut ID
887
+ * @throws KeyboardShortcutError if shortcut doesn't exist
888
+ */
889
+ removeShortcutFilter(shortcutId) {
890
+ const shortcut = this.shortcuts.get(shortcutId);
891
+ if (!shortcut) {
892
+ throw KeyboardShortcutsErrorFactory.cannotDeactivateShortcut(shortcutId);
893
+ }
894
+ if (!shortcut.filter) {
895
+ // No filter to remove, silently succeed
896
+ return;
897
+ }
898
+ this.shortcuts.set(shortcutId, {
899
+ ...shortcut,
900
+ filter: undefined
901
+ });
902
+ this.updateState();
903
+ }
904
+ /**
905
+ * Remove all filters from all groups
906
+ */
907
+ clearAllGroupFilters() {
908
+ this.batchUpdate(() => {
909
+ this.groups.forEach((group, id) => {
910
+ if (group.filter) {
911
+ this.removeGroupFilter(id);
912
+ }
913
+ });
914
+ });
915
+ }
916
+ /**
917
+ * Remove all filters from all shortcuts
918
+ */
919
+ clearAllShortcutFilters() {
920
+ this.batchUpdate(() => {
921
+ this.shortcuts.forEach((shortcut, id) => {
922
+ if (shortcut.filter) {
923
+ this.removeShortcutFilter(id);
924
+ }
925
+ });
926
+ });
927
+ }
928
+ /**
929
+ * Check if a group has a filter
930
+ * @param groupId - The group ID
931
+ * @returns True if the group has a filter
932
+ */
933
+ hasGroupFilter(groupId) {
934
+ const group = this.groups.get(groupId);
935
+ return !!group?.filter;
936
+ }
937
+ /**
938
+ * Check if a shortcut has a filter
939
+ * @param shortcutId - The shortcut ID
940
+ * @returns True if the shortcut has a filter
941
+ */
942
+ hasShortcutFilter(shortcutId) {
943
+ const shortcut = this.shortcuts.get(shortcutId);
944
+ return !!shortcut?.filter;
945
+ }
946
+ /**
947
+ * Register multiple shortcuts in a single batch update
948
+ * @param shortcuts - Array of shortcuts to register
949
+ */
950
+ registerMany(shortcuts) {
951
+ this.batchUpdate(() => {
952
+ shortcuts.forEach(shortcut => this.register(shortcut));
953
+ });
954
+ }
955
+ /**
956
+ * Unregister multiple shortcuts in a single batch update
957
+ * @param ids - Array of shortcut IDs to unregister
958
+ */
959
+ unregisterMany(ids) {
960
+ this.batchUpdate(() => {
961
+ ids.forEach(id => this.unregister(id));
962
+ });
963
+ }
964
+ /**
965
+ * Unregister multiple groups in a single batch update
966
+ * @param ids - Array of group IDs to unregister
967
+ */
968
+ unregisterGroups(ids) {
969
+ this.batchUpdate(() => {
970
+ ids.forEach(id => this.unregisterGroup(id));
971
+ });
972
+ }
973
+ /**
974
+ * Clear all shortcuts and groups (nuclear reset)
975
+ */
976
+ clearAll() {
977
+ this.batchUpdate(() => {
978
+ this.shortcuts.clear();
979
+ this.groups.clear();
980
+ this.activeShortcuts.clear();
981
+ this.activeGroups.clear();
982
+ this.shortcutToGroup.clear();
983
+ this.globalFilters.clear();
984
+ this.clearCurrentlyDownKeys();
985
+ this.clearPendingSequence();
986
+ });
987
+ }
988
+ /**
989
+ * Get all shortcuts belonging to a specific group
990
+ * @param groupId - The group ID
991
+ * @returns Array of shortcuts in the group
992
+ */
993
+ getGroupShortcuts(groupId) {
994
+ const group = this.groups.get(groupId);
995
+ if (!group)
996
+ return [];
997
+ return [...group.shortcuts];
998
+ }
999
+ /**
1000
+ * Normalize a key to lowercase for consistent comparison
1001
+ */
1002
+ normalizeKey(key) {
1003
+ return key.toLowerCase();
1004
+ }
611
1005
  /**
612
1006
  * Find the group that contains a specific shortcut.
613
1007
  *
@@ -677,7 +1071,7 @@ class KeyboardShortcuts {
677
1071
  // Fast path: if any global filter blocks this event, bail out before
678
1072
  // scanning all active shortcuts. This drastically reduces per-event work
679
1073
  // when filters are commonly blocking (e.g., while typing in inputs).
680
- if (this.globalFilters.size > 0) {
1074
+ if (this.globalFilters.size > MIN_KEY_LENGTH) {
681
1075
  for (const f of this.globalFilters.values()) {
682
1076
  if (!f(event)) {
683
1077
  // Also clear any pending multi-step sequence – entering a globally
@@ -716,7 +1110,7 @@ class KeyboardShortcuts {
716
1110
  if (expected && this.keysMatch(stepPressed, expected)) {
717
1111
  // Advance sequence
718
1112
  clearTimeout(pending.timerId);
719
- pending.stepIndex += 1;
1113
+ pending.stepIndex += STATE_VERSION_INCREMENT;
720
1114
  if (pending.stepIndex >= normalizedSteps.length) {
721
1115
  // Completed - check filters before executing
722
1116
  if (!this.shouldProcessEvent(event, shortcut)) {
@@ -734,8 +1128,10 @@ class KeyboardShortcuts {
734
1128
  this.pendingSequence = null;
735
1129
  return;
736
1130
  }
737
- // Reset timer for next step
738
- pending.timerId = setTimeout(() => { this.pendingSequence = null; }, this.sequenceTimeout);
1131
+ // Reset timer for next step (only if shortcut has timeout configured)
1132
+ if (shortcut.sequenceTimeout !== undefined) {
1133
+ pending.timerId = setTimeout(() => { this.pendingSequence = null; }, shortcut.sequenceTimeout);
1134
+ }
739
1135
  return;
740
1136
  }
741
1137
  else {
@@ -762,14 +1158,14 @@ class KeyboardShortcuts {
762
1158
  ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
763
1159
  : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
764
1160
  const normalizedSteps = this.normalizeToSteps(steps);
765
- const firstStep = normalizedSteps[0];
1161
+ const firstStep = normalizedSteps[FIRST_INDEX];
766
1162
  // Decide which pressed-keys representation to use for this shortcut's
767
1163
  // expected step: if it requires multiple non-modifier keys, treat it as
768
1164
  // a chord and use accumulated keys; otherwise use per-event keys to avoid
769
1165
  // interference from previously pressed non-modifier keys.
770
- const nonModifierCount = firstStep.filter(k => !KeyboardShortcuts.MODIFIER_KEYS.has(k.toLowerCase())).length;
1166
+ const nonModifierCount = firstStep.filter(k => !KeyMatcher.isModifierKey(k)).length;
771
1167
  // Normalize pressed keys to a Set<string> for consistent typing
772
- const pressedForStep = nonModifierCount > 1
1168
+ const pressedForStep = nonModifierCount > MIN_COUNT_ONE
773
1169
  ? this.buildPressedKeysForMatch(event)
774
1170
  : this.getPressedKeys(event);
775
1171
  if (this.keysMatch(pressedForStep, firstStep)) {
@@ -777,7 +1173,7 @@ class KeyboardShortcuts {
777
1173
  if (!this.shouldProcessEvent(event, shortcut)) {
778
1174
  continue; // Skip this shortcut due to filters
779
1175
  }
780
- if (normalizedSteps.length === 1) {
1176
+ if (normalizedSteps.length === SINGLE_STEP_LENGTH) {
781
1177
  // single-step
782
1178
  event.preventDefault();
783
1179
  event.stopPropagation();
@@ -794,10 +1190,13 @@ class KeyboardShortcuts {
794
1190
  if (this.pendingSequence) {
795
1191
  this.clearPendingSequence();
796
1192
  }
1193
+ const timerId = shortcut.sequenceTimeout !== undefined
1194
+ ? setTimeout(() => { this.pendingSequence = null; }, shortcut.sequenceTimeout)
1195
+ : null;
797
1196
  this.pendingSequence = {
798
1197
  shortcutId: shortcut.id,
799
- stepIndex: 1,
800
- timerId: setTimeout(() => { this.pendingSequence = null; }, this.sequenceTimeout)
1198
+ stepIndex: SECOND_STEP_INDEX,
1199
+ timerId
801
1200
  };
802
1201
  event.preventDefault();
803
1202
  event.stopPropagation();
@@ -807,9 +1206,9 @@ class KeyboardShortcuts {
807
1206
  }
808
1207
  }
809
1208
  handleKeyup(event) {
810
- // Remove the key from currentlyDownKeys on keyup
1209
+ // Remove the key from currently DownKeys on keyup
811
1210
  const key = event.key ? event.key.toLowerCase() : '';
812
- if (key && !KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1211
+ if (key && !KeyMatcher.isModifierKey(key)) {
813
1212
  this.currentlyDownKeys.delete(key);
814
1213
  }
815
1214
  }
@@ -834,6 +1233,11 @@ class KeyboardShortcuts {
834
1233
  this.clearCurrentlyDownKeys();
835
1234
  this.clearPendingSequence();
836
1235
  }
1236
+ else if (this.document.visibilityState === 'visible') {
1237
+ // When returning to visibility, clear keys to avoid stale state
1238
+ // from keys that may have been released while document was hidden
1239
+ this.clearCurrentlyDownKeys();
1240
+ }
837
1241
  }
838
1242
  /**
839
1243
  * Update the currentlyDownKeys set when keydown events happen.
@@ -843,7 +1247,7 @@ class KeyboardShortcuts {
843
1247
  updateCurrentlyDownKeysOnKeydown(event) {
844
1248
  const key = event.key ? event.key.toLowerCase() : '';
845
1249
  // Ignore modifier-only keydown entries
846
- if (KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1250
+ if (KeyMatcher.isModifierKey(key)) {
847
1251
  return;
848
1252
  }
849
1253
  // Normalize some special cases similar to the demo component's recording logic
@@ -863,7 +1267,7 @@ class KeyboardShortcuts {
863
1267
  this.currentlyDownKeys.add('enter');
864
1268
  return;
865
1269
  }
866
- if (key && key.length > 0) {
1270
+ if (key && key.length > MIN_KEY_LENGTH) {
867
1271
  this.currentlyDownKeys.add(key);
868
1272
  }
869
1273
  }
@@ -886,17 +1290,17 @@ class KeyboardShortcuts {
886
1290
  if (event.metaKey)
887
1291
  modifiers.add('meta');
888
1292
  // Collect non-modifier keys from currentlyDownKeys (excluding modifiers)
889
- const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyboardShortcuts.MODIFIER_KEYS.has(k));
1293
+ const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyMatcher.isModifierKey(k));
890
1294
  const result = new Set();
891
1295
  // Add modifiers first
892
1296
  modifiers.forEach(m => result.add(m));
893
- if (nonModifierKeys.length > 0) {
1297
+ if (nonModifierKeys.length > MIN_KEY_LENGTH) {
894
1298
  nonModifierKeys.forEach(k => result.add(k.toLowerCase()));
895
1299
  return result;
896
1300
  }
897
1301
  // Fallback: single main key from the event (existing behavior)
898
1302
  const key = event.key.toLowerCase();
899
- if (!KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1303
+ if (!KeyMatcher.isModifierKey(key)) {
900
1304
  result.add(key);
901
1305
  }
902
1306
  return result;
@@ -917,7 +1321,7 @@ class KeyboardShortcuts {
917
1321
  result.add('meta');
918
1322
  // Add the main key (normalize to lowercase) if it's not a modifier
919
1323
  const key = (event.key ?? '').toLowerCase();
920
- if (key && !KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1324
+ if (key && !KeyMatcher.isModifierKey(key)) {
921
1325
  result.add(key);
922
1326
  }
923
1327
  return result;
@@ -927,40 +1331,28 @@ class KeyboardShortcuts {
927
1331
  * Accepts either a Set<string> (preferred) or an array for backwards compatibility.
928
1332
  * Uses Set-based comparison: sizes must match and every element in target must exist in pressed.
929
1333
  */
1334
+ /**
1335
+ * @deprecated Use KeyMatcher.keysMatch() instead
1336
+ */
930
1337
  keysMatch(pressedKeys, targetKeys) {
931
- // Normalize targetKeys into a Set<string> (lowercased)
932
- const normalizedTarget = new Set(targetKeys.map(k => k.toLowerCase()));
933
- // Normalize pressedKeys into a Set<string> if it's an array
934
- const pressedSet = Array.isArray(pressedKeys)
935
- ? new Set(pressedKeys.map(k => k.toLowerCase()))
936
- : new Set(Array.from(pressedKeys).map(k => k.toLowerCase()));
937
- if (pressedSet.size !== normalizedTarget.size) {
938
- return false;
939
- }
940
- // Check if every element in normalizedTarget exists in pressedSet
941
- for (const key of normalizedTarget) {
942
- if (!pressedSet.has(key)) {
943
- return false;
944
- }
945
- }
946
- return true;
1338
+ return KeyMatcher.keysMatch(pressedKeys, targetKeys);
947
1339
  }
948
- /** Compare two multi-step sequences for equality */
1340
+ /**
1341
+ * Compare two multi-step sequences for equality
1342
+ * @deprecated Use KeyMatcher.stepsMatch() instead
1343
+ */
949
1344
  stepsMatch(a, b) {
950
- if (a.length !== b.length)
951
- return false;
952
- for (let i = 0; i < a.length; i++) {
953
- if (!this.keysMatch(a[i], b[i]))
954
- return false;
955
- }
956
- return true;
1345
+ return KeyMatcher.stepsMatch(a, b);
957
1346
  }
958
1347
  /** Safely clear any pending multi-step sequence */
959
1348
  clearPendingSequence() {
960
1349
  if (!this.pendingSequence)
961
1350
  return;
962
1351
  try {
963
- clearTimeout(this.pendingSequence.timerId);
1352
+ // Only clear timeout if one was set
1353
+ if (this.pendingSequence.timerId !== null) {
1354
+ clearTimeout(this.pendingSequence.timerId);
1355
+ }
964
1356
  }
965
1357
  catch { /* ignore */ }
966
1358
  this.pendingSequence = null;
@@ -993,7 +1385,7 @@ class KeyboardShortcuts {
993
1385
  */
994
1386
  precomputeBlockedGroups(event) {
995
1387
  const blocked = new Set();
996
- if (this.activeGroups.size === 0)
1388
+ if (this.activeGroups.size === MIN_KEY_LENGTH)
997
1389
  return blocked;
998
1390
  for (const groupId of this.activeGroups) {
999
1391
  const group = this.groups.get(groupId);
@@ -1013,6 +1405,235 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
1013
1405
  }]
1014
1406
  }], ctorParameters: () => [] });
1015
1407
 
1408
+ /**
1409
+ * Directive for registering keyboard shortcuts directly on elements in templates.
1410
+ *
1411
+ * This directive automatically:
1412
+ * - Registers the shortcut when the directive initializes
1413
+ * - Triggers the host element's click event (default) or executes a custom action
1414
+ * - Unregisters the shortcut when the component is destroyed
1415
+ *
1416
+ * @example
1417
+ * Basic usage with click trigger:
1418
+ * ```html
1419
+ * <button ngxKeys
1420
+ * keys="ctrl,s"
1421
+ * description="Save document"
1422
+ * (click)="save()">
1423
+ * Save
1424
+ * </button>
1425
+ * ```
1426
+ *
1427
+ * @example
1428
+ * With custom action:
1429
+ * ```html
1430
+ * <button ngxKeys
1431
+ * keys="ctrl,s"
1432
+ * description="Save document"
1433
+ * [action]="customSaveAction">
1434
+ * Save
1435
+ * </button>
1436
+ * ```
1437
+ *
1438
+ * @example
1439
+ * Multi-step shortcuts:
1440
+ * ```html
1441
+ * <button ngxKeys
1442
+ * [steps]="[['ctrl', 'k'], ['ctrl', 's']]"
1443
+ * description="Format document"
1444
+ * (click)="format()">
1445
+ * Format
1446
+ * </button>
1447
+ * ```
1448
+ *
1449
+ * @example
1450
+ * Mac-specific keys:
1451
+ * ```html
1452
+ * <button ngxKeys
1453
+ * keys="ctrl,s"
1454
+ * macKeys="cmd,s"
1455
+ * description="Save document"
1456
+ * (click)="save()">
1457
+ * Save
1458
+ * </button>
1459
+ * ```
1460
+ *
1461
+ * @example
1462
+ * On non-interactive elements:
1463
+ * ```html
1464
+ * <div ngxKeys
1465
+ * keys="?"
1466
+ * description="Show help"
1467
+ * [action]="showHelp">
1468
+ * Help content
1469
+ * </div>
1470
+ * ```
1471
+ */
1472
+ class KeyboardShortcutDirective {
1473
+ keyboardShortcuts = inject(KeyboardShortcuts);
1474
+ elementRef = inject((ElementRef));
1475
+ /**
1476
+ * Comma-separated string of keys for the shortcut (e.g., "ctrl,s" or "alt,shift,k")
1477
+ * Use either `keys` for single-step shortcuts or `steps` for multi-step shortcuts.
1478
+ */
1479
+ keys;
1480
+ /**
1481
+ * Comma-separated string of keys for Mac users (e.g., "cmd,s")
1482
+ * If not provided, `keys` will be used for all platforms.
1483
+ */
1484
+ macKeys;
1485
+ /**
1486
+ * Multi-step shortcut as an array of key arrays.
1487
+ * Each inner array represents one step in the sequence.
1488
+ * Example: [['ctrl', 'k'], ['ctrl', 's']] for Ctrl+K followed by Ctrl+S
1489
+ */
1490
+ steps;
1491
+ /**
1492
+ * Multi-step shortcut for Mac users.
1493
+ * Example: [['cmd', 'k'], ['cmd', 's']]
1494
+ */
1495
+ macSteps;
1496
+ /**
1497
+ * Description of what the shortcut does (displayed in help/documentation)
1498
+ */
1499
+ description;
1500
+ /**
1501
+ * Optional custom action to execute when the shortcut is triggered.
1502
+ * If not provided, the directive will trigger a click event on the host element.
1503
+ */
1504
+ action;
1505
+ /**
1506
+ * Optional custom ID for the shortcut. If not provided, a unique ID will be generated.
1507
+ * Useful for programmatically referencing the shortcut or for debugging.
1508
+ */
1509
+ shortcutId;
1510
+ /**
1511
+ * Event emitted when the keyboard shortcut is triggered.
1512
+ * This fires in addition to the action or click trigger.
1513
+ */
1514
+ triggered = output();
1515
+ /**
1516
+ * Adds a data attribute to the host element for styling or testing purposes
1517
+ */
1518
+ get dataAttribute() {
1519
+ return this.generatedId;
1520
+ }
1521
+ generatedId = '';
1522
+ isRegistered = false;
1523
+ ngOnInit() {
1524
+ // Generate unique ID if not provided
1525
+ this.generatedId = this.shortcutId || this.generateUniqueId();
1526
+ // Validate inputs
1527
+ this.validateInputs();
1528
+ // Parse keys from comma-separated strings
1529
+ const parsedKeys = this.keys ? this.parseKeys(this.keys) : undefined;
1530
+ const parsedMacKeys = this.macKeys ? this.parseKeys(this.macKeys) : undefined;
1531
+ // Define the action: custom action or default click behavior
1532
+ const shortcutAction = () => {
1533
+ if (this.action) {
1534
+ this.action();
1535
+ }
1536
+ else {
1537
+ // Trigger click on the host element
1538
+ this.elementRef.nativeElement.click();
1539
+ }
1540
+ // Emit the triggered event
1541
+ this.triggered.emit();
1542
+ };
1543
+ // Register the shortcut
1544
+ try {
1545
+ this.keyboardShortcuts.register({
1546
+ id: this.generatedId,
1547
+ keys: parsedKeys,
1548
+ macKeys: parsedMacKeys,
1549
+ steps: this.steps,
1550
+ macSteps: this.macSteps,
1551
+ action: shortcutAction,
1552
+ description: this.description,
1553
+ });
1554
+ this.isRegistered = true;
1555
+ }
1556
+ catch (error) {
1557
+ console.error(`[ngxKeys] Failed to register shortcut:`, error);
1558
+ throw error;
1559
+ }
1560
+ }
1561
+ ngOnDestroy() {
1562
+ // Automatically unregister the shortcut when the directive is destroyed
1563
+ if (this.isRegistered) {
1564
+ try {
1565
+ this.keyboardShortcuts.unregister(this.generatedId);
1566
+ }
1567
+ catch (error) {
1568
+ // Silently handle unregister errors (shortcut might have been manually removed)
1569
+ console.warn(`[ngxKeys] Failed to unregister shortcut ${this.generatedId}:`, error);
1570
+ }
1571
+ }
1572
+ }
1573
+ /**
1574
+ * Parse comma-separated key string into an array
1575
+ * Example: "ctrl,s" -> ["ctrl", "s"]
1576
+ */
1577
+ parseKeys(keysString) {
1578
+ return keysString
1579
+ .split(',')
1580
+ .map(key => key.trim())
1581
+ .filter(key => key.length > 0);
1582
+ }
1583
+ /**
1584
+ * Generate a unique ID for the shortcut based on the element and keys
1585
+ */
1586
+ generateUniqueId() {
1587
+ const timestamp = Date.now();
1588
+ const random = Math.random().toString(36).substring(2, 9);
1589
+ const keysStr = this.keys || this.steps?.flat().join('-') || 'unknown';
1590
+ return `ngx-shortcut-${keysStr}-${timestamp}-${random}`;
1591
+ }
1592
+ /**
1593
+ * Validate that required inputs are provided correctly
1594
+ */
1595
+ validateInputs() {
1596
+ const hasSingleStep = this.keys || this.macKeys;
1597
+ const hasMultiStep = this.steps || this.macSteps;
1598
+ if (!hasSingleStep && !hasMultiStep) {
1599
+ throw new Error(`[ngxKeys] Must provide either 'keys'/'macKeys' for single-step shortcuts or 'steps'/'macSteps' for multi-step shortcuts.`);
1600
+ }
1601
+ if (hasSingleStep && hasMultiStep) {
1602
+ throw new Error(`[ngxKeys] Cannot use both single-step ('keys'/'macKeys') and multi-step ('steps'/'macSteps') inputs simultaneously. Choose one approach.`);
1603
+ }
1604
+ if (!this.description) {
1605
+ throw new Error(`[ngxKeys] 'description' input is required.`);
1606
+ }
1607
+ }
1608
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcutDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1609
+ 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 });
1610
+ }
1611
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: KeyboardShortcutDirective, decorators: [{
1612
+ type: Directive,
1613
+ args: [{
1614
+ selector: '[ngxKeys]',
1615
+ standalone: true,
1616
+ }]
1617
+ }], propDecorators: { keys: [{
1618
+ type: Input
1619
+ }], macKeys: [{
1620
+ type: Input
1621
+ }], steps: [{
1622
+ type: Input
1623
+ }], macSteps: [{
1624
+ type: Input
1625
+ }], description: [{
1626
+ type: Input,
1627
+ args: [{ required: true }]
1628
+ }], action: [{
1629
+ type: Input
1630
+ }], shortcutId: [{
1631
+ type: Input
1632
+ }], dataAttribute: [{
1633
+ type: HostBinding,
1634
+ args: ['attr.data-keyboard-shortcut']
1635
+ }] } });
1636
+
1016
1637
  /*
1017
1638
  * Public API Surface of ngx-keys
1018
1639
  */
@@ -1021,5 +1642,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
1021
1642
  * Generated bundle index. Do not edit.
1022
1643
  */
1023
1644
 
1024
- export { KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors };
1645
+ export { KEYBOARD_SHORTCUTS_CONFIG, KeyboardShortcutDirective, KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors, provideKeyboardShortcutsConfig };
1025
1646
  //# sourceMappingURL=ngx-keys.mjs.map