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.
package/README.md CHANGED
@@ -5,11 +5,14 @@ A lightweight, reactive Angular service for managing keyboard shortcuts with sig
5
5
  ## Features
6
6
 
7
7
  - **๐ŸŽฏ Service-Focused**: Clean, focused API without unnecessary UI components
8
+ - **๐Ÿ“ Declarative Directive**: Optional directive for template-based shortcut registration
8
9
  - **โšก Reactive Signals**: Track active/inactive shortcuts with Angular signals
9
10
  - **๐Ÿ”ง UI-Agnostic**: Build your own UI using the provided reactive signals
10
11
  - **๐ŸŒ Cross-Platform**: Automatic Mac/PC key display formatting
11
12
  - **๐Ÿ”„ Dynamic Management**: Add, remove, activate/deactivate shortcuts at runtime
12
13
  - **๐Ÿ“ Group Management**: Organize shortcuts into logical groups
14
+ - **โš™๏ธ Configurable**: Customize sequence timeout and other behavior via dependency injection
15
+ - **๐Ÿ” Smart Conflict Detection**: Register multiple shortcuts with same keys when not simultaneously active
13
16
  - **๐Ÿชถ Lightweight**: Zero dependencies, minimal bundle impact
14
17
 
15
18
  ## Installation
@@ -83,7 +86,110 @@ export class MyComponent {
83
86
  }
84
87
  ```
85
88
 
89
+ ### Using the Directive (Declarative Approach)
86
90
 
91
+ For a more declarative approach, use the `ngxKeys` directive directly on your elements:
92
+
93
+ ```typescript
94
+ import { Component } from '@angular/core';
95
+ import { KeyboardShortcutDirective } from 'ngx-keys';
96
+
97
+ @Component({
98
+ standalone: true,
99
+ imports: [KeyboardShortcutDirective],
100
+ template: `
101
+ <h3>My App</h3>
102
+ <p>Last action: {{ lastAction }}</p>
103
+
104
+ <!-- Directive automatically registers and unregisters shortcuts -->
105
+ <button
106
+ ngxKeys
107
+ keys="ctrl,s"
108
+ macKeys="cmd,s"
109
+ description="Save document"
110
+ (click)="save()">
111
+ Save
112
+ </button>
113
+
114
+ <button
115
+ ngxKeys
116
+ keys="ctrl,h"
117
+ macKeys="cmd,h"
118
+ description="Show help"
119
+ (click)="showHelp()">
120
+ Help
121
+ </button>
122
+
123
+ <!-- Multi-step shortcuts work too -->
124
+ <button
125
+ ngxKeys
126
+ [steps]="[['ctrl', 'k'], ['ctrl', 'p']]"
127
+ [macSteps]="[['cmd', 'k'], ['cmd', 'p']]"
128
+ description="Open command palette (Ctrl+K then Ctrl+P)"
129
+ (click)="openCommandPalette()">
130
+ Command Palette
131
+ </button>
132
+
133
+ <!-- Use custom action instead of click -->
134
+ <div
135
+ ngxKeys
136
+ keys="?"
137
+ description="Show help overlay"
138
+ [action]="showHelpOverlay">
139
+ Press ? for help
140
+ </div>
141
+ `
142
+ })
143
+ export class MyComponent {
144
+ protected lastAction = 'Try pressing Ctrl+S or Ctrl+H';
145
+
146
+ protected readonly showHelpOverlay = () => {
147
+ this.lastAction = 'Help overlay displayed!';
148
+ };
149
+
150
+ protected save() {
151
+ this.lastAction = 'Document saved!';
152
+ }
153
+
154
+ protected showHelp() {
155
+ this.lastAction = 'Help displayed!';
156
+ }
157
+
158
+ protected openCommandPalette() {
159
+ this.lastAction = 'Command palette opened!';
160
+ }
161
+ }
162
+ ```
163
+
164
+ > [!TIP]
165
+ > The directive automatically:
166
+ > - Registers shortcuts when initialized
167
+ > - Triggers click events on the host element (or executes a custom action)
168
+ > - Unregisters shortcuts when destroyed
169
+ > - Adds a `data-keyboard-shortcut` attribute for styling/testing
170
+
171
+ **Directive Inputs:**
172
+
173
+ | Input | Type | Description |
174
+ |-------|------|-------------|
175
+ | `keys` | `string` | Comma-separated keys for single-step shortcut (e.g., `"ctrl,s"`) |
176
+ | `macKeys` | `string` | Comma-separated keys for Mac (e.g., `"cmd,s"`) |
177
+ | `steps` | `string[][]` | Multi-step sequence (e.g., `[['ctrl', 'k'], ['ctrl', 'd']]`) |
178
+ | `macSteps` | `string[][]` | Multi-step sequence for Mac |
179
+ | `description` | `string` | Required. Description of what the shortcut does |
180
+ | `action` | `() => void` | Optional custom action. If not provided, triggers click on host element |
181
+ | `shortcutId` | `string` | Optional custom ID. If not provided, generates unique ID |
182
+
183
+ **Directive Outputs:**
184
+
185
+ | Output | Type | Description |
186
+ |--------|------|-------------|
187
+ | `triggered` | `void` | Emitted when the keyboard shortcut is triggered |
188
+
189
+ **When to use the directive vs. the service:**
190
+
191
+ - **Use the directive** when shortcuts are tied to specific UI elements and their lifecycle
192
+ - **Use the service** when you need programmatic control, dynamic shortcuts, or shortcuts not tied to elements
87
193
 
88
194
  ## Explore the Demo
89
195
 
@@ -94,6 +200,7 @@ Want to see ngx-keys in action? Check out our comprehensive [demo application](/
94
200
  | [**App Component**](/projects/demo/src/app/app.ts) | Global shortcuts | Single & group registration, cleanup patterns |
95
201
  | [**Home Component**](/projects/demo/src/app/home/home.component.ts) | Reactive UI | Real-time status display, toggle controls |
96
202
  | [**Feature Component**](/projects/demo/src/app/feature/feature.component.ts) | Route-specific shortcuts | Scoped shortcuts, lifecycle management |
203
+ | [**Directive Demo**](/projects/demo/src/app/directive-demo/directive-demo.component.ts) | Declarative shortcuts | Directive usage, automatic lifecycle management |
97
204
  | [**Customize Component**](/projects/demo/src/app/customize/customize.component.ts) | Dynamic shortcut recording | Real-time key capture, shortcut customization |
98
205
 
99
206
  **Run the demo:**
@@ -122,27 +229,107 @@ this.keyboardService.register({
122
229
  });
123
230
  ```
