@ngrithms/hotkeys 0.1.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 ADDED
@@ -0,0 +1,390 @@
1
+ # @ngrithms/hotkeys
2
+
3
+ [![npm](https://img.shields.io/npm/v/@ngrithms/hotkeys.svg)](https://www.npmjs.com/package/@ngrithms/hotkeys)
4
+ [![CI](https://github.com/aboudbadra/ngrithms-hotkeys/actions/workflows/ci.yml/badge.svg)](https://github.com/aboudbadra/ngrithms-hotkeys/actions/workflows/ci.yml)
5
+ [![Angular compat](https://github.com/aboudbadra/ngrithms-hotkeys/actions/workflows/compat-matrix.yml/badge.svg)](https://github.com/aboudbadra/ngrithms-hotkeys/actions/workflows/compat-matrix.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ Modern keyboard-shortcut library for Angular 17+. Standalone, signals-first, SSR-safe, zero runtime dependencies. Register shortcuts with a directive that auto-cleans on destroy, scope them to modals or palettes, and define multi-step sequences like `g i` (Gmail / GitHub / Linear-style).
9
+
10
+ ```html
11
+ <button (ngrHotkey)="save()" hotkey="mod+s" hotkeyDescription="Save">Save</button>
12
+ ```
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @ngrithms/hotkeys
18
+ ```
19
+
20
+ Peer-compatible with Angular `>=17.2.0 <22.0.0`. No runtime dependencies.
21
+
22
+ ## Quick start
23
+
24
+ ```ts
25
+ // app.config.ts
26
+ import { ApplicationConfig } from '@angular/core';
27
+ import { provideHotkeys } from '@ngrithms/hotkeys';
28
+
29
+ export const appConfig: ApplicationConfig = {
30
+ providers: [provideHotkeys()],
31
+ };
32
+ ```
33
+
34
+ ```ts
35
+ // any component
36
+ import { Component } from '@angular/core';
37
+ import { HotkeyDirective } from '@ngrithms/hotkeys';
38
+
39
+ @Component({
40
+ standalone: true,
41
+ imports: [HotkeyDirective],
42
+ template: `
43
+ <button (ngrHotkey)="save()" hotkey="mod+s" hotkeyDescription="Save">Save</button>
44
+ <button (ngrHotkey)="open()" hotkey="mod+k" hotkeyDescription="Command palette">Open palette</button>
45
+ `,
46
+ })
47
+ export class Toolbar {
48
+ save() { /* ... */ }
49
+ open() { /* ... */ }
50
+ }
51
+ ```
52
+
53
+ That's it. The directive registers on init and unregisters on destroy. No `ngOnDestroy`, no manual cleanup.
54
+
55
+ ## The directive (the recommended way)
56
+
57
+ `(ngrHotkey)="..."` is an Angular `Output` — it fires when the combo matches.
58
+
59
+ | Input | Type | What it does |
60
+ |---|---|---|
61
+ | `hotkey` | `string \| string[]` *(required)* | The combo, or an array of combos that all map to this handler. Examples: `'mod+s'`, `'g i'`, `['mod+s', 'mod+shift+s']`. |
62
+ | `hotkeyDescription` | `string` | Human-readable label for the cheatsheet. Without it, the binding is hidden from the cheatsheet. |
63
+ | `hotkeyScope` | `string` | Logical group. Defaults to the nearest `[ngrHotkeyScope]`, or `'global'`. |
64
+ | `hotkeyCategory` | `string` | Optional sub-grouping label for the cheatsheet (e.g. `"Navigation"`, `"Editing"`). Bindings with the same category appear under a shared subheader within their scope. |
65
+ | `hotkeyAllowInInputs` | `boolean` | Let this binding fire when focus is in an input/textarea/contenteditable. |
66
+ | `hotkeyPreventDefault` | `boolean` | Override the global `preventDefault` for this one binding. |
67
+
68
+ ## The combo grammar in plain English
69
+
70
+ A combo is a chain of optional modifiers and exactly one key, joined with `+`.
71
+
72
+ ```
73
+ mod+s ⌘+S on Mac, Ctrl+S elsewhere
74
+ mod+shift+k ⌘+Shift+K / Ctrl+Shift+K
75
+ escape the Escape key on its own
76
+ / slash (Shift+/ on most layouts — works because matching is on the produced key)
77
+ ```
78
+
79
+ A **sequence** is two or more combos separated by a space. The user must press each combo within `sequenceTimeoutMs` of the last.
80
+
81
+ ```
82
+ g i press G, then I within 1 second
83
+ g d press G, then D within 1 second
84
+ ```
85
+
86
+ ### The `mod` keyword
87
+
88
+ Every modern keyboard-driven app uses one logical "primary modifier" that maps to Cmd on Mac and Ctrl elsewhere — VS Code, Linear, Notion, GitHub, Slack all do this. `mod` is that modifier. Use `meta` or `ctrl` explicitly if you really mean one platform only.
89
+
90
+ ### Multi-combo per binding
91
+
92
+ Sometimes you want two combos to do the same thing — `mod+s` *and* `mod+shift+s` both save, `mod+/` *and* `?` both open help. Pass an array and skip the duplicate registration:
93
+
94
+ ```html
95
+ <button (ngrHotkey)="save()" [hotkey]="['mod+s', 'mod+shift+s']" hotkeyDescription="Save">Save</button>
96
+ ```
97
+
98
+ The handler fires when **any** of the combos matches. In the cheatsheet, the first combo renders prominently and the rest appear as muted "or" badges next to it. The `HotkeyTriggerEvent.combo` field reports which specific combo actually fired — useful if you want to analytics on it.
99
+
100
+ ### A note on non-US keyboard layouts
101
+
102
+ Matching uses `KeyboardEvent.key` — the character the keyboard *produces*, not the physical button. That has two practical implications:
103
+
104
+ - **Letter bindings (`mod+k`, `mod+s`) work everywhere.** Every Latin layout produces the same lowercase letter for the same physical key, so `KeyboardEvent.key` agrees across QWERTY, QWERTZ, AZERTY, Dvorak, Colemak.
105
+ - **Symbol bindings (`mod+/`, `shift+/`) may not fire on non-US layouts.** On AZERTY, for example, `/` is produced by `Shift+:`, so an event with `key === '/'` only happens after the user presses `Shift+:` — not the bare key you might assume. If you must support symbol shortcuts globally, prefer to ship one binding per common layout (`mod+/` and `mod+shift+7`), or wait for the layout-independent matching mode planned for a future release.
106
+
107
+ There's a related quirk on macOS: `Option+letter` produces a special character (`Option+E` → `´`), so `alt+e` doesn't currently fire on Mac. Use `mod+e` instead, or a letter that doesn't have a dead-key overlay on the layouts you care about.
108
+
109
+ ### Reserved browser / OS shortcuts (can't be intercepted)
110
+
111
+ Some shortcuts are reserved by the browser or operating system and never reach the page — no JavaScript library, this one included, can override them. The most common ones to avoid:
112
+
113
+ - `Cmd/Ctrl + N` — new window
114
+ - `Cmd/Ctrl + T` — new tab
115
+ - `Cmd/Ctrl + W` — close tab
116
+ - `Cmd/Ctrl + Q` — quit
117
+ - `Cmd/Ctrl + Shift + N` — incognito window
118
+ - `Cmd/Ctrl + Shift + T` — reopen closed tab
119
+ - `Cmd/Ctrl + L` — focus address bar
120
+ - `Cmd/Ctrl + R` — reload
121
+ - `Cmd + M` (Mac) — minimize
122
+ - `F11` — fullscreen
123
+
124
+ `Cmd/Ctrl + S`, `Cmd/Ctrl + P`, `Cmd/Ctrl + F` show a browser dialog by default but **can** be intercepted via `preventDefault()` (the library does this for you). If you need a "New item" shortcut, use a single letter like `c` (Gmail/Linear convention) or a non-reserved combo like `mod+shift+a`.
125
+
126
+ ## Configuration — what each option actually does
127
+
128
+ Every option below is optional. Defaults are listed; the explanation is what you'd want to know before changing them.
129
+
130
+ ```ts
131
+ provideHotkeys({
132
+ cheatsheetHotkey: '?',
133
+ allowInInputs: false,
134
+ sequenceTimeoutMs: 1000,
135
+ preventDefault: true,
136
+ allowDuplicateBindings: false,
137
+ allowReservedShortcuts: false,
138
+ });
139
+ ```
140
+
141
+ ### `cheatsheetHotkey` — default `'?'`
142
+
143
+ The single-key combo that opens the built-in cheatsheet (when `<ngr-hotkey-cheatsheet>` is rendered). Setting `null` disables the toggle entirely — useful when you render your own cheatsheet UI.
144
+
145
+ > **Real-world example:** Linear uses `?` for "Show keyboard shortcuts," so does GitHub. If your app already binds `?` to something else, set this to another key (e.g. `'F1'`) or `null` and trigger the cheatsheet yourself.
146
+
147
+ ### `allowInInputs` — default `false`
148
+
149
+ Should single-key shortcuts fire while the user is typing in a form field?
150
+
151
+ By default: **no**. If a user is typing in a text field, pressing `n` should insert "n" — not trigger your "New item" shortcut. This matches Gmail, GitHub, Linear, Notion, Slack — every keyboard-heavy app.
152
+
153
+ You can override per-binding via `[hotkeyAllowInInputs]="true"`. Common opt-ins: `Escape` to blur, `Cmd+S` to save while editing, `Cmd+Enter` to submit.
154
+
155
+ > **Real-world example:** In a comment composer, set `[hotkeyAllowInInputs]="true"` on `mod+enter` so users can submit without taking their hands off the keyboard.
156
+
157
+ ### `sequenceTimeoutMs` — default `1000`
158
+
159
+ Maximum gap (in milliseconds) between the keys of a sequence before the buffer resets.
160
+
161
+ ```
162
+ g …(800 ms)… i → fires the 'g i' binding
163
+ g …(1500 ms)… i → buffer reset; nothing fires; 'i' tried as a standalone combo
164
+ ```
165
+
166
+ > **Real-world example:** Gmail uses ~1 second; some users prefer more time. Bump to `1500` for a slower audience, drop to `750` to feel snappier.
167
+
168
+ ### `preventDefault` — default `true`
169
+
170
+ When a binding matches, should we call `event.preventDefault()` so the browser doesn't also do its built-in thing (e.g. open Save dialog on `Cmd+S`)?
171
+
172
+ Most of the time, yes — you defined the shortcut, you want your handler to win. Override per-binding if you want the browser default *too* (rare).
173
+
174
+ ### `allowDuplicateBindings` — default `false`
175
+
176
+ In development builds, registering two bindings with the same combo in the same scope emits a `console.warn`. This catches the bug where two components both bind `mod+k` and one silently shadows the other — a problem that's nearly impossible to debug without the warning.
177
+
178
+ The warning only fires for **same-scope** collisions. Cross-scope shadowing (e.g. a modal binding `escape` while a global `escape` exists) is intentional and never warns. Production builds never emit the warning regardless of this setting.
179
+
180
+ Set `true` if you deliberately layer multiple handlers on the same combo and don't want the noise.
181
+
182
+ ### `allowReservedShortcuts` — default `false`
183
+
184
+ In development builds, registering a combo the browser/OS reserves (`mod+n`, `mod+t`, `mod+w`, `mod+shift+n`, etc.) emits a `console.warn` with the reason it won't fire. Catches the exact "why isn't this triggering?" frustration these combos cause — you find out at registration time instead of in production. Production builds never emit the warning regardless.
185
+
186
+ Set `true` to silence — useful when you know your app is wrapped in something (Electron, an iframe with `sandbox` permissions, a kiosk shell) that lets you intercept these.
187
+
188
+ ## Use case 1 — global app shortcuts
189
+
190
+ The "save," "new," "search" shortcuts every productivity app has.
191
+
192
+ ```html
193
+ <button (ngrHotkey)="save()" hotkey="mod+s" hotkeyDescription="Save">Save</button>
194
+ <button (ngrHotkey)="newItem()" hotkey="c" hotkeyDescription="Create new">New</button>
195
+ <button (ngrHotkey)="search()" hotkey="/" hotkeyDescription="Focus search">/</button>
196
+ ```
197
+
198
+ The directive places these on actual buttons, but they fire even when those buttons aren't focused — matching happens at the `document` level. Putting the binding on the button keeps the shortcut documented next to the action it triggers.
199
+
200
+ ## Use case 2 — navigation sequences
201
+
202
+ Press `G`, then a letter, to jump somewhere. Used by Gmail, GitHub, Linear.
203
+
204
+ ```html
205
+ <a (ngrHotkey)="go('/inbox')" hotkey="g i" hotkeyDescription="Go to inbox"></a>
206
+ <a (ngrHotkey)="go('/drafts')" hotkey="g d" hotkeyDescription="Go to drafts"></a>
207
+ <a (ngrHotkey)="go('/sent')" hotkey="g s" hotkeyDescription="Go to sent"></a>
208
+ ```
209
+
210
+ Why two keys instead of one? You run out of single letters fast. `g` + 26 letters gives you a clean, conflict-free navigation namespace.
211
+
212
+ ## Use case 3 — modal / dialog shortcuts (scopes)
213
+
214
+ When a modal is open, `Esc` should close *the modal*, not trigger your global Esc handler.
215
+
216
+ ```html
217
+ <button (click)="open.set(true)">Edit</button>
218
+
219
+ @if (open()) {
220
+ <div ngrHotkeyScope="modal" class="modal">
221
+ <button (ngrHotkey)="open.set(false)" hotkey="escape" hotkeyDescription="Close">
222
+ Close
223
+ </button>
224
+ </div>
225
+ }
226
+ ```
227
+
228
+ `[ngrHotkeyScope]` pushes the `'modal'` scope when it mounts and pops it when it unmounts. Bindings *inside* the scope take precedence over `'global'` bindings with the same combo while the modal is open. No manual lifecycle wiring.
229
+
230
+ > **Why this matters:** without scopes, every Esc handler has to manually check "is a modal open right now?" Scopes solve it once at the library level.
231
+
232
+ ## Use case 4 — let some shortcuts fire while typing
233
+
234
+ A composer where `Cmd+Enter` submits even while the textarea has focus.
235
+
236
+ ```html
237
+ <textarea
238
+ (ngrHotkey)="submit()"
239
+ hotkey="mod+enter"
240
+ hotkeyDescription="Send"
241
+ [hotkeyAllowInInputs]="true"
242
+ ></textarea>
243
+ ```
244
+
245
+ The textarea still receives all normal typing. Only `Cmd+Enter` is intercepted.
246
+
247
+ ## Use case 5 — command palette (`Cmd+K`)
248
+
249
+ ```html
250
+ <button (ngrHotkey)="palette.open()" hotkey="mod+k" hotkeyDescription="Command palette">
251
+ ⌘K
252
+ </button>
253
+
254
+ @if (palette.isOpen()) {
255
+ <div ngrHotkeyScope="palette" class="palette">
256
+ <input ... />
257
+ <button (ngrHotkey)="palette.close()" hotkey="escape" [hotkeyAllowInInputs]="true">Close</button>
258
+ </div>
259
+ }
260
+ ```
261
+
262
+ Pattern is identical to the modal scope above — that's the point. One mechanism, many UIs.
263
+
264
+ ## The cheatsheet
265
+
266
+ Drop it anywhere — usually next to `<router-outlet>` in your shell:
267
+
268
+ ```ts
269
+ import { HotkeyCheatsheetComponent } from '@ngrithms/hotkeys/cheatsheet';
270
+
271
+ @Component({
272
+ standalone: true,
273
+ imports: [HotkeyCheatsheetComponent],
274
+ template: `
275
+ <router-outlet />
276
+ <ngr-hotkey-cheatsheet />
277
+ `,
278
+ })
279
+ export class App {}
280
+ ```
281
+
282
+ Press `?` (configurable) to toggle. Only bindings with a `hotkeyDescription` show up; bindings are grouped by `scope`.
283
+
284
+ ### Searchable
285
+
286
+ For apps with many shortcuts, opt into a filter input above the list:
287
+
288
+ ```html
289
+ <ngr-hotkey-cheatsheet [searchable]="true" />
290
+ ```
291
+
292
+ The filter matches against the description, the combo string, and the rendered display (so `'⌘S'` matches `mod+s`). Off by default — overkill for apps with five shortcuts.
293
+
294
+ ### Categories
295
+
296
+ Group bindings within a scope under sub-headers by setting `hotkeyCategory` on the directive (or `category` on a service registration):
297
+
298
+ ```html
299
+ <button (ngrHotkey)="save()" hotkey="mod+s" hotkeyCategory="Editing" hotkeyDescription="Save">Save</button>
300
+ <button (ngrHotkey)="undo()" hotkey="mod+z" hotkeyCategory="Editing" hotkeyDescription="Undo">Undo</button>
301
+ <button (ngrHotkey)="palette()" hotkey="mod+k" hotkeyCategory="Navigation" hotkeyDescription="Command palette">⌘K</button>
302
+ ```
303
+
304
+ The cheatsheet shows a single "global" scope header, then sub-headers for "Editing" and "Navigation" with the bindings under each. Uncategorized bindings render first.
305
+
306
+ ### Theming
307
+
308
+ Override CSS custom properties on the component:
309
+
310
+ ```css
311
+ ngr-hotkey-cheatsheet {
312
+ --ngr-hotkey-bg: #0b1220;
313
+ --ngr-hotkey-fg: #e2e8f0;
314
+ --ngr-hotkey-kbd-bg: #1e293b;
315
+ --ngr-hotkey-kbd-fg: #f8fafc;
316
+ }
317
+ ```
318
+
319
+ The cheatsheet ships as a **secondary entry point** so the core stays small. If you don't import it, none of its code lands in your bundle.
320
+
321
+ ## Programmatic API — `HotkeysService`
322
+
323
+ For shortcuts that aren't tied to a single component (e.g. an app-wide help dialog or a feature behind a service):
324
+
325
+ ```ts
326
+ const hotkeys = inject(HotkeysService);
327
+
328
+ const dispose = hotkeys.register({
329
+ keys: 'mod+/',
330
+ description: 'Open help',
331
+ handler: () => this.help.toggle(),
332
+ });
333
+
334
+ // dispose() to remove later. The directive does this automatically; service-level
335
+ // registrations are yours to clean up.
336
+ ```
337
+
338
+ Signals you can read:
339
+
340
+ | Member | Type | Description |
341
+ |---|---|---|
342
+ | `registered` | `Signal<readonly HotkeyBinding[]>` | All current bindings — read this from your own cheatsheet UI. |
343
+ | `activeScope` | `Signal<string>` | Top of the scope stack. |
344
+ | `lastTriggered` | `Signal<HotkeyTriggerEvent \| null>` | The most recent fire — useful for analytics or debug UIs. |
345
+ | `onTrigger` | `Observable<HotkeyTriggerEvent>` | Stream of every fire. |
346
+
347
+ Scope control:
348
+
349
+ ```ts
350
+ const release = hotkeys.pushScope('modal');
351
+ // ... later ...
352
+ release();
353
+ ```
354
+
355
+ You almost never need this — `[ngrHotkeyScope]` handles it via the component's own lifecycle. Use it only when your scope boundary doesn't match an element (e.g. a router-level "in editor route" scope).
356
+
357
+ ## SSR
358
+
359
+ All DOM listeners are guarded by `isPlatformBrowser`. On the server platform:
360
+
361
+ - `register()` returns a no-op disposer.
362
+ - `registered()` is `[]`.
363
+ - The cheatsheet renders nothing.
364
+
365
+ You can import and use the library anywhere in your code, including SSR-rendered pages — it won't break your build.
366
+
367
+ ## Comparison to `angular2-hotkeys`
368
+
369
+ | | `angular2-hotkeys` | `@ngrithms/hotkeys` |
370
+ |---|---|---|
371
+ | Maintenance | Last release 2 years ago | Active |
372
+ | Setup | `HotkeyModule.forRoot()` | `provideHotkeys({...})` functional config |
373
+ | Registration | Manual `service.add(new Hotkey(...))` + `remove()` in `ngOnDestroy` | `(ngrHotkey)` directive auto-cleans on destroy |
374
+ | State surface | None | Signals (`registered`, `activeScope`, `lastTriggered`) |
375
+ | Cross-platform `mod` | Manual `meta` vs `ctrl` branching | Auto-resolved via `mod` keyword |
376
+ | Scopes | None | Stack-based via `[ngrHotkeyScope]` |
377
+ | Sequences (`g i`) | No | Yes |
378
+ | Multi-combo per binding | Yes (one of the few areas it had) | Yes (parity) |
379
+ | Dev-mode conflict warning | No | Yes — catches silent shadowing |
380
+ | Reserved-combo warning | No | Yes — flags `mod+n`, `mod+t`, etc. at registration |
381
+ | Input filtering | One global boolean | Per-binding, distinguishes `contenteditable` |
382
+ | Cheatsheet | Built-in, hard to theme | Optional secondary entry, themable, searchable, category groups |
383
+ | Standalone / signals | ✗ | ✓ |
384
+ | SSR-safe | ✗ | ✓ |
385
+ | Runtime deps | `hotkeys.js` | None |
386
+ | Bundle (gz) | ~10 KB (lib) + `hotkeys.js` | ~9 KB core, ~13 KB with cheatsheet |
387
+
388
+ ## License
389
+
390
+ MIT © Aboud Badra
@@ -0,0 +1,4 @@
1
+ {
2
+ "module": "../fesm2022/ngrithms-hotkeys-cheatsheet.mjs",
3
+ "typings": "../types/ngrithms-hotkeys-cheatsheet.d.ts"
4
+ }