@tanstack/angular-hotkeys 0.19.1 → 0.20.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/dist/_zone.js ADDED
@@ -0,0 +1,14 @@
1
+ import { NgZone, inject } from "@angular/core";
2
+
3
+ //#region src/_zone.ts
4
+ /**
5
+ * Runs `fn` outside the Angular zone, so core listeners it attaches don't
6
+ * run change detection on every event. Zoneless apps call `fn` directly.
7
+ * Must be called in an injection context.
8
+ */
9
+ function runOutsideZone(fn) {
10
+ return inject(NgZone).runOutsideAngular(fn);
11
+ }
12
+
13
+ //#endregion
14
+ export { runOutsideZone };
@@ -62,7 +62,9 @@ export type MaybeGetter<T> = T | (() => T);
62
62
  * inherits app hotkeys and merges default options. With `scope`, hotkeys
63
63
  * injected in the component and its children fire only while it exists and
64
64
  * is enabled; with `exclusive`, only they fire. The scope activates when the
65
- * first hotkey in it is injected.
65
+ * first hotkey in it is injected. Angular creates the provider lazily, so a
66
+ * component with no hotkeys of its own calls `injectHotkeysContext()` to
67
+ * activate its scope.
66
68
  *
67
69
  * @example
68
70
  * ```ts
@@ -94,6 +96,21 @@ export type MaybeGetter<T> = T | (() => T);
94
96
  *
95
97
  * @example
96
98
  * ```ts
99
+ * // An overlay with no hotkeys that suspends the page's hotkeys.
100
+ * @Component({
101
+ * selector: 'app-loading-overlay',
102
+ * providers: [provideHotkeys({ scope: 'loading', exclusive: true })],
103
+ * template: `...`,
104
+ * })
105
+ * export class LoadingOverlay {
106
+ * constructor() {
107
+ * injectHotkeysContext()
108
+ * }
109
+ * }
110
+ * ```
111
+ *
112
+ * @example
113
+ * ```ts
97
114
  * // A scope that follows the providing component's input.