124
231
 
232
+ ### Smart Conflict Detection
233
+ > [!IMPORTANT]
234
+ Conflicts are only checked among **active** shortcuts, not all registered shortcuts.
235
+
236
+ ngx-keys allows registering multiple shortcuts with the same key combination, as long as they're not simultaneously active. This enables powerful patterns:
237
+
238
+ - **Context-specific shortcuts**: Same keys for different UI contexts
239
+ - **Alternative shortcuts**: Multiple ways to trigger the same action
240
+ - **Feature toggles**: Same keys for different modes
241
+
242
+ ```typescript
243
+ // Basic conflict handling
244
+ this.keyboardService.register(shortcut1); // Active by default
245
+ this.keyboardService.deactivate('shortcut1');
246
+ this.keyboardService.register(shortcut2); // Same keys, but shortcut1 is inactive โœ…
247
+
248
+ // This would fail - conflicts with active shortcut2
249
+ // this.keyboardService.activate('shortcut1'); // โŒ Throws error
250
+ ```
251
+
252
+ ### Group Management
253
+
254
+ Organize related shortcuts into groups for easier management:
255
+
256
+ ```typescript
257
+ const editorShortcuts = [
258
+ {
259
+ id: 'bold',
260
+ keys: ['ctrl', 'b'],
261
+ macKeys: ['meta', 'b'],
262
+ action: () => this.makeBold(),
263
+ description: 'Make text bold'
264
+ },
265
+ {
266
+ id: 'italic',
267
+ keys: ['ctrl', 'i'],
268
+ macKeys: ['meta', 'i'],
269
+ action: () => this.makeItalic(),
270
+ description: 'Make text italic'
271
+ }
272
+ ];
273
+
274
+ // Register all shortcuts in the group
275
+ this.keyboardService.registerGroup('editor', editorShortcuts);
276
+
277
+ // Control the entire group
278
+ this.keyboardService.deactivateGroup('editor'); // Disable all editor shortcuts
279
+ this.keyboardService.activateGroup('editor'); // Re-enable all editor shortcuts
280
+ ```
281
+
125
282
  ### Multi-step (sequence) shortcuts
126
283
 
127
284
  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.
128
285
 
129
- Example: register a sequence that requires `Ctrl+K` followed by `S`:
286
+ Example: register a sequence that requires `Ctrl+K` followed by `Ctrl+D`:
130
287
 
131
288
  ```typescript
132
289
  this.keyboardService.register({
133
- id: 'open-settings-seq',
134
- steps: [['ctrl', 'k'], ['s']],
135
- macSteps: [['meta', 'k'], ['s']],
136
- action: () => this.openSettings(),
137
- description: 'Open settings (Ctrl+K then S)'
290
+ id: 'format-document-seq',
291
+ steps: [['ctrl', 'k'], ['ctrl', 'd']],
292
+ macSteps: [['meta', 'k'], ['meta', 'd']],
293
+ action: () => this.formatDocument(),
294
+ description: 'Format document (Ctrl+K then Ctrl+D)'
138
295
  });
139
296
  ```
140
297
 
