ngx-keys 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,8 @@ A lightweight, reactive Angular service for managing keyboard shortcuts with sig
10
10
  - **🌍 Cross-Platform**: Automatic Mac/PC key display formatting
11
11
  - **🔄 Dynamic Management**: Add, remove, activate/deactivate shortcuts at runtime
12
12
  - **📁 Group Management**: Organize shortcuts into logical groups
13
- - **🪶 Lightweight**: Zero dependencies, minimal bundle impact
13
+ - **� Smart Conflict Detection**: Register multiple shortcuts with same keys when not simultaneously active
14
+ - **�🪶 Lightweight**: Zero dependencies, minimal bundle impact
14
15
 
15
16
  ## Installation
16
17
 
@@ -122,6 +123,56 @@ this.keyboardService.register({
122
123
  });
123
124
  ```
124
125
 
126
+ ### Smart Conflict Detection
127
+ > [!IMPORTANT]
128
+ Conflicts are only checked among **active** shortcuts, not all registered shortcuts.
129
+
130
+ ngx-keys allows registering multiple shortcuts with the same key combination, as long as they're not simultaneously active. This enables powerful patterns:
131
+
132
+ - **Context-specific shortcuts**: Same keys for different UI contexts
133
+ - **Alternative shortcuts**: Multiple ways to trigger the same action
134
+ - **Feature toggles**: Same keys for different modes
135
+
136
+ ```typescript
137
+ // Basic conflict handling
138
+ this.keyboardService.register(shortcut1); // Active by default
139
+ this.keyboardService.deactivate('shortcut1');
140
+ this.keyboardService.register(shortcut2); // Same keys, but shortcut1 is inactive ✅
141
+
142
+ // This would fail - conflicts with active shortcut2
143
+ // this.keyboardService.activate('shortcut1'); // ❌ Throws error
144
+ ```
145
+
146
+ ### Group Management
147
+
148
+ Organize related shortcuts into groups for easier management:
149
+
150
+ ```typescript
151
+ const editorShortcuts = [
152
+ {
153
+ id: 'bold',
154
+ keys: ['ctrl', 'b'],
155
+ macKeys: ['meta', 'b'],
156
+ action: () => this.makeBold(),
157
+ description: 'Make text bold'
158
+ },
159
+ {
160
+ id: 'italic',
161
+ keys: ['ctrl', 'i'],
162
+ macKeys: ['meta', 'i'],
163
+ action: () => this.makeItalic(),
164
+ description: 'Make text italic'
165
+ }
166
+ ];
167
+
168
+ // Register all shortcuts in the group
169
+ this.keyboardService.registerGroup('editor', editorShortcuts);
170
+
171
+ // Control the entire group
172
+ this.keyboardService.deactivateGroup('editor'); // Disable all editor shortcuts
173
+ this.keyboardService.activateGroup('editor'); // Re-enable all editor shortcuts
174
+ ```
175
+
125
176
  ### Multi-step (sequence) shortcuts
126
177
 
127
178
  In addition to single-step shortcuts using `keys` / `macKeys`, ngx-keys supports ordered multi-step sequences using `steps` and `macSteps` on the `KeyboardShortcut` object. Each element in `steps` is itself an array of key tokens that must be pressed together for that step.
@@ -138,7 +189,7 @@ this.keyboardService.register({
138
189
  });
139
190
  ```
140
191
 
141
- Important behavior notes:
192
+ **Important behavior notes**
142
193
 
143
194
  - Default sequence timeout: the service requires the next step to be entered within 2000ms (2 seconds) of the previous step; otherwise the pending sequence is cleared. This timeout is intentionally conservative and can be changed in future releases or exposed per-shortcut if needed.
144
195
  - Steps are order-sensitive. `steps: [['ctrl','k'], ['s']]` is different from `steps: [['s'], ['ctrl','k']]`.
@@ -155,6 +206,155 @@ this.keyboardService.deactivate('save');
155
206
  this.keyboardService.activate('save');