98
115
  * @Component({
99
116
  * selector: 'app-folder',
@@ -112,6 +129,10 @@ export type MaybeGetter<T> = T | (() => T);
112
129
  * ```
113
130
  */
114
131
  export declare function provideHotkeys(options?: ProvideHotkeysOptions): Provider;
132
+ /**
133
+ * Returns the nearest provider's context. Injecting it activates that
134
+ * provider's scope.
135
+ */
115
136
  export declare function injectHotkeysContext(): HotkeysContextValue;
116
137
  export declare function injectDefaultHotkeysOptions(): HotkeysProviderOptions;
117
138
  //#endregion
@@ -71,7 +71,9 @@ function createHotkeysContext(options) {
71
71
  * inherits app hotkeys and merges default options. With `scope`, hotkeys
72
72
  * injected in the component and its children fire only while it exists and
73
73
  * is enabled; with `exclusive`, only they fire. The scope activates when the
74
- * first hotkey in it is injected.
74
+ * first hotkey in it is injected. Angular creates the provider lazily, so a
75
+ * component with no hotkeys of its own calls `injectHotkeysContext()` to
76
+ * activate its scope.
75
77
  *
76
78
  * @example
77
79
  * ```ts
@@ -103,6 +105,21 @@ function createHotkeysContext(options) {
103
105
  *
104
106
  * @example
105
107
  * ```ts
108
+ * // An overlay with no hotkeys that suspends the page's hotkeys.
109
+ * @Component({
110
+ * selector: 'app-loading-overlay',
111
+ * providers: [provideHotkeys({ scope: 'loading', exclusive: true })],
112
+ * template: `...`,
113
+ * })
114
+ * export class LoadingOverlay {
115
+ * constructor() {
116
+ * injectHotkeysContext()
117
+ * }
118
+ * }
119
+ * ```
120
+ *
121
+ * @example
122
+ * ```ts
106
123
  * // A scope that follows the providing component's input.
107
124
  * @Component({
108
125
  * selector: 'app-folder',
@@ -126,6 +143,10 @@ function provideHotkeys(options = {}) {
126
143
  useFactory: () => createHotkeysContext(options)
127
144
  };
128
145
  }
146
+ /**
147
+ * Returns the nearest provider's context. Injecting it activates that
148
+ * provider's scope.
149
+ */
129
150
  function injectHotkeysContext() {
130
151
  return inject(HOTKEYS_INJECTION_TOKEN);
131
152
  }
@@ -1,4 +1,5 @@
1
1
  import { injectSelector } from "./_injectSelector.js";
2
+ import { runOutsideZone } from "./_zone.js";
2
3
  import { getKeyStateTracker } from "@tanstack/hotkeys";
3
4
 
4
5
  //#region src/injectHeldKeyCodes.ts
@@ -26,7 +27,7 @@ import { getKeyStateTracker } from "@tanstack/hotkeys";
26
27
  * ```
27
28
  */
28
29
  function injectHeldKeyCodes() {
29
- const tracker = getKeyStateTracker();
30
+ const tracker = runOutsideZone(getKeyStateTracker);
30
31
  return injectSelector(tracker.store, (state) => state.heldCodes);
31
32
  }
32
33
 
@@ -1,4 +1,5 @@
1
1
  import { injectSelector } from "./_injectSelector.js";
2
+ import { runOutsideZone } from "./_zone.js";
2
3
  import { getKeyStateTracker } from "@tanstack/hotkeys";
3
4
 
4
5
  //#region src/injectHeldKeys.ts
@@ -21,7 +22,7 @@ import { getKeyStateTracker } from "@tanstack/hotkeys";
21
22
  * ```
22
23
  */
23
24
  function injectHeldKeys() {
24
- const tracker = getKeyStateTracker();
25
+ const tracker = runOutsideZone(getKeyStateTracker);
25
26
  return injectSelector(tracker.store, (state) => state.heldKeys);
26
27
  }
27
28
 
@@ -1,12 +1,13 @@
1
1
  import { AppHotkey, HotkeyCallback, HotkeyCallbackContextFor, RegisterableHotkey, Target } from "@tanstack/hotkeys";
2
+ import { ElementRef, Signal } from "@angular/core";
2
3
  import { AdapterAppHotkeyOptions, AdapterChordOptions, AdapterCommonOptions, AdapterHotkeyOptions, AdapterOptionsFor, AdapterSequenceOptions } from "@tanstack/hotkeys/adapter";
3
4
  //#region src/injectHotkey.d.ts
4
5
  /**
5
- * The element to listen on. A `null` target skips registration, so pass a
6
- * getter such as `() => ({ target: this.element() })` for elements that
7
- * attach after the view renders.
6
+ * The element to listen on: an element, an `ElementRef`, or a signal of
7
+ * either, such as `viewChild<ElementRef>('panel')`. A `null` target, or a
8
+ * signal of `undefined`, skips registration until the element exists.
8
9
  */
9
- export type InjectHotkeyTarget = Target | null;
10
+ export type InjectHotkeyTarget = Target | ElementRef<Target> | Signal<Target | ElementRef<Target> | null | undefined> | null;
10
11
  /** Options shared by chords and sequences. */
11
12
  export type InjectHotkeyOptions = AdapterHotkeyOptions<InjectHotkeyTarget>;
12
13
  /** Options for a chord such as `'Mod+S'`. */
@@ -1,5 +1,6 @@
1
1
  import { injectHotkeysContext } from "./hotkeys-provider.js";
2
2
  import { injectSelector } from "./_injectSelector.js";
3
+ import { runOutsideZone } from "./_zone.js";
3
4
  import { getHotkeyManager, getKeyStateTracker, isAppHotkey, matchesHeldModifiers, resolveAppHotkey } from "@tanstack/hotkeys";
4
5
  import { computed } from "@angular/core";
5
6
 
@@ -17,7 +18,7 @@ function injectHotkeyHint(hotkey, options = {}) {
17
18
  const context = injectHotkeysContext();
18
19
  const manager = getHotkeyManager();
19
20
  const registrations = injectSelector(manager.registrations, (state) => state);
20
- const held = injectSelector(getKeyStateTracker().store, (state) => state.heldKeys);
21
+ const held = injectSelector(runOutsideZone(getKeyStateTracker).store, (state) => state.heldKeys);
21
22
  return computed(() => {
22
23
  context.appHotkeysState();
23
24
  const value = typeof hotkey === "function" ? hotkey() : hotkey;
@@ -1,23 +1,33 @@
1
1
  import { injectDefaultHotkeysOptions } from "./hotkeys-provider.js";
2
2
  import { HotkeyRecorder } from "@tanstack/hotkeys";
3
- import { DestroyRef, computed, effect, inject, linkedSignal, untracked } from "@angular/core";
3
+ import { DestroyRef, NgZone, computed, effect, inject, linkedSignal, untracked } from "@angular/core";
4
4
  import { withDefaults } from "@tanstack/hotkeys/adapter";
5
5
 
6
6
  //#region src/injectHotkeyRecorder.ts
7
7
  function injectHotkeyRecorder(options) {
8
8
  const defaultOptions = injectDefaultHotkeysOptions();
9
9
  const destroyRef = inject(DestroyRef);
10
- const recorderSignal = computed(() => untracked(() => {
11
- const resolvedOptions = typeof options === "function" ? options() : options;
12
- return new HotkeyRecorder(withDefaults(defaultOptions.hotkeyRecorder, resolvedOptions));
13
- }));
10
+ const zone = inject(NgZone);
11
+ const resolve = () => {
12
+ const resolved = withDefaults(defaultOptions.hotkeyRecorder, typeof options === "function" ? options() : options);
13
+ for (const key of [
14
+ "onRecord",
15
+ "onCancel",
16
+ "onClear",
17
+ "onReject"
18
+ ]) {
19
+ const callback = resolved[key];
20
+ if (callback) resolved[key] = (...args) => zone.run(callback, null, args);
21
+ }
22
+ return resolved;
23
+ };
24
+ const recorderSignal = computed(() => untracked(() => new HotkeyRecorder(resolve())));
14
25
  const recorderStore = computed(() => untracked(() => recorderSignal().store));
15
26
  const isRecording = injectLazyStore(recorderStore, (state) => state.isRecording);
16
27
  const steps = injectLazyStore(recorderStore, (state) => state.steps);
17
28
  const recordedHotkey = injectLazyStore(recorderStore, (state) => state.recordedHotkey);
18
29
  effect(() => {
19
- const resolved = typeof options === "function" ? options() : options;
20
- recorderSignal().setOptions(withDefaults(defaultOptions.hotkeyRecorder, resolved));
30
+ recorderSignal().setOptions(resolve());
21
31
  });
22
32
  destroyRef.onDestroy(() => {
23
33
  recorderSignal().destroy();
@@ -26,7 +36,7 @@ function injectHotkeyRecorder(options) {
26
36
  isRecording,
27
37
  steps,
28
38
  recordedHotkey,
29
- startRecording: () => recorderSignal().start(),
39
+ startRecording: () => zone.runOutsideAngular(() => recorderSignal().start()),
30
40
  stopRecording: () => recorderSignal().stop(),
31
41
  cancelRecording: () => recorderSignal().cancel(),
32
42
  commitRecording: () => recorderSignal().commit()
@@ -13,7 +13,9 @@ export type InjectHotkeyDefinition = AdapterHotkeyDefinition<InjectHotkeyTarget>
13
13
  * Options merge in this order: provider defaults, app hotkey declaration,
14
14
  * commonOptions, per-hotkey options. Each option applies only to the kinds
15
15
  * that support it. Unbound app hotkeys and empty sequences register nothing.
16
- * A `null` target skips registration until it is set.
16
+ * A target may be an element, an `ElementRef`, or a signal of either, such
17
+ * as `viewChild('panel')`. A `null` target, or a signal of `undefined`, skips
18
+ * registration until the element exists.
17
19
  *
18
20
  * @example
19
21
  * ```ts
@@ -1,5 +1,5 @@
1
1
  import { injectHotkeysContext } from "./hotkeys-provider.js";
2
- import { DestroyRef, effect, inject, untracked } from "@angular/core";
2
+ import { DestroyRef, ElementRef, NgZone, effect, inject, untracked } from "@angular/core";
3
3
  import { createHotkeyBindings, prepareHotkeyDefinitions } from "@tanstack/hotkeys/adapter";
4
4
 
5
5
  //#region src/injectHotkeys.ts
@@ -10,7 +10,9 @@ import { createHotkeyBindings, prepareHotkeyDefinitions } from "@tanstack/hotkey
10
10
  * Options merge in this order: provider defaults, app hotkey declaration,
11
11
  * commonOptions, per-hotkey options. Each option applies only to the kinds
12
12
  * that support it. Unbound app hotkeys and empty sequences register nothing.
13
- * A `null` target skips registration until it is set.
13
+ * A target may be an element, an `ElementRef`, or a signal of either, such
14
+ * as `viewChild('panel')`. A `null` target, or a signal of `undefined`, skips
15
+ * registration until the element exists.
14
16
  *
15
17
  * @example
16
18
  * ```ts
@@ -22,6 +24,7 @@ import { createHotkeyBindings, prepareHotkeyDefinitions } from "@tanstack/hotkey
22
24
  */
23
25
  function injectHotkeys(hotkeys, commonOptions = {}) {
24
26
  const context = injectHotkeysContext();
27
+ const zone = inject(NgZone);
25
28
  const bindings = createHotkeyBindings();
26
29
  inject(DestroyRef).onDestroy(() => bindings.destroy());
27
30
  effect(() => {
@@ -30,9 +33,16 @@ function injectHotkeys(hotkeys, commonOptions = {}) {
30
33
  appHotkeysStore: context.appHotkeysStore,
31
34
  defaults: context.defaultOptions.hotkey,
32
35
  commonOptions: typeof commonOptions === "function" ? commonOptions() : commonOptions,
33
- resolveTarget: (target) => target
36
+ resolveTarget: (target) => {
37
+ const value = typeof target === "function" ? target() : target;
38
+ return (value instanceof ElementRef ? value.nativeElement : value) ?? null;
39
+ }
34
40
  });
35
- untracked(() => bindings.update(definitions));
41
+ for (const definition of definitions) {
42
+ const callback = definition.callback;
43
+ definition.callback = (...args) => zone.run(callback, null, args);
44
+ }
45
+ untracked(() => zone.runOutsideAngular(() => bindings.update(definitions)));
36
46
  });
37
47
  }
38
48
 
@@ -1,4 +1,5 @@
1
1
  import { injectSelector } from "./_injectSelector.js";
2
+ import { runOutsideZone } from "./_zone.js";
2
3
  import { getKeyStateTracker } from "@tanstack/hotkeys";
3
4
  import { computed } from "@angular/core";
4
5
 
@@ -37,7 +38,7 @@ import { computed } from "@angular/core";
37
38
  * ```
38
39
  */
39
40
  function injectKeyHold(key) {
40
- const tracker = getKeyStateTracker();
41
+ const tracker = runOutsideZone(getKeyStateTracker);
41
42
  const heldKeysSelector = injectSelector(tracker.store, (state) => state.heldKeys);
42
43
  return computed(() => {
43
44
  const normalizedKey = (typeof key === "function" ? key() : key).toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/angular-hotkeys",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Angular adapter for TanStack Hotkeys",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "skills"
38
38
  ],
39
39
  "dependencies": {
40
- "@tanstack/hotkeys": "0.22.0"
40
+ "@tanstack/hotkeys": "0.22.1"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@angular/common": ">=19.0.0",
@@ -47,7 +47,8 @@
47
47
  "@angular/common": "^22.2.1",
48
48
  "@angular/core": "^22.2.1",
49
49
  "@angular/platform-browser-dynamic": "^22.2.1",
50
- "typescript": "6.0.3"
50
+ "typescript": "6.0.3",
51
+ "zone.js": "~0.16.3"
51
52
  },
52
53
  "scripts": {
53
54
  "clean": "premove ./build ./dist",
@@ -48,13 +48,8 @@ export class PanelComponent {
48
48
  () => this.save(),
49
49
  () => ({ enabled: this.isDirty() }),
50
50
  )
51
- injectHotkey(
52
- 'Escape',
53
- () => this.close(),
54
- () => ({
55
- target: this.panel()?.nativeElement ?? null,
56
- }),
57
- )
51
+ // A viewChild signal target registers once the element renders.
52
+ injectHotkey('Escape', () => this.close(), { target: this.panel })
58
53
  }
59
54
 
60
55
  save() {}
@@ -69,7 +64,7 @@ export class PanelComponent {
69
64
  | `injectHotkey(hotkey, callback, options?)` | Register one chord, sequence, or app hotkey for the component's or service's lifetime; options can be a function that reads signals. |
70
65
  | `injectHotkeys(definitions, commonOptions?)` | Register several hotkeys, or a function returning a list built from data, in one call. |
71
66
  | `provideHotkeys(options)` | Set default options, a scope (`exclusive` for dialogs), `enabled`, and app hotkeys in the app config or a component's `providers`. |
72
- | `injectHotkeysContext()` | Read the nearest provided defaults and enabled state, or `null` without a provider. |
67
+ | `injectHotkeysContext()` | Read the nearest provided defaults and enabled state. Activates that provider's scope. |
73
68
  | `injectDefaultHotkeysOptions()` | Read the default options merged from every provider above. |
74
69
  | `injectAppHotkeys()` | Get a signal of the declared app hotkeys with the user's custom hotkeys applied. |
75
70
  | `injectAppHotkeyList()` | Get a signal listing every app hotkey with its status, for a cheat sheet or settings screen. |
@@ -116,7 +111,9 @@ A component's `providers` apply to the component itself, so it can register its
116
111
 
117
112
  ## Pitfalls
118
113
 
119
- - **Pass options as a function.** `{ enabled: this.isDirty() }` is read once, in the constructor. A `viewChild` read there is `undefined`, and an undefined target means `document`. Use `() => ({ ... })`.
114
+ - **Pass options as a function.** `{ enabled: this.isDirty() }` is read once, in the constructor. Use `() => ({ ... })`.
115
+ - **Pass the `viewChild` signal as `target`, not its value.** `this.panel()` in the constructor is `undefined`, and an undefined target means `document`. `{ target: this.panel }` waits for the element.
116
+ - **An exclusive provider with no hotkeys never activates.** Angular creates providers lazily. Call `injectHotkeysContext()` in the component's constructor to activate the scope.
120
117
  - **Replace `@HostListener('document:keydown')` and `(keydown.control.s)` bindings with `injectHotkey`.**
121
118
  - **Register runtime lists in the constructor.** `injectHotkey` throws outside an injection context, such as in `ngOnInit`. For hotkeys from data, use `injectHotkeys(() => this.items().map(...))`.
122
119
  - **Call the inject functions directly.** A shortcut service or `injectShortcut(keys: string)` wrapper has to cast strings to hotkeys, which turns off type checking and drops sequences, signal options, and app hotkeys.