141
- Important behavior notes:
298
+ **Configuring Sequence Timeout**
299
+
300
+ By default, multi-step shortcuts have **no timeout** - users can take as long as they need between steps. You can configure a timeout globally using a provider function:
301
+
302
+ ```typescript
303
+ import { ApplicationConfig } from '@angular/core';
304
+ import { provideKeyboardShortcutsConfig } from 'ngx-keys';
305
+
306
+ export const appConfig: ApplicationConfig = {
307
+ providers: [
308
+ provideKeyboardShortcutsConfig({ sequenceTimeoutMs: 2000 }) // 2 seconds
309
+ ]
310
+ };
311
+ ```
312
+
313
+ Alternatively, you can use the injection token directly:
314
+
315
+ ```typescript
316
+ import { ApplicationConfig } from '@angular/core';
317
+ import { KEYBOARD_SHORTCUTS_CONFIG } from 'ngx-keys';
318
+
319
+ export const appConfig: ApplicationConfig = {
320
+ providers: [
321
+ {
322
+ provide: KEYBOARD_SHORTCUTS_CONFIG,
323
+ useValue: { sequenceTimeoutMs: 2000 } // 2 seconds
324
+ }
325
+ ]
326
+ };
327
+ ```
328
+
329
+ **Important behavior notes**
142
330
 
143
- - 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
- - Steps are order-sensitive. `steps: [['ctrl','k'], ['s']]` is different from `steps: [['s'], ['ctrl','k']]`.
145
- - Existing single-step `keys` / `macKeys` remain supported and continue to work as before.
331
+ - **Sequence timeout**: Steps must be entered within the configured timeout (default 2000ms) or the sequence is cleared.
332
+ - **Order-sensitive**: Steps are order-sensitive. `steps: [['ctrl','k'], ['s']]` is different from `steps: [['s'], ['ctrl','k']]`.
146
333
 
147
334
 
148
335
  Use the `activate()` and `deactivate()` methods for dynamic control after registration:
@@ -155,6 +342,201 @@ this.keyboardService.deactivate('save');
155
342
  this.keyboardService.activate('save');