156
207
  ```
157
208
 
209
+ ## Advanced Usage
210
+
211
+ ### Context-Specific Shortcuts
212
+
213
+ Register different actions for the same keys in different UI contexts:
214
+
215
+ ```typescript
216
+ // Modal context
217
+ this.keyboardService.register({
218
+ id: 'modal-escape',
219
+ keys: ['escape'],
220
+ action: () => this.closeModal(),
221
+ description: 'Close modal'
222
+ });
223
+
224
+ // Initially deactivate since modal isn't shown
225
+ this.keyboardService.deactivate('modal-escape');
226
+
227
+ // Editor context
228
+ this.keyboardService.register({
229
+ id: 'editor-escape',
230
+ keys: ['escape'], // Same key, different context
231
+ action: () => this.exitEditMode(),
232
+ description: 'Exit edit mode'
233
+ });
234
+
235
+ // Switch contexts dynamically
236
+ showModal() {
237
+ this.keyboardService.deactivate('editor-escape');
238
+ this.keyboardService.activate('modal-escape');
239
+ }
240
+
241
+ hideModal() {
242
+ this.keyboardService.deactivate('modal-escape');
243
+ this.keyboardService.activate('editor-escape');
244
+ }
245
+ ```
246
+
247
+ ### Alternative Shortcuts
248
+
249
+ Provide multiple ways to trigger the same functionality:
250
+
251
+ ```typescript
252
+ // Primary shortcut
253
+ this.keyboardService.register({
254
+ id: 'help-f1',
255
+ keys: ['f1'],
256
+ action: () => this.showHelp(),
257
+ description: 'Show help (F1)'
258
+ });
259
+
260
+ // Alternative shortcut - different keys, same action
261
+ this.keyboardService.register({
262
+ id: 'help-ctrl-h',
263
+ keys: ['ctrl', 'h'],
264
+ action: () => this.showHelp(), // Same action
265
+ description: 'Show help (Ctrl+H)'
266
+ });
267
+
268
+ // Both are active simultaneously since they don't conflict
269
+ ```
270
+
271
+ ### Feature Toggles
272
+
273
+ Switch between different modes that use the same keys:
274
+
275
+ ```typescript
276
+ // Design mode
277
+ this.keyboardService.register({
278
+ id: 'design-mode-space',
279
+ keys: ['space'],
280
+ action: () => this.toggleDesignElement(),
281
+ description: 'Toggle design element'
282
+ });
283
+
284
+ // Play mode (same key, different action)
285
+ this.keyboardService.register({
286
+ id: 'play-mode-space',
287
+ keys: ['space'],
288
+ action: () => this.pausePlayback(),
289
+ description: 'Pause/resume playback'
290
+ });
291
+
292
+ // Initially deactivate play mode
293
+ this.keyboardService.deactivate('play-mode-space');
294
+
295
+ // Switch modes
296
+ switchToPlayMode() {
297
+ this.keyboardService.deactivate('design-mode-space');
298
+ this.keyboardService.activate('play-mode-space');
299
+ }
300
+
301
+ switchToDesignMode() {
302
+ this.keyboardService.deactivate('play-mode-space');
303
+ this.keyboardService.activate('design-mode-space');
304
+ }
305
+ ```
306
+
307
+ ### Advanced Group Patterns
308
+
309
+ Use groups for complex activation/deactivation scenarios:
310
+
311
+ ```typescript
312
+ // Create context-specific groups
313
+ const modalShortcuts = [
314
+ { id: 'modal-close', keys: ['escape'], action: () => this.closeModal(), description: 'Close modal' },
315
+ { id: 'modal-confirm', keys: ['enter'], action: () => this.confirmModal(), description: 'Confirm' }
316
+ ];
317
+
318
+ const editorShortcuts = [
319
+ { id: 'editor-save', keys: ['ctrl', 's'], action: () => this.save(), description: 'Save' },
320
+ { id: 'editor-undo', keys: ['ctrl', 'z'], action: () => this.undo(), description: 'Undo' }
321
+ ];
322
+
323
+ // Register both groups
324
+ this.keyboardService.registerGroup('modal', modalShortcuts);
325
+ this.keyboardService.registerGroup('editor', editorShortcuts);
326
+
327
+ // Initially only editor is active
328
+ this.keyboardService.deactivateGroup('modal');
329
+
330
+ // Switch contexts
331
+ showModal() {
332
+ this.keyboardService.deactivateGroup('editor');
333
+ this.keyboardService.activateGroup('modal');
334
+ }
335
+
336
+ hideModal() {
337
+ this.keyboardService.deactivateGroup('modal');
338
+ this.keyboardService.activateGroup('editor');
339
+ }
340
+ ```
341
+
342
+ ### Conflict Detection Rules
343
+
344
+ - **Registration**: Only checks conflicts with currently **active** shortcuts
345
+ - **Activation**: Throws error if activating would conflict with other active shortcuts
346
+ - **Groups**: Same rules apply - groups can contain conflicting shortcuts as long as they're not simultaneously active
347
+
348
+ ```typescript
349
+ // ✅ This works - shortcuts with same keys but only one active at a time
350
+ this.keyboardService.register(shortcut1); // Active by default
351
+ this.keyboardService.deactivate('shortcut1');
352
+ this.keyboardService.register(shortcut2); // Same keys, but shortcut1 is inactive
353
+
354
+ // ❌ This fails - trying to activate would create conflict
355
+ this.keyboardService.activate('shortcut1'); // Throws error - conflicts with active shortcut2
356
+ ```
357
+
158
358
  ## API Reference
159
359
 
160
360
  ### KeyboardShortcuts Service
@@ -162,15 +362,18 @@ this.keyboardService.activate('save');
162
362
  #### Methods
163
363
 
164
364
  **Registration Methods:**
165
- - `register(shortcut: KeyboardShortcut)` - Register and automatically activate a single shortcut *Throws error on conflicts*
166
- - `registerGroup(groupId: string, shortcuts: KeyboardShortcut[])` - Register and automatically activate a group of shortcuts *Throws error on conflicts*
365
+ > [!TIP]
366
+ Conflicts are only checked among **active** shortcuts, not all registered shortcuts.
367
+
368
+ - `register(shortcut: KeyboardShortcut)` - Register and automatically activate a single shortcut *Throws error on conflicts with active shortcuts only*
369
+ - `registerGroup(groupId: string, shortcuts: KeyboardShortcut[])` - Register and automatically activate a group of shortcuts *Throws error on conflicts with active shortcuts only*
167
370
 
168
371
  **Management Methods:**
169
372
  - `unregister(shortcutId: string)` - Remove a shortcut *Throws error if not found*
170
373
  - `unregisterGroup(groupId: string)` - Remove a group *Throws error if not found*
171
- - `activate(shortcutId: string)` - Activate a shortcut *Throws error if not registered*
374
+ - `activate(shortcutId: string)` - Activate a shortcut *Throws error if not registered or would create conflicts*
172
375
  - `deactivate(shortcutId: string)` - Deactivate a shortcut *Throws error if not registered*
173
- - `activateGroup(groupId: string)` - Activate all shortcuts in a group *Throws error if not found*
376
+ - `activateGroup(groupId: string)` - Activate all shortcuts in a group *Throws error if not found or would create conflicts*
174
377
  - `deactivateGroup(groupId: string)` - Deactivate all shortcuts in a group *Throws error if not found*
175
378
 
176
379
  **Query Methods:**
@@ -527,6 +730,157 @@ this.keyboardService.register({
527
730
  });
528
731
  ```
