ngx-keys 1.2.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,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
@@ -365,10 +561,10 @@ class KeyboardShortcuts {
365
561
  keyConflicts.push(`"${shortcut.id}" conflicts with active shortcut "${conflictId}"`);
366
562
  }
367
563
  });
368
- if (duplicateIds.length > 0) {
564
+ if (duplicateIds.length > MIN_KEY_LENGTH) {
369
565
  throw KeyboardShortcutsErrorFactory.shortcutIdsAlreadyRegistered(duplicateIds);
370
566
  }
371
- if (keyConflicts.length > 0) {
567
+ if (keyConflicts.length > MIN_KEY_LENGTH) {
372
568
  throw KeyboardShortcutsErrorFactory.keyConflictsInGroup(keyConflicts);
373
569
  }
374
570
  // Validate that all shortcuts have unique IDs within the group
@@ -382,7 +578,7 @@ class KeyboardShortcuts {
382
578
  groupIds.add(shortcut.id);
383
579
  }
384
580
  });
385
- if (duplicatesInGroup.length > 0) {
581
+ if (duplicatesInGroup.length > MIN_KEY_LENGTH) {
386
582
  throw KeyboardShortcutsErrorFactory.duplicateShortcutsInGroup(duplicatesInGroup);
387
583
  }
388
584
  // Use batch update to reduce signal updates
@@ -446,7 +642,7 @@ class KeyboardShortcuts {
446
642
  }
447
643
  // Check for conflicts that would be created by activation
448
644
  const conflicts = this.findActivationConflicts(shortcutId);
449
- if (conflicts.length > 0) {
645
+ if (conflicts.length > MIN_KEY_LENGTH) {
450
646
  throw KeyboardShortcutsErrorFactory.activationKeyConflict(shortcutId, conflicts);
451
647
  }
452
648
  this.activeShortcuts.add(shortcutId);
@@ -478,7 +674,7 @@ class KeyboardShortcuts {
478
674
  const conflicts = this.findActivationConflicts(shortcut.id);
479
675
  allConflicts.push(...conflicts);
480
676
  });
481
- if (allConflicts.length > 0) {
677
+ if (allConflicts.length > MIN_KEY_LENGTH) {
482
678
  throw KeyboardShortcutsErrorFactory.groupActivationKeyConflict(groupId, allConflicts);
483
679
  }
484
680
  this.batchUpdate(() => {
@@ -608,6 +804,147 @@ class KeyboardShortcuts {
608
804
  hasFilter(name) {
609
805
  return this.globalFilters.has(name);
610
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
+ }
611
948
  /**
612
949
  * Find the group that contains a specific shortcut.
613
950
  *
@@ -677,7 +1014,7 @@ class KeyboardShortcuts {
677
1014
  // Fast path: if any global filter blocks this event, bail out before
678
1015
  // scanning all active shortcuts. This drastically reduces per-event work
679
1016
  // when filters are commonly blocking (e.g., while typing in inputs).
680
- if (this.globalFilters.size > 0) {
1017
+ if (this.globalFilters.size > MIN_KEY_LENGTH) {
681
1018
  for (const f of this.globalFilters.values()) {
682
1019
  if (!f(event)) {
683
1020
  // Also clear any pending multi-step sequence – entering a globally
@@ -716,7 +1053,7 @@ class KeyboardShortcuts {
716
1053
  if (expected && this.keysMatch(stepPressed, expected)) {
717
1054
  // Advance sequence
718
1055
  clearTimeout(pending.timerId);
719
- pending.stepIndex += 1;
1056
+ pending.stepIndex += STATE_VERSION_INCREMENT;
720
1057
  if (pending.stepIndex >= normalizedSteps.length) {
721
1058
  // Completed - check filters before executing
722
1059
  if (!this.shouldProcessEvent(event, shortcut)) {
@@ -734,8 +1071,10 @@ class KeyboardShortcuts {
734
1071
  this.pendingSequence = null;
735
1072
  return;
736
1073
  }
737
- // Reset timer for next step
738
- 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
+ }
739
1078
  return;
740
1079
  }
741
1080
  else {
@@ -762,14 +1101,14 @@ class KeyboardShortcuts {
762
1101
  ? (shortcut.macSteps ?? shortcut.macKeys ?? shortcut.steps ?? shortcut.keys ?? [])
763
1102
  : (shortcut.steps ?? shortcut.keys ?? shortcut.macSteps ?? shortcut.macKeys ?? []);
764
1103
  const normalizedSteps = this.normalizeToSteps(steps);
765
- const firstStep = normalizedSteps[0];
1104
+ const firstStep = normalizedSteps[FIRST_INDEX];
766
1105
  // Decide which pressed-keys representation to use for this shortcut's
767
1106
  // expected step: if it requires multiple non-modifier keys, treat it as
768
1107
  // a chord and use accumulated keys; otherwise use per-event keys to avoid
769
1108
  // interference from previously pressed non-modifier keys.
770
- const nonModifierCount = firstStep.filter(k => !KeyboardShortcuts.MODIFIER_KEYS.has(k.toLowerCase())).length;
1109
+ const nonModifierCount = firstStep.filter(k => !KeyMatcher.isModifierKey(k)).length;
771
1110
  // Normalize pressed keys to a Set<string> for consistent typing
772
- const pressedForStep = nonModifierCount > 1
1111
+ const pressedForStep = nonModifierCount > MIN_COUNT_ONE
773
1112
  ? this.buildPressedKeysForMatch(event)
774
1113
  : this.getPressedKeys(event);
775
1114
  if (this.keysMatch(pressedForStep, firstStep)) {
@@ -777,7 +1116,7 @@ class KeyboardShortcuts {
777
1116
  if (!this.shouldProcessEvent(event, shortcut)) {
778
1117
  continue; // Skip this shortcut due to filters
779
1118
  }
780
- if (normalizedSteps.length === 1) {
1119
+ if (normalizedSteps.length === SINGLE_STEP_LENGTH) {
781
1120
  // single-step
782
1121
  event.preventDefault();
783
1122
  event.stopPropagation();
@@ -794,10 +1133,13 @@ class KeyboardShortcuts {
794
1133
  if (this.pendingSequence) {
795
1134
  this.clearPendingSequence();
796
1135
  }
1136
+ const timerId = shortcut.sequenceTimeout !== undefined
1137
+ ? setTimeout(() => { this.pendingSequence = null; }, shortcut.sequenceTimeout)
1138
+ : null;
797
1139
  this.pendingSequence = {
798
1140
  shortcutId: shortcut.id,
799
- stepIndex: 1,
800
- timerId: setTimeout(() => { this.pendingSequence = null; }, this.sequenceTimeout)
1141
+ stepIndex: SECOND_STEP_INDEX,
1142
+ timerId
801
1143
  };
802
1144
  event.preventDefault();
803
1145
  event.stopPropagation();
@@ -807,9 +1149,9 @@ class KeyboardShortcuts {
807
1149
  }
808
1150
  }
809
1151
  handleKeyup(event) {
810
- // Remove the key from currentlyDownKeys on keyup
1152
+ // Remove the key from currently DownKeys on keyup
811
1153
  const key = event.key ? event.key.toLowerCase() : '';
812
- if (key && !KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1154
+ if (key && !KeyMatcher.isModifierKey(key)) {
813
1155
  this.currentlyDownKeys.delete(key);
814
1156
  }
815
1157
  }
@@ -834,6 +1176,11 @@ class KeyboardShortcuts {
834
1176
  this.clearCurrentlyDownKeys();
835
1177
  this.clearPendingSequence();
836
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
+ }
837
1184
  }
838
1185
  /**
839
1186
  * Update the currentlyDownKeys set when keydown events happen.
@@ -843,7 +1190,7 @@ class KeyboardShortcuts {
843
1190
  updateCurrentlyDownKeysOnKeydown(event) {
844
1191
  const key = event.key ? event.key.toLowerCase() : '';
845
1192
  // Ignore modifier-only keydown entries
846
- if (KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1193
+ if (KeyMatcher.isModifierKey(key)) {
847
1194
  return;
848
1195
  }
849
1196
  // Normalize some special cases similar to the demo component's recording logic
@@ -863,7 +1210,7 @@ class KeyboardShortcuts {
863
1210
  this.currentlyDownKeys.add('enter');
864
1211
  return;
865
1212
  }
866
- if (key && key.length > 0) {
1213
+ if (key && key.length > MIN_KEY_LENGTH) {
867
1214
  this.currentlyDownKeys.add(key);
868
1215
  }
869
1216
  }
@@ -886,17 +1233,17 @@ class KeyboardShortcuts {
886
1233
  if (event.metaKey)
887
1234
  modifiers.add('meta');
888
1235
  // Collect non-modifier keys from currentlyDownKeys (excluding modifiers)
889
- const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyboardShortcuts.MODIFIER_KEYS.has(k));
1236
+ const nonModifierKeys = Array.from(this.currentlyDownKeys).filter(k => !KeyMatcher.isModifierKey(k));
890
1237
  const result = new Set();
891
1238
  // Add modifiers first
892
1239
  modifiers.forEach(m => result.add(m));
893
- if (nonModifierKeys.length > 0) {
1240
+ if (nonModifierKeys.length > MIN_KEY_LENGTH) {
894
1241
  nonModifierKeys.forEach(k => result.add(k.toLowerCase()));
895
1242
  return result;
896
1243
  }
897
1244
  // Fallback: single main key from the event (existing behavior)
898
1245
  const key = event.key.toLowerCase();
899
- if (!KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1246
+ if (!KeyMatcher.isModifierKey(key)) {
900
1247
  result.add(key);
901
1248
  }
902
1249
  return result;
@@ -917,7 +1264,7 @@ class KeyboardShortcuts {
917
1264
  result.add('meta');
918
1265
  // Add the main key (normalize to lowercase) if it's not a modifier
919
1266
  const key = (event.key ?? '').toLowerCase();
920
- if (key && !KeyboardShortcuts.MODIFIER_KEYS.has(key)) {
1267
+ if (key && !KeyMatcher.isModifierKey(key)) {
921
1268
  result.add(key);
922
1269
  }
923
1270
  return result;
@@ -927,40 +1274,28 @@ class KeyboardShortcuts {
927
1274
  * Accepts either a Set<string> (preferred) or an array for backwards compatibility.
928
1275
  * Uses Set-based comparison: sizes must match and every element in target must exist in pressed.
929
1276
  */
1277
+ /**
1278
+ * @deprecated Use KeyMatcher.keysMatch() instead
1279
+ */
930
1280
  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;
1281
+ return KeyMatcher.keysMatch(pressedKeys, targetKeys);
947
1282
  }
948
- /** Compare two multi-step sequences for equality */
1283
+ /**
1284
+ * Compare two multi-step sequences for equality
1285
+ * @deprecated Use KeyMatcher.stepsMatch() instead
1286
+ */
949
1287
  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;
1288
+ return KeyMatcher.stepsMatch(a, b);
957
1289
  }
958
1290
  /** Safely clear any pending multi-step sequence */
959
1291
  clearPendingSequence() {
960
1292
  if (!this.pendingSequence)
961
1293
  return;
962
1294
  try {
963
- clearTimeout(this.pendingSequence.timerId);
1295
+ // Only clear timeout if one was set
1296
+ if (this.pendingSequence.timerId !== null) {
1297
+ clearTimeout(this.pendingSequence.timerId);
1298
+ }
964
1299
  }
965
1300
  catch { /* ignore */ }
966
1301
  this.pendingSequence = null;
@@ -993,7 +1328,7 @@ class KeyboardShortcuts {
993
1328
  */
994
1329
  precomputeBlockedGroups(event) {
995
1330
  const blocked = new Set();
996
- if (this.activeGroups.size === 0)
1331
+ if (this.activeGroups.size === MIN_KEY_LENGTH)
997
1332
  return blocked;
998
1333
  for (const groupId of this.activeGroups) {
999
1334
  const group = this.groups.get(groupId);
@@ -1013,6 +1348,235 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
1013
1348
  }]
1014
1349
  }], ctorParameters: () => [] });
1015
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
+
1016
1580
  /*
1017
1581
  * Public API Surface of ngx-keys
1018
1582
  */
@@ -1021,5 +1585,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImpor
1021
1585
  * Generated bundle index. Do not edit.
1022
1586
  */
1023
1587
 
1024
- export { KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors };
1588
+ export { KEYBOARD_SHORTCUTS_CONFIG, KeyboardShortcutDirective, KeyboardShortcutError, KeyboardShortcuts, KeyboardShortcutsErrorFactory, KeyboardShortcutsErrors, provideKeyboardShortcutsConfig };
1025
1589
  //# sourceMappingURL=ngx-keys.mjs.map