156
343
  ```
157
344
 
345
+ ## Advanced Usage
346
+
347
+ ### Type-Safe Action Functions
348
+
349
+ For better type safety and code reusability, use the exported `Action` type when defining action functions:
350
+
351
+ ```typescript
352
+ import { Component, inject } from '@angular/core';
353
+ import { KeyboardShortcuts, Action } from 'ngx-keys';
354
+
355
+ export class MyComponent {
356
+ private readonly keyboardService = inject(KeyboardShortcuts);
357
+
358
+ // Define reusable, type-safe action functions
359
+ private readonly saveAction: Action = () => {
360
+ console.log('Saving document...');
361
+ this.performSave();
362
+ };
363
+
364
+ private readonly undoAction: Action = () => {
365
+ console.log('Undoing...');
366
+ this.performUndo();
367
+ };
368
+
369
+ constructor() {
370
+ // Use the typed actions in shortcuts
371
+ this.keyboardService.register({
372
+ id: 'save',
373
+ keys: ['ctrl', 's'],
374
+ macKeys: ['meta', 's'],
375
+ action: this.saveAction,
376
+ description: 'Save document'
377
+ });
378
+
379
+ this.keyboardService.register({
380
+ id: 'undo',
381
+ keys: ['ctrl', 'z'],
382
+ macKeys: ['meta', 'z'],
383
+ action: this.undoAction,
384
+ description: 'Undo last action'
385
+ });
386
+ }
387
+
388
+ private performSave() { /* implementation */ }
389
+ private performUndo() { /* implementation */ }
390
+ }
391
+ ```
392
+
393
+ ### Context-Specific Shortcuts
394
+
395
+ Register different actions for the same keys in different UI contexts:
396
+
397
+ ```typescript
398
+ // Modal context
399
+ this.keyboardService.register({
400
+ id: 'modal-escape',
401
+ keys: ['escape'],
402
+ action: () => this.closeModal(),
403
+ description: 'Close modal'
404
+ });
405
+
406
+ // Initially deactivate since modal isn't shown
407
+ this.keyboardService.deactivate('modal-escape');
408
+
409
+ // Editor context
410
+ this.keyboardService.register({
411
+ id: 'editor-escape',
412
+ keys: ['escape'], // Same key, different context
413
+ action: () => this.exitEditMode(),
414
+ description: 'Exit edit mode'
415
+ });
416
+
417
+ // Switch contexts dynamically
418
+ showModal() {
419
+ this.keyboardService.deactivate('editor-escape');
420
+ this.keyboardService.activate('modal-escape');
421
+ }
422
+
423
+ hideModal() {
424
+ this.keyboardService.deactivate('modal-escape');
425
+ this.keyboardService.activate('editor-escape');
426
+ }
427
+ ```
428
+
429
+ ### Alternative Shortcuts
430
+
431
+ Provide multiple ways to trigger the same functionality:
432
+
433
+ ```typescript
434
+ // Primary shortcut
435
+ this.keyboardService.register({
436
+ id: 'help-f1',
437
+ keys: ['f1'],
438
+ action: () => this.showHelp(),
439
+ description: 'Show help (F1)'
440
+ });
441
+
442
+ // Alternative shortcut - different keys, same action
443
+ this.keyboardService.register({
444
+ id: 'help-ctrl-h',
445
+ keys: ['ctrl', 'h'],
446
+ action: () => this.showHelp(), // Same action
447
+ description: 'Show help (Ctrl+H)'
448
+ });
449
+
450
+ // Both are active simultaneously since they don't conflict
451
+ ```
452
+
453
+ ### Feature Toggles
454
+
455
+ Switch between different modes that use the same keys:
456
+
457
+ ```typescript
458
+ // Design mode
459
+ this.keyboardService.register({
460
+ id: 'design-mode-space',
461
+ keys: ['space'],
462
+ action: () => this.toggleDesignElement(),
463
+ description: 'Toggle design element'
464
+ });
465
+
466
+ // Play mode (same key, different action)
467
+ this.keyboardService.register({
468
+ id: 'play-mode-space',
469
+ keys: ['space'],
470
+ action: () => this.pausePlayback(),
471
+ description: 'Pause/resume playback'
472
+ });
473
+
474
+ // Initially deactivate play mode
475
+ this.keyboardService.deactivate('play-mode-space');
476
+
477
+ // Switch modes
478
+ switchToPlayMode() {
479
+ this.keyboardService.deactivate('design-mode-space');
480
+ this.keyboardService.activate('play-mode-space');
481
+ }
482
+
483
+ switchToDesignMode() {
484
+ this.keyboardService.deactivate('play-mode-space');
485
+ this.keyboardService.activate('design-mode-space');
486
+ }
487
+ ```
488
+
489
+ ### Advanced Group Patterns
490
+
491
+ Use groups for complex activation/deactivation scenarios:
492
+
493
+ ```typescript
494
+ // Create context-specific groups
495
+ const modalShortcuts = [
496
+ { id: 'modal-close', keys: ['escape'], action: () => this.closeModal(), description: 'Close modal' },
497
+ { id: 'modal-confirm', keys: ['enter'], action: () => this.confirmModal(), description: 'Confirm' }
498
+ ];
499
+
500
+ const editorShortcuts = [
501
+ { id: 'editor-save', keys: ['ctrl', 's'], action: () => this.save(), description: 'Save' },
502
+ { id: 'editor-undo', keys: ['ctrl', 'z'], action: () => this.undo(), description: 'Undo' }
503
+ ];
504
+
505
+ // Register both groups
506
+ this.keyboardService.registerGroup('modal', modalShortcuts);
507
+ this.keyboardService.registerGroup('editor', editorShortcuts);
508
+
509
+ // Initially only editor is active
510
+ this.keyboardService.deactivateGroup('modal');
511
+
512
+ // Switch contexts
513
+ showModal() {
514
+ this.keyboardService.deactivateGroup('editor');
515
+ this.keyboardService.activateGroup('modal');
516
+ }
517
+
518
+ hideModal() {
519
+ this.keyboardService.deactivateGroup('modal');
520
+ this.keyboardService.activateGroup('editor');
521
+ }
522
+ ```
523
+
524
+ ### Conflict Detection Rules
525
+
526
+ - **Registration**: Only checks conflicts with currently **active** shortcuts
527
+ - **Activation**: Throws error if activating would conflict with other active shortcuts
528
+ - **Groups**: Same rules apply - groups can contain conflicting shortcuts as long as they're not simultaneously active
529
+
530
+ ```typescript
531
+ // โœ… This works - shortcuts with same keys but only one active at a time
532
+ this.keyboardService.register(shortcut1); // Active by default
533
+ this.keyboardService.deactivate('shortcut1');
534
+ this.keyboardService.register(shortcut2); // Same keys, but shortcut1 is inactive
535
+
536
+ // โŒ This fails - trying to activate would create conflict
537
+ this.keyboardService.activate('shortcut1'); // Throws error - conflicts with active shortcut2
538
+ ```
539
+
158
540
  ## API Reference
159
541
 
160
542
  ### KeyboardShortcuts Service
@@ -162,17 +544,43 @@ this.keyboardService.activate('save');
162
544
  #### Methods
163
545
 
164
546
  **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*
547
+ > [!TIP]
548
+ Conflicts are only checked among **active** shortcuts, not all registered shortcuts.
549
+
550
+ - `register(shortcut: KeyboardShortcut)` - Register and automatically activate a single shortcut *Throws error on conflicts with active shortcuts only*
551
+ - `registerGroup(groupId: string, shortcuts: KeyboardShortcut[])` - Register and automatically activate a group of shortcuts *Throws error on conflicts with active shortcuts only*
552
+ - `registerMany(shortcuts: KeyboardShortcut[])` - Register multiple shortcuts in a single batch update
553
+
554
+ **Unregistration Methods:**
555
+ > [!NOTE]
556
+ > `unregister()` automatically removes shortcuts from all groups they belong to.
557
+
558
+ - `unregister(shortcutId: string)` - Remove a shortcut and its group associations *Throws error if not found*
559
+ - `unregisterGroup(groupId: string)` - Remove a group and all its shortcuts *Throws error if not found*
560
+ - `unregisterMany(ids: string[])` - Unregister multiple shortcuts in a single batch update
561
+ - `unregisterGroups(ids: string[])` - Unregister multiple groups in a single batch update
562
+ - `clearAll()` - Remove all shortcuts, groups, and filters (nuclear reset)
167
563
 
168
- **Management Methods:**
169
- - `unregister(shortcutId: string)` - Remove a shortcut *Throws error if not found*
170
- - `unregisterGroup(groupId: string)` - Remove a group *Throws error if not found*
171
- - `activate(shortcutId: string)` - Activate a shortcut *Throws error if not registered*
564
+ **Activation Methods:**
565
+ - `activate(shortcutId: string)` - Activate a shortcut *Throws error if not registered or would create conflicts*
172
566
  - `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*