529
732
 
733
+ ### Event Filtering
734
+
735
+ You can configure which keyboard events should be processed by setting a filter function. This is useful for ignoring shortcuts when users are typing in input fields, text areas, or other form elements.
736
+
737
+ > [!NOTE]
738
+ > **No Default Filtering**: ngx-keys processes ALL keyboard events by default. This gives you maximum flexibility - some apps want shortcuts to work everywhere, others want to exclude form inputs. You decide!
739
+
740
+ #### Named filters (recommended)
741
+
742
+ For efficiency and control, prefer named global filters. You can toggle them on/off without replacing others, and ngx-keys evaluates them only once per keydown event (fast path), short‑circuiting further work when blocked.
743
+
744
+ ```typescript
745
+ // Add named filters
746
+ keyboardService.addFilter('forms', (event) => {
747
+ const t = event.target as HTMLElement | null;
748
+ const tag = t?.tagName?.toLowerCase();
749
+ return !(['input', 'textarea', 'select'].includes(tag ?? '')) && !t?.isContentEditable;
750
+ });
751
+
752
+ keyboardService.addFilter('modal-scope', (event) => {
753
+ const t = event.target as HTMLElement | null;
754
+ return !!t?.closest('.modal');
755
+ });
756
+
757
+ // Remove/toggle when context changes
758
+ keyboardService.removeFilter('modal-scope');
759
+
760
+ // Inspect and manage
761
+ keyboardService.getFilterNames(); // ['forms']
762
+ keyboardService.clearFilters(); // remove all
763
+ ```
764
+
765
+ ```typescript
766
+ import { Component, inject } from '@angular/core';
767
+ import { KeyboardShortcuts, KeyboardShortcutFilter } from 'ngx-keys';
768
+
769
+ export class FilterExampleComponent {
770
+ private readonly keyboardService = inject(KeyboardShortcuts);
771
+
772
+ constructor() {
773
+ // Set up shortcuts
774
+ this.keyboardService.register({
775
+ id: 'save',
776
+ keys: ['ctrl', 's'],
777
+ macKeys: ['meta', 's'],
778
+ action: () => this.save(),
779
+ description: 'Save document'
780
+ });
781
+
782
+ // Configure filtering to ignore form elements
783
+ this.setupInputFiltering();
784
+ }
785
+
786
+ private setupInputFiltering() {
787
+ const inputFilter: KeyboardShortcutFilter = (event) => {
788
+ const target = event.target as HTMLElement;
789
+ const tagName = target?.tagName?.toLowerCase();
790
+ return !['input', 'textarea', 'select'].includes(tagName) && !target?.isContentEditable;
791
+ };
792
+
793
+ // Use named filter for toggling
794
+ this.keyboardService.addFilter('forms', inputFilter);
795
+ }
796
+
797
+ private save() {
798
+ console.log('Document saved!');
799
+ }
800
+ }
801
+ ```
802
+
803
+ #### Common Filter Patterns
804
+
805
+ **Ignore form elements:**
806
+ ```typescript
807
+ const formFilter: KeyboardShortcutFilter = (event) => {
808
+ const target = event.target as HTMLElement;
809
+ const tagName = target?.tagName?.toLowerCase();
810
+ return !['input', 'textarea', 'select'].includes(tagName) && !target?.isContentEditable;
811
+ };
812
+
813
+ keyboardService.addFilter('forms', formFilter);
814
+ ```
815
+
816
+ **Ignore elements with specific attributes:**
817
+ ```typescript
818
+ const attributeFilter: KeyboardShortcutFilter = (event) => {
819
+ const target = event.target as HTMLElement;
820
+ return !target?.hasAttribute('data-no-shortcuts');
821
+ };
822
+
823
+ keyboardService.addFilter('no-shortcuts-attr', attributeFilter);
824
+ ```
825
+
826
+ **Complex conditional filtering:**
827
+ ```typescript
828
+ const conditionalFilter: KeyboardShortcutFilter = (event) => {
829
+ const target = event.target as HTMLElement;
830
+
831
+ // Allow shortcuts in code editors (even though they're contentEditable)
832
+ if (target?.classList?.contains('code-editor')) {
833
+ return true;
834
+ }
835
+
836
+ // Block shortcuts in form elements
837
+ if (target?.tagName?.match(/INPUT|TEXTAREA|SELECT/i) || target?.isContentEditable) {
838
+ return false;
839
+ }
840
+
841
+ return true;
842
+ };
843
+
844
+ keyboardService.addFilter('conditional', conditionalFilter);
845
+ ```
846
+
847
+ **Remove filtering:**
848
+ ```typescript
849
+ // Remove a specific named filter
850
+ keyboardService.removeFilter('forms');
851
+ // Or remove all
852
+ keyboardService.clearFilters();
853
+ ```
854
+
855
+ #### Example: Modal Context Filtering
856
+
857
+ ```typescript
858
+ export class ModalComponent {
859
+ constructor() {
860
+ // When modal opens, only allow modal-specific shortcuts
861
+ this.keyboardService.addFilter('modal-scope', (event) => {
862
+ const target = event.target as HTMLElement;
863
+
864
+ // Only process events within the modal
865
+ return target?.closest('.modal') !== null;
866
+ });
867
+ }
868
+
869
+ onClose() {
870
+ // Restore normal filtering when modal closes
871
+ this.keyboardService.removeFilter('modal-scope');
872
+ }
873
+ }
874
+ ```
875
+
876
+ #### Performance tips
877
+
878
+ - Filters are evaluated once per keydown before scanning shortcuts. If any global filter returns false, ngx-keys exits early and clears pending sequences.
879
+ - Group-level filters are precomputed once per event; shortcuts in blocked groups are skipped without key matching.
880
+ - Keep filters cheap and synchronous. Prefer reading event.target properties (tagName, isContentEditable, classList) over layout-triggering queries.
881
+ - Use named filters to toggle contexts (modals, editors) without allocating new closures per interaction.
882
+ - Avoid complex DOM traversals inside filters; if needed, memoize simple queries or use attributes (e.g., data-no-shortcuts).
883
+
530
884
  ## Building
531
885
 
532
886
  To build the library:
@@ -555,4 +909,4 @@ Contributions are welcome! Please feel free to submit a Pull Request.
555
909
  2. Create your feature branch (`git checkout -b feature/amazing-feature`)
556
910
  3. Commit your changes (`git commit -m 'Add some amazing feature'`)
557
911
  4. Push to the branch (`git push origin feature/amazing-feature`)
558
- 5. Open a Pull Request
912
+ 5. Open a Pull Request