567
+ - `activateGroup(groupId: string)` - Activate all shortcuts in a group *Throws error if not found or would create conflicts*
174
568
  - `deactivateGroup(groupId: string)` - Deactivate all shortcuts in a group *Throws error if not found*
175
569
 
570
+ **Filter Methods:**
571
+ - `addFilter(name: string, filter: Function)` - Add a named global filter
572
+ - `removeFilter(name: string)` - Remove a named global filter
573
+ - `clearFilters()` - Remove all global filters
574
+ - `hasFilter(name: string): boolean` - Check if a filter exists
575
+ - `getFilter(name: string)` - Get a filter function by name
576
+ - `getFilterNames(): string[]` - Get all filter names
577
+ - `removeGroupFilter(groupId: string)` - Remove filter from a group
578
+ - `removeShortcutFilter(shortcutId: string)` - Remove filter from a shortcut
579
+ - `clearAllGroupFilters()` - Remove all group filters
580
+ - `clearAllShortcutFilters()` - Remove all shortcut filters
581
+ - `hasGroupFilter(groupId: string): boolean` - Check if group has a filter
582
+ - `hasShortcutFilter(shortcutId: string): boolean` - Check if shortcut has a filter
583
+
176
584
  **Query Methods:**
177
585
  - `isActive(shortcutId: string): boolean` - Check if a shortcut is active
178
586
  - `isRegistered(shortcutId: string): boolean` - Check if a shortcut is registered
@@ -180,6 +588,7 @@ this.keyboardService.activate('save');
180
588
  - `isGroupRegistered(groupId: string): boolean` - Check if a group is registered
181
589
  - `getShortcuts(): ReadonlyMap<string, KeyboardShortcut>` - Get all registered shortcuts
182
590
  - `getGroups(): ReadonlyMap<string, KeyboardShortcutGroup>` - Get all registered groups
591
+ - `getGroupShortcuts(groupId: string): KeyboardShortcut[]` - Get all shortcuts in a specific group
183
592
 
184
593
  **Utility Methods:**
185
594
  - `formatShortcutForUI(shortcut: KeyboardShortcut): KeyboardShortcutUI` - Format a shortcut for display
@@ -222,6 +631,9 @@ interface KeyboardShortcutUI {
222
631
  ### KeyboardShortcut Interface
223
632
 
224
633
  ```typescript
634
+ // Type for shortcut action functions
635
+ type Action = () => void;
636
+
225
637
  interface KeyboardShortcut {
226
638
  id: string; // Unique identifier
227
639
  // Single-step combinations (existing API)
@@ -232,7 +644,7 @@ interface KeyboardShortcut {
232
644
  // Each step is an array of keys pressed together. Example: steps: [['ctrl','k'], ['s']]
233
645
  steps?: string[][];
234
646
  macSteps?: string[][];
235
- action: () => void; // Function to execute
647
+ action: Action; // Function to execute
236
648
  description: string; // Human-readable description
237
649
  }
238
650
  ```
@@ -247,6 +659,108 @@ interface KeyboardShortcutGroup {
247
659
  }
248
660
  ```
249
661
 
662
+ ### KeyboardShortcutDirective
663
+
664
+ A declarative directive for registering keyboard shortcuts directly in templates.
665
+
666
+ #### Selector
667
+ ```typescript
668
+ [ngxKeys]
669
+ ```
670
+
671
+ #### Inputs
672
+
673
+ | Input | Type | Required | Description |
674
+ |-------|------|----------|-------------|
675
+ | `keys` | `string` | No* | Comma-separated keys for single-step shortcut (e.g., `"ctrl,s"`) |
676
+ | `macKeys` | `string` | No | Comma-separated keys for Mac (e.g., `"cmd,s"`) |
677
+ | `steps` | `string[][]` | No* | Multi-step sequence. Each inner array is one step (e.g., `[['ctrl', 'k'], ['ctrl', 'd']]`) |
678
+ | `macSteps` | `string[][]` | No | Multi-step sequence for Mac |
679
+ | `description` | `string` | **Yes** | Human-readable description of what the shortcut does |
680
+ | `action` | `() => void` | No | Custom action to execute. If not provided, triggers click on host element |
681
+ | `shortcutId` | `string` | No | Custom ID for the shortcut. If not provided, generates a unique ID |
682
+
683
+ \* Either `keys`/`macKeys` OR `steps`/`macSteps` must be provided (not both)
684
+
685
+ #### Outputs
686
+
687
+ | Output | Type | Description |
688
+ |--------|------|-------------|
689
+ | `triggered` | `void` | Emitted when the keyboard shortcut is triggered (in addition to the action) |
690
+
691
+ #### Host Bindings
692
+
693
+ The directive adds the following to the host element:
694
+ - `data-keyboard-shortcut` attribute with the shortcut ID (useful for styling or testing)
695
+
696
+ #### Behavior
697
+
698
+ - **Registration**: Automatically registers the shortcut when the directive initializes
699
+ - **Default Action**: Triggers a click event on the host element (unless custom `action` is provided)
700
+ - **Cleanup**: Automatically unregisters the shortcut when the directive is destroyed
701
+ - **Error Handling**: Throws errors for invalid input combinations or registration failures
702
+
703
+ #### Usage Examples
704
+
705
+ **Basic button with click trigger:**
706
+ ```html
707
+ <button
708
+ ngxKeys
709
+ keys="ctrl,s"
710
+ macKeys="cmd,s"
711
+ description="Save document"
712
+ (click)="save()">
713
+ Save
714
+ </button>
715
+ ```
716
+
717
+ **Multi-step shortcut:**
718
+ ```html
719
+ <button
720
+ ngxKeys
721
+ [steps]="[['ctrl', 'k'], ['ctrl', 'd']]"
722
+ [macSteps]="[['cmd', 'k'], ['cmd', 'd']]"
723
+ description="Format document (Ctrl+K then Ctrl+D)"
724
+ (click)="format()">
725
+ Format
726
+ </button>
727
+ ```
728
+
729
+ **Custom action on non-interactive element:**
730
+ ```html
731
+ <div
732
+ ngxKeys
733
+ keys="?"
734
+ description="Show help"
735
+ [action]="showHelp">
736
+ Press ? for help
737
+ </div>
738
+ ```
739
+
740
+ **Listen to triggered event:**
741
+ ```html
742
+ <button
743
+ ngxKeys
744
+ keys="ctrl,s"
745
+ description="Save document"
746
+ (triggered)="onShortcutTriggered()"
747
+ (click)="save()">
748
+ Save
749
+ </button>
750
+ ```
751
+
752
+ **Custom shortcut ID:**
753
+ ```html
754
+ <button
755
+ ngxKeys
756
+ keys="ctrl,s"
757
+ description="Save document"
758
+ shortcutId="my-custom-save-shortcut"
759
+ (click)="save()">
760
+ Save
761
+ </button>
762
+ ```
763
+
250
764
  ## Key Mapping Reference
251
765
 
252
766
  ### Modifier Keys
@@ -483,6 +997,130 @@ export class BatchUpdateComponent {
483
997
  }
484
998
  ```
485
999
 
1000
+ ### Bulk Registration and Unregistration
1001
+
1002
+ Register or unregister multiple shortcuts efficiently:
1003
+
1004
+ ```typescript
1005
+ import { Component, inject } from '@angular/core';
1006
+ import { KeyboardShortcuts } from 'ngx-keys';
1007
+
1008
+ export class MyComponent {
1009
+ private readonly keyboardService = inject(KeyboardShortcuts);
1010
+
1011
+ setupToolbarShortcuts() {
1012
+ // Register multiple shortcuts at once
1013
+ this.keyboardService.registerMany([
1014
+ {
1015
+ id: 'save',
1016
+ keys: ['ctrl', 's'],
1017
+ macKeys: ['meta', 's'],
1018
+ action: () => this.save(),
1019
+ description: 'Save document'
1020
+ },
1021
+ {
1022
+ id: 'save-as',
1023
+ keys: ['ctrl', 'shift', 's'],
1024
+ macKeys: ['meta', 'shift', 's'],
1025
+ action: () => this.saveAs(),
1026
+ description: 'Save as...'
1027
+ },
1028
+ {
1029
+ id: 'print',
1030
+ keys: ['ctrl', 'p'],
1031
+ macKeys: ['meta', 'p'],
1032
+ action: () => this.print(),
1033
+ description: 'Print document'
1034
+ }
1035
+ ]);
1036
+ }
1037
+
1038
+ cleanup() {
1039
+ // Unregister multiple shortcuts at once
1040
+ this.keyboardService.unregisterMany(['save', 'save-as', 'print']);
1041
+
1042
+ // Or unregister entire groups
1043
+ this.keyboardService.unregisterGroups(['toolbar', 'menu']);
1044
+
1045
+ // Or clear everything
1046
+ this.keyboardService.clearAll();
1047
+ }
1048
+
1049
+ private save() { /* implementation */ }
1050
+ private saveAs() { /* implementation */ }
1051
+ private print() { /* implementation */ }
1052
+ }
1053
+ ```
1054
+
1055
+ ### Managing Group Shortcuts
1056
+
1057
+ Remove individual shortcuts from groups and query group contents:
1058
+
1059
+ ```typescript
1060
+ import { Component, inject } from '@angular/core';
1061
+ import { KeyboardShortcuts } from 'ngx-keys';
1062
+
1063
+ export class MyComponent {
1064
+ private readonly keyboardService = inject(KeyboardShortcuts);
1065
+
1066
+ manageEditorGroup() {
1067
+ // Check if a shortcut is registered
1068
+ if (this.keyboardService.isRegistered('bold')) {
1069
+ // Remove a shortcut - automatically removes it from all groups
1070
+ this.keyboardService.unregister('bold');
1071
+ }
1072
+
1073
+ // Remove multiple shortcuts (automatically removes from groups)
1074
+ this.keyboardService.unregisterMany(['bold', 'italic', 'underline']);
1075
+
1076
+ // Get all shortcuts in a group
1077
+ const editorShortcuts = this.keyboardService.getGroupShortcuts('editor');
1078
+ console.log('Remaining editor shortcuts:', editorShortcuts);
1079
+
1080
+ // Or remove the entire group and all its shortcuts
1081
+ this.keyboardService.unregisterGroup('editor');
1082
+ }
1083
+ }
1084
+ ```
1085
+
1086
+ ### Filter Management
1087
+
1088
+ Remove filters when no longer needed:
1089
+
1090
+ ```typescript
1091
+ import { Component, inject } from '@angular/core';
1092
+ import { KeyboardShortcuts } from 'ngx-keys';
1093
+
1094
+ export class MyComponent {
1095
+ private readonly keyboardService = inject(KeyboardShortcuts);
1096
+
1097
+ setupModalFilters() {
1098
+ // Add filters for modal context
1099
+ this.keyboardService.addGroupFilter('navigation', () => this.isModalOpen);
1100
+ this.keyboardService.addShortcutFilter('global-search', () => this.isModalOpen);
1101
+ }
1102
+
1103
+ cleanupModalFilters() {
1104
+ // Check if filters exist
1105
+ if (this.keyboardService.hasGroupFilter('navigation')) {
1106
+ // Remove specific group filter
1107
+ this.keyboardService.removeGroupFilter('navigation');
1108
+ }
1109
+
1110
+ if (this.keyboardService.hasShortcutFilter('global-search')) {
1111
+ // Remove specific shortcut filter
1112
+ this.keyboardService.removeShortcutFilter('global-search');
1113
+ }
1114
+
1115
+ // Or clear all filters at once
1116
+ this.keyboardService.clearAllGroupFilters();
1117
+ this.keyboardService.clearAllShortcutFilters();
1118
+ }
1119
+
1120
+ private isModalOpen = false;
1121
+ }
1122
+ ```
1123
+
486
1124
  ### Checking Status
487
1125
 
488
1126
  > [!NOTE]
@@ -527,6 +1165,157 @@ this.keyboardService.register({
527
1165
  });
528
1166
  ```
529
1167
 
1168
+ ### Event Filtering
1169
+
1170
+ 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.
1171
+
1172
+ > [!NOTE]
1173
+ > **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!
1174
+
1175
+ #### Named filters (recommended)
1176
+
1177
+ 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.
1178
+
1179
+ ```typescript
1180
+ // Add named filters
1181
+ keyboardService.addFilter('forms', (event) => {
1182
+ const t = event.target as HTMLElement | null;
1183
+ const tag = t?.tagName?.toLowerCase();
1184
+ return !(['input', 'textarea', 'select'].includes(tag ?? '')) && !t?.isContentEditable;
1185
+ });
1186
+
1187
+ keyboardService.addFilter('modal-scope', (event) => {
1188
+ const t = event.target as HTMLElement | null;
1189
+ return !!t?.closest('.modal');
1190
+ });
1191
+
1192
+ // Remove/toggle when context changes
1193
+ keyboardService.removeFilter('modal-scope');
1194
+
1195
+ // Inspect and manage
1196
+ keyboardService.getFilterNames(); // ['forms']
1197
+ keyboardService.clearFilters(); // remove all
1198
+ ```
1199
+
1200
+ ```typescript
1201
+ import { Component, inject } from '@angular/core';
1202
+ import { KeyboardShortcuts, KeyboardShortcutFilter } from 'ngx-keys';
1203
+
1204
+ export class FilterExampleComponent {
1205
+ private readonly keyboardService = inject(KeyboardShortcuts);
1206
+
1207
+ constructor() {
1208
+ // Set up shortcuts
1209
+ this.keyboardService.register({
1210
+ id: 'save',
1211
+ keys: ['ctrl', 's'],
1212
+ macKeys: ['meta', 's'],
1213
+ action: () => this.save(),
1214
+ description: 'Save document'
1215
+ });
1216
+
1217
+ // Configure filtering to ignore form elements
1218
+ this.setupInputFiltering();
1219
+ }
1220
+
1221
+ private setupInputFiltering() {
1222
+ const inputFilter: KeyboardShortcutFilter = (event) => {
1223
+ const target = event.target as HTMLElement;
1224
+ const tagName = target?.tagName?.toLowerCase();
1225
+ return !['input', 'textarea', 'select'].includes(tagName) && !target?.isContentEditable;
1226
+ };
1227
+
1228
+ // Use named filter for toggling
1229
+ this.keyboardService.addFilter('forms', inputFilter);
1230
+ }
1231
+
1232
+ private save() {
1233
+ console.log('Document saved!');
1234
+ }
1235
+ }
1236
+ ```
1237
+
1238
+ #### Common Filter Patterns
1239
+
1240
+ **Ignore form elements:**
1241
+ ```typescript
1242
+ const formFilter: KeyboardShortcutFilter = (event) => {
1243
+ const target = event.target as HTMLElement;
1244
+ const tagName = target?.tagName?.toLowerCase();
1245
+ return !['input', 'textarea', 'select'].includes(tagName) && !target?.isContentEditable;
1246
+ };
1247
+
1248
+ keyboardService.addFilter('forms', formFilter);
1249
+ ```
1250
+
1251
+ **Ignore elements with specific attributes:**
1252
+ ```typescript
1253
+ const attributeFilter: KeyboardShortcutFilter = (event) => {
1254
+ const target = event.target as HTMLElement;
1255
+ return !target?.hasAttribute('data-no-shortcuts');
1256
+ };
1257
+
1258
+ keyboardService.addFilter('no-shortcuts-attr', attributeFilter);
1259
+ ```
1260
+
1261
+ **Complex conditional filtering:**
1262
+ ```typescript
1263
+ const conditionalFilter: KeyboardShortcutFilter = (event) => {
1264
+ const target = event.target as HTMLElement;
1265
+
1266
+ // Allow shortcuts in code editors (even though they're contentEditable)
1267
+ if (target?.classList?.contains('code-editor')) {
1268
+ return true;
1269
+ }
1270
+
1271
+ // Block shortcuts in form elements
1272
+ if (target?.tagName?.match(/INPUT|TEXTAREA|SELECT/i) || target?.isContentEditable) {
1273
+ return false;
1274
+ }
1275
+
1276
+ return true;
1277
+ };
1278
+
1279
+ keyboardService.addFilter('conditional', conditionalFilter);
1280
+ ```
1281
+
1282
+ **Remove filtering:**
1283
+ ```typescript
1284
+ // Remove a specific named filter
1285
+ keyboardService.removeFilter('forms');
1286
+ // Or remove all
1287
+ keyboardService.clearFilters();
1288
+ ```
1289
+
1290
+ #### Example: Modal Context Filtering
1291
+
1292
+ ```typescript
1293
+ export class ModalComponent {
1294
+ constructor() {
1295
+ // When modal opens, only allow modal-specific shortcuts
1296
+ this.keyboardService.addFilter('modal-scope', (event) => {
1297
+ const target = event.target as HTMLElement;
1298
+
1299
+ // Only process events within the modal
1300
+ return target?.closest('.modal') !== null;
1301
+ });
1302
+ }
1303
+
1304
+ onClose() {
1305
+ // Restore normal filtering when modal closes
1306
+ this.keyboardService.removeFilter('modal-scope');
1307
+ }
1308
+ }
1309
+ ```
1310
+
1311
+ #### Performance tips
1312
+
1313
+ - Filters are evaluated once per keydown before scanning shortcuts. If any global filter returns false, ngx-keys exits early and clears pending sequences.
1314
+ - Group-level filters are precomputed once per event; shortcuts in blocked groups are skipped without key matching.
1315
+ - Keep filters cheap and synchronous. Prefer reading event.target properties (tagName, isContentEditable, classList) over layout-triggering queries.
1316
+ - Use named filters to toggle contexts (modals, editors) without allocating new closures per interaction.
1317
+ - Avoid complex DOM traversals inside filters; if needed, memoize simple queries or use attributes (e.g., data-no-shortcuts).
1318
+
530
1319
  ## Building
531
1320
 
532
1321
  To build the library:
@@ -555,4 +1344,4 @@ Contributions are welcome! Please feel free to submit a Pull Request.
555
1344
  2. Create your feature branch (`git checkout -b feature/amazing-feature`)
556
1345
  3. Commit your changes (`git commit -m 'Add some amazing feature'`)
557
1346
  4. Push to the branch (`git push origin feature/amazing-feature`)
558
- 5. Open a Pull Request
1347
+ 5. Open a Pull Request