@tanstack/hotkeys 0.20.0 → 0.22.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/_keyboard-event.js +8 -3
- package/dist/_match.js +1 -1
- package/dist/_registration.d.ts +14 -0
- package/dist/_registration.js +32 -1
- package/dist/_target-listeners.js +3 -6
- package/dist/adapter.d.ts +13 -5
- package/dist/adapter.js +14 -24
- package/dist/app-hotkeys.d.ts +18 -3
- package/dist/app-hotkeys.js +50 -3
- package/dist/hint.d.ts +3 -2
- package/dist/hint.js +3 -1
- package/dist/hotkey-manager.d.ts +3 -2
- package/dist/hotkey-manager.js +48 -33
- package/dist/key-state-tracker.js +25 -3
- package/dist/match.js +4 -1
- package/dist/validate.d.ts +6 -2
- package/dist/validate.js +6 -2
- package/package.json +1 -1
- package/skills/app-hotkeys/SKILL.md +1 -2
- package/skills/hotkeys-core/references/testing.md +1 -1
package/dist/_keyboard-event.js
CHANGED
|
@@ -22,7 +22,7 @@ function normalizeKeyboardEvent(event, platform) {
|
|
|
22
22
|
meta: Boolean(event.metaKey),
|
|
23
23
|
altGraph: altGraph && !controlAlt,
|
|
24
24
|
location: event.location,
|
|
25
|
-
isComposing: Boolean(event.isComposing) || key === "Process" || key === "Unidentified"
|
|
25
|
+
isComposing: Boolean(event.isComposing) || event.keyCode === 229 || key === "Process" || key === "Unidentified"
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
@@ -93,10 +93,15 @@ function isAsciiLetter(key) {
|
|
|
93
93
|
function isLatinLetter(key) {
|
|
94
94
|
return /^\p{Script_Extensions=Latin}$/u.test(key);
|
|
95
95
|
}
|
|
96
|
+
let collator;
|
|
96
97
|
/** Compares letters without case, preserving accents and avoiding expansions such as ß → SS. */
|
|
97
98
|
function keysEqual(left, right) {
|
|
98
|
-
if (
|
|
99
|
-
|
|
99
|
+
if (left === right) return true;
|
|
100
|
+
if (/^\p{Letter}$/u.test(left) && /^\p{Letter}$/u.test(right)) {
|
|
101
|
+
collator ??= new Intl.Collator(void 0, { sensitivity: "accent" });
|
|
102
|
+
return collator.compare(left, right) === 0;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
100
105
|
}
|
|
101
106
|
/** Rejects reserved physical-token syntax in logical object fields while allowing Unicode characters. */
|
|
102
107
|
function assertLogicalKey(key) {
|
package/dist/_match.js
CHANGED
|
@@ -11,7 +11,7 @@ function matchKeyboardEvent(event, hotkey, platform = detectPlatform()) {
|
|
|
11
11
|
matched: false,
|
|
12
12
|
score: 0
|
|
13
13
|
};
|
|
14
|
-
if (normalized.isComposing
|
|
14
|
+
if (normalized.isComposing) return noMatch;
|
|
15
15
|
if (normalized.altGraph && (parsed.ctrl || parsed.alt)) return noMatch;
|
|
16
16
|
if (normalized.altGraph && !altGraphTypedCharacter(normalized)) return noMatch;
|
|
17
17
|
if (parsed.code !== void 0) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/_registration.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Merges option layers left to right. A key set to `undefined` keeps the
|
|
4
|
+
* earlier layer's value, so an unset option keeps its default. Getters are
|
|
5
|
+
* read once.
|
|
6
|
+
*/
|
|
7
|
+
export declare function definedOptions<T extends object>(...layers: ReadonlyArray<object | undefined>): T;
|
|
8
|
+
/**
|
|
9
|
+
* Merges options over defaults for an API whose `setOptions` treats
|
|
10
|
+
* `undefined` as "clear". An unset option takes its default, or stays
|
|
11
|
+
* `undefined` to clear an earlier value when it has no default.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withDefaults<T extends object>(defaults: object | undefined, options: T | undefined): T;
|
|
14
|
+
//#endregion
|
package/dist/_registration.js
CHANGED
|
@@ -16,6 +16,37 @@ const defaultHotkeyOptions = {
|
|
|
16
16
|
scope: GLOBAL_SCOPE
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
|
+
* Merges option layers left to right. A key set to `undefined` keeps the
|
|
20
|
+
* earlier layer's value, so an unset option keeps its default. Getters are
|
|
21
|
+
* read once.
|
|
22
|
+
*/
|
|
23
|
+
function definedOptions(...layers) {
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const layer of layers) {
|
|
26
|
+
if (!layer) continue;
|
|
27
|
+
for (const key of Object.keys(layer)) {
|
|
28
|
+
const value = layer[key];
|
|
29
|
+
if (value !== void 0) result[key] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Merges options over defaults for an API whose `setOptions` treats
|
|
36
|
+
* `undefined` as "clear". An unset option takes its default, or stays
|
|
37
|
+
* `undefined` to clear an earlier value when it has no default.
|
|
38
|
+
*/
|
|
39
|
+
function withDefaults(defaults, options) {
|
|
40
|
+
return {
|
|
41
|
+
...options,
|
|
42
|
+
...definedOptions(defaults, options)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Whether a parsed chord has a key. An empty one would match key-less events. */
|
|
46
|
+
function chordHasKey(chord) {
|
|
47
|
+
return !!(chord.code || chord.key);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
19
50
|
* Resolves `ignoreInputs: 'auto'` from the hotkey (the first step of a sequence).
|
|
20
51
|
* Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
|
|
21
52
|
*/
|
|
@@ -50,4 +81,4 @@ function optionsEqual(left, right) {
|
|
|
50
81
|
}
|
|
51
82
|
|
|
52
83
|
//#endregion
|
|
53
|
-
export { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual };
|
|
84
|
+
export { GLOBAL_SCOPE, chordHasKey, defaultHotkeyOptions, definedOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual, withDefaults };
|
|
@@ -9,11 +9,7 @@ var TargetListeners = class {
|
|
|
9
9
|
}
|
|
10
10
|
add(target, capture, id) {
|
|
11
11
|
if (typeof document === "undefined") return;
|
|
12
|
-
|
|
13
|
-
if (!phases) {
|
|
14
|
-
phases = /* @__PURE__ */ new Map();
|
|
15
|
-
this.#targets.set(target, phases);
|
|
16
|
-
}
|
|
12
|
+
const phases = this.#targets.get(target) ?? /* @__PURE__ */ new Map();
|
|
17
13
|
let group = phases.get(capture);
|
|
18
14
|
if (!group) {
|
|
19
15
|
const ids = /* @__PURE__ */ new Set();
|
|
@@ -41,9 +37,10 @@ var TargetListeners = class {
|
|
|
41
37
|
keydown: listener("keydown"),
|
|
42
38
|
keyup: listener("keyup")
|
|
43
39
|
};
|
|
44
|
-
phases.set(capture, group);
|
|
45
40
|
target.addEventListener("keydown", group.keydown, capture);
|
|
46
41
|
target.addEventListener("keyup", group.keyup, capture);
|
|
42
|
+
phases.set(capture, group);
|
|
43
|
+
this.#targets.set(target, phases);
|
|
47
44
|
}
|
|
48
45
|
group.ids.add(id);
|
|
49
46
|
}
|
package/dist/adapter.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { HotkeyCallback, HotkeyChord, HotkeySequence, RegisterableHotkey, Regist
|
|
|
3
3
|
import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyOptions, HotkeySequenceOptions, Target } from "./hotkey-manager.js";
|
|
4
4
|
import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
|
|
5
5
|
import { createAppHotkeysStore } from "./_app-hotkeys-factory.js";
|
|
6
|
+
import { definedOptions, withDefaults } from "./_registration.js";
|
|
6
7
|
//#region src/adapter.d.ts
|
|
7
8
|
/**
|
|
8
9
|
* A hotkey and its callback, with optional per-binding options. Chords accept
|
|
@@ -16,6 +17,11 @@ export type HotkeyDefinition = {
|
|
|
16
17
|
hotkey: RegisterableHotkeySequence;
|
|
17
18
|
callback: HotkeyCallback;
|
|
18
19
|
options?: HotkeySequenceOptions;
|
|
20
|
+
} | {
|
|
21
|
+
/** No hotkey. Registers nothing. */
|
|
22
|
+
hotkey: null | undefined;
|
|
23
|
+
callback: HotkeyCallback;
|
|
24
|
+
options?: HotkeyChordOptions & HotkeySequenceOptions;
|
|
19
25
|
};
|
|
20
26
|
/** Options shared by every definition. Each option applies only to the kinds that support it. */
|
|
21
27
|
export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOptions;
|
|
@@ -23,7 +29,8 @@ export type HotkeyBindingsCommonOptions = HotkeyChordOptions & HotkeySequenceOpt
|
|
|
23
29
|
export declare function createHotkeyBindings(): {
|
|
24
30
|
/**
|
|
25
31
|
* Registers new definitions, updates existing ones in place, and removes
|
|
26
|
-
* stale ones. Definitions with
|
|
32
|
+
* stale ones. Definitions with no hotkey (undefined, null, empty string, or
|
|
33
|
+
* empty sequence) or a null target are skipped.
|
|
27
34
|
*/
|
|
28
35
|
update(definitions: ReadonlyArray<HotkeyDefinition>, commonOptions?: HotkeyBindingsCommonOptions): void;
|
|
29
36
|
destroy(): void;
|
|
@@ -79,9 +86,10 @@ export type AdapterHotkeyDefinition<TTarget> = {
|
|
|
79
86
|
} | {
|
|
80
87
|
/**
|
|
81
88
|
* A hotkey whose kind is not known statically, such as user data typed
|
|
82
|
-
* `Hotkey`. Accepts only shared options.
|
|
89
|
+
* `Hotkey`. Accepts only shared options. `undefined`, `null`, and `''`
|
|
90
|
+
* register nothing, for an optional shortcut.
|
|
83
91
|
*/
|
|
84
|
-
hotkey: RegisterableHotkey;
|
|
92
|
+
hotkey: RegisterableHotkey | null | undefined;
|
|
85
93
|
callback: HotkeyCallback;
|
|
86
94
|
options?: AdapterHotkeyOptions<TTarget>;
|
|
87
95
|
};
|
|
@@ -96,7 +104,7 @@ export interface PrepareHotkeyDefinitionsOptions<TTarget> {
|
|
|
96
104
|
/**
|
|
97
105
|
* Resolves a framework target, such as a ref. Return `undefined` for the
|
|
98
106
|
* document or `null` to skip registration until the target exists.
|
|
99
|
-
* Without it, targets pass through
|
|
107
|
+
* Without it, targets pass through, so `null` skips registration.
|
|
100
108
|
*/
|
|
101
109
|
resolveTarget?: (target: TTarget) => Target | null | undefined;
|
|
102
110
|
}
|
|
@@ -163,4 +171,4 @@ export declare function getHotkeysProviderScopeKey(options: {
|
|
|
163
171
|
*/
|
|
164
172
|
export declare function activateHotkeysProviderScope(key: string): () => void;
|
|
165
173
|
//#endregion
|
|
166
|
-
export { type Store, createAppHotkeysStore, createStore };
|
|
174
|
+
export { type Store, createAppHotkeysStore, createStore, definedOptions, withDefaults };
|
package/dist/adapter.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { detectPlatform } from "./platform.js";
|
|
2
|
-
import { isHotkeySequence, normalizeHotkey } from "./parse.js";
|
|
2
|
+
import { isHotkeySequence, normalizeHotkey, parseHotkey } from "./parse.js";
|
|
3
|
+
import { chordHasKey, defaultHotkeyOptions, definedOptions, withDefaults } from "./_registration.js";
|
|
3
4
|
import { createStore } from "./_store.js";
|
|
4
|
-
import { defaultHotkeyOptions } from "./_registration.js";
|
|
5
5
|
import { getHotkeyManager } from "./hotkey-manager.js";
|
|
6
6
|
import { createAppHotkeysStore } from "./_app-hotkeys-factory.js";
|
|
7
7
|
import { getAppHotkeyRegistrationOptions, isAppHotkey, resolveAppHotkey } from "./app-hotkeys.js";
|
|
@@ -13,24 +13,23 @@ function createHotkeyBindings() {
|
|
|
13
13
|
return {
|
|
14
14
|
/**
|
|
15
15
|
* Registers new definitions, updates existing ones in place, and removes
|
|
16
|
-
* stale ones. Definitions with
|
|
16
|
+
* stale ones. Definitions with no hotkey (undefined, null, empty string, or
|
|
17
|
+
* empty sequence) or a null target are skipped.
|
|
17
18
|
*/
|
|
18
19
|
update(definitions, commonOptions = {}) {
|
|
19
20
|
const prepared = [];
|
|
20
21
|
definitions.forEach((definition, index) => {
|
|
21
|
-
const merged =
|
|
22
|
-
...commonOptions,
|
|
23
|
-
...definition.options
|
|
24
|
-
};
|
|
22
|
+
const merged = definedOptions(commonOptions, definition.options);
|
|
25
23
|
const options = {
|
|
26
24
|
...defaultHotkeyOptions,
|
|
27
25
|
...merged,
|
|
28
|
-
ignoreInputs: merged.ignoreInputs ?? "auto",
|
|
29
26
|
target: "target" in merged ? merged.target ?? null : typeof document === "undefined" ? null : document,
|
|
30
27
|
platform: merged.platform ?? detectPlatform()
|
|
31
28
|
};
|
|
32
|
-
if (!options.target || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
|
|
33
|
-
const
|
|
29
|
+
if (!options.target || !definition.hotkey || isHotkeySequence(definition.hotkey) && definition.hotkey.length === 0) return;
|
|
30
|
+
const parsed = parseHotkey(definition.hotkey, options.platform);
|
|
31
|
+
if (!(Array.isArray(parsed) ? parsed : [parsed]).every(chordHasKey)) return;
|
|
32
|
+
const hotkey = normalizeHotkey(parsed, options.platform);
|
|
34
33
|
if (isHotkeySequence(hotkey)) delete options.requireReset;
|
|
35
34
|
else delete options.timeout;
|
|
36
35
|
prepared.push({
|
|
@@ -89,13 +88,8 @@ function prepareHotkeyDefinitions(definitions, options = {}) {
|
|
|
89
88
|
declared = getAppHotkeyRegistrationOptions(appHotkey);
|
|
90
89
|
hotkey = appHotkey.hotkey ?? [];
|
|
91
90
|
}
|
|
92
|
-
const { target, ...merged } =
|
|
93
|
-
|
|
94
|
-
...declared,
|
|
95
|
-
...options.commonOptions,
|
|
96
|
-
...definition.options
|
|
97
|
-
};
|
|
98
|
-
const resolved = target === void 0 ? void 0 : options.resolveTarget ? options.resolveTarget(target) : target ?? void 0;
|
|
91
|
+
const { target, ...merged } = definedOptions(options.defaults, declared, options.commonOptions, definition.options);
|
|
92
|
+
const resolved = target === void 0 ? void 0 : options.resolveTarget ? options.resolveTarget(target) : target;
|
|
99
93
|
return {
|
|
100
94
|
hotkey,
|
|
101
95
|
callback: definition.callback,
|
|
@@ -136,14 +130,10 @@ function subscribePublishedAppHotkeys(onChange) {
|
|
|
136
130
|
function mergeHotkeysProviderDefaults(parent, own, scope) {
|
|
137
131
|
return {
|
|
138
132
|
hotkey: {
|
|
139
|
-
...parent?.hotkey,
|
|
140
|
-
...own?.hotkey,
|
|
133
|
+
...definedOptions(parent?.hotkey, own?.hotkey),
|
|
141
134
|
...scope && { scope }
|
|
142
135
|
},
|
|
143
|
-
hotkeyRecorder:
|
|
144
|
-
...parent?.hotkeyRecorder,
|
|
145
|
-
...own?.hotkeyRecorder
|
|
146
|
-
}
|
|
136
|
+
hotkeyRecorder: definedOptions(parent?.hotkeyRecorder, own?.hotkeyRecorder)
|
|
147
137
|
};
|
|
148
138
|
}
|
|
149
139
|
/**
|
|
@@ -173,4 +163,4 @@ function activateHotkeysProviderScope(key) {
|
|
|
173
163
|
}
|
|
174
164
|
|
|
175
165
|
//#endregion
|
|
176
|
-
export { activateHotkeysProviderScope, createAppHotkeysStore, createHotkeyBindings, createStore, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys };
|
|
166
|
+
export { activateHotkeysProviderScope, createAppHotkeysStore, createHotkeyBindings, createStore, definedOptions, getHotkeysProviderScopeKey, mergeHotkeysProviderDefaults, prepareHotkeyDefinitions, subscribePublishedAppHotkeys, withDefaults };
|
package/dist/app-hotkeys.d.ts
CHANGED
|
@@ -135,7 +135,13 @@ export interface AppHotkeysState<T extends AppHotkeys = AppHotkeys> {
|
|
|
135
135
|
}
|
|
136
136
|
/** Options for `AppHotkeysStore`. */
|
|
137
137
|
export interface AppHotkeysStoreOptions<T extends AppHotkeys = AppHotkeys> {
|
|
138
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* The user's saved custom hotkeys. Invalid entries are ignored with a
|
|
140
|
+
* warning. Passing a different value to `setOptions` later, such as data
|
|
141
|
+
* that finished loading or another user's data, replaces the current
|
|
142
|
+
* custom hotkeys. `undefined` leaves them unchanged; a change to `null`
|
|
143
|
+
* clears them.
|
|
144
|
+
*/
|
|
139
145
|
customHotkeys?: CustomHotkeys<T> | null;
|
|
140
146
|
/** Called with the custom hotkeys after every change, ready to save. */
|
|
141
147
|
onCustomHotkeysChange?: (customHotkeys: CustomHotkeys<T>) => void;
|
|
@@ -165,8 +171,17 @@ export declare class AppHotkeysStore<T extends AppHotkeys = AppHotkeys> {
|
|
|
165
171
|
get appHotkeys(): T;
|
|
166
172
|
/** The user's hotkeys that differ from the defaults. */
|
|
167
173
|
get customHotkeys(): CustomHotkeys<T>;
|
|
168
|
-
/**
|
|
169
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Updates options. Providers call it with their props on every update.
|
|
176
|
+
*
|
|
177
|
+
* When `customHotkeys` differs from the value last passed in, it replaces
|
|
178
|
+
* the current custom hotkeys. An equal value, such as the same saved data
|
|
179
|
+
* parsed again, keeps changes the user made since. `undefined` changes
|
|
180
|
+
* nothing, such as while saved data loads; `null` clears them, such as on
|
|
181
|
+
* sign-out. Replacing does not call `onCustomHotkeysChange`, because the
|
|
182
|
+
* data came from the app.
|
|
183
|
+
*/
|
|
184
|
+
setOptions(options: AppHotkeysStoreOptions<T>): void;
|
|
170
185
|
/**
|
|
171
186
|
* Replaces the declarations, for example after hot module replacement.
|
|
172
187
|
* Custom hotkeys for ids that still exist are kept.
|
package/dist/app-hotkeys.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { detectPlatform } from "./platform.js";
|
|
2
2
|
import { isHotkeySequence, simplifyHotkey } from "./parse.js";
|
|
3
3
|
import { validateHotkey } from "./validate.js";
|
|
4
|
+
import { GLOBAL_SCOPE } from "./_registration.js";
|
|
4
5
|
import { areHotkeysEqual } from "./match.js";
|
|
5
6
|
import { createStore } from "./_store.js";
|
|
6
|
-
import { GLOBAL_SCOPE } from "./_registration.js";
|
|
7
7
|
import { getHotkeyManager } from "./hotkey-manager.js";
|
|
8
8
|
import { setAppHotkeysStoreFactory } from "./_app-hotkeys-factory.js";
|
|
9
9
|
|
|
@@ -88,6 +88,31 @@ function sanitizeCustomHotkeys(appHotkeys, saved) {
|
|
|
88
88
|
}
|
|
89
89
|
return result;
|
|
90
90
|
}
|
|
91
|
+
/** Whether two saved values hold the same data. Nothing saved equals `{}`. */
|
|
92
|
+
function sameSavedValue(left, right) {
|
|
93
|
+
if (Object.is(left, right)) return true;
|
|
94
|
+
if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((step, i) => Object.is(step, right[i]));
|
|
95
|
+
if (typeof left !== "object" || typeof right !== "object") return false;
|
|
96
|
+
if (left === null || right === null) return false;
|
|
97
|
+
const keys = Object.keys(left);
|
|
98
|
+
return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameSavedValue(left[key], right[key]));
|
|
99
|
+
}
|
|
100
|
+
/** Copies saved custom hotkeys, so later changes made in place are detected. */
|
|
101
|
+
function copySavedCustomHotkeys(saved) {
|
|
102
|
+
if (typeof saved !== "object" || saved === null) return saved;
|
|
103
|
+
const copy = {};
|
|
104
|
+
for (const [id, value] of Object.entries(saved)) copy[id] = Array.isArray(value) ? [...value] : value;
|
|
105
|
+
return copy;
|
|
106
|
+
}
|
|
107
|
+
/** Whether two saved custom hotkeys objects hold the same data. */
|
|
108
|
+
function sameSavedCustomHotkeys(left, right) {
|
|
109
|
+
return sameSavedValue(left === void 0 ? {} : left, right === void 0 ? {} : right);
|
|
110
|
+
}
|
|
111
|
+
/** Whether two sanitized custom hotkeys objects bind the same hotkeys. */
|
|
112
|
+
function sameCustomHotkeys(left, right, platform) {
|
|
113
|
+
const keys = Object.keys(left);
|
|
114
|
+
return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && hotkeysEqual(left[key] ?? null, right[key] ?? null, platform));
|
|
115
|
+
}
|
|
91
116
|
/** Whether two app hotkeys can apply at the same time. */
|
|
92
117
|
function scopesOverlap(a, b) {
|
|
93
118
|
const left = a.scope ?? "global";
|
|
@@ -121,9 +146,12 @@ var AppHotkeysStore = class {
|
|
|
121
146
|
store;
|
|
122
147
|
#declarations;
|
|
123
148
|
#options;
|
|
149
|
+
/** The `customHotkeys` option last passed in, compared by value. */
|
|
150
|
+
#savedCustomHotkeys;
|
|
124
151
|
constructor(appHotkeys, options = {}) {
|
|
125
152
|
this.#declarations = appHotkeys;
|
|
126
153
|
this.#options = options;
|
|
154
|
+
this.#savedCustomHotkeys = copySavedCustomHotkeys(options.customHotkeys);
|
|
127
155
|
const customHotkeys = sanitizeCustomHotkeys(appHotkeys, options.customHotkeys);
|
|
128
156
|
this.store = createStore({
|
|
129
157
|
appHotkeys: applyCustomHotkeys(appHotkeys, customHotkeys),
|
|
@@ -141,12 +169,31 @@ var AppHotkeysStore = class {
|
|
|
141
169
|
get customHotkeys() {
|
|
142
170
|
return this.store.state.customHotkeys;
|
|
143
171
|
}
|
|
144
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Updates options. Providers call it with their props on every update.
|
|
174
|
+
*
|
|
175
|
+
* When `customHotkeys` differs from the value last passed in, it replaces
|
|
176
|
+
* the current custom hotkeys. An equal value, such as the same saved data
|
|
177
|
+
* parsed again, keeps changes the user made since. `undefined` changes
|
|
178
|
+
* nothing, such as while saved data loads; `null` clears them, such as on
|
|
179
|
+
* sign-out. Replacing does not call `onCustomHotkeysChange`, because the
|
|
180
|
+
* data came from the app.
|
|
181
|
+
*/
|
|
145
182
|
setOptions(options) {
|
|
183
|
+
const { customHotkeys, ...rest } = options;
|
|
146
184
|
this.#options = {
|
|
147
185
|
...this.#options,
|
|
148
|
-
...
|
|
186
|
+
...rest
|
|
149
187
|
};
|
|
188
|
+
if (customHotkeys === void 0) return;
|
|
189
|
+
if (sameSavedCustomHotkeys(customHotkeys, this.#savedCustomHotkeys)) return;
|
|
190
|
+
this.#savedCustomHotkeys = copySavedCustomHotkeys(customHotkeys);
|
|
191
|
+
const next = sanitizeCustomHotkeys(this.#declarations, customHotkeys);
|
|
192
|
+
if (sameCustomHotkeys(next, this.store.state.customHotkeys, this.#platform)) return;
|
|
193
|
+
this.store.setState(() => ({
|
|
194
|
+
appHotkeys: applyCustomHotkeys(this.#declarations, next),
|
|
195
|
+
customHotkeys: next
|
|
196
|
+
}));
|
|
150
197
|
}
|
|
151
198
|
/**
|
|
152
199
|
* Replaces the declarations, for example after hot module replacement.
|
package/dist/hint.d.ts
CHANGED
|
@@ -11,11 +11,12 @@ export interface HeldModifierOptions {
|
|
|
11
11
|
* By default, any nonempty subset of the binding's modifiers qualifies; an
|
|
12
12
|
* unrelated modifier hides the hint. `exact` requires every binding modifier.
|
|
13
13
|
* Nonmodifier keys are ignored. This does not check registration or focus.
|
|
14
|
-
* A sequence reveals its hint from its first step.
|
|
14
|
+
* A sequence reveals its hint from its first step. Returns false for no
|
|
15
|
+
* hotkey (`undefined`, `null`, or `''`).
|
|
15
16
|
*
|
|
16
17
|
* @example
|
|
17
18
|
* matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt']) // true
|
|
18
19
|
* matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt', 'Control']) // false
|
|
19
20
|
*/
|
|
20
|
-
export declare function matchesHeldModifiers(hotkey: RegisterableHotkey, heldKeys: ReadonlyArray<string>, options?: HeldModifierOptions): boolean;
|
|
21
|
+
export declare function matchesHeldModifiers(hotkey: RegisterableHotkey | null | undefined, heldKeys: ReadonlyArray<string>, options?: HeldModifierOptions): boolean;
|
|
21
22
|
//#endregion
|
package/dist/hint.js
CHANGED
|
@@ -7,13 +7,15 @@ import { isHotkeySequence, parseHotkey } from "./parse.js";
|
|
|
7
7
|
* By default, any nonempty subset of the binding's modifiers qualifies; an
|
|
8
8
|
* unrelated modifier hides the hint. `exact` requires every binding modifier.
|
|
9
9
|
* Nonmodifier keys are ignored. This does not check registration or focus.
|
|
10
|
-
* A sequence reveals its hint from its first step.
|
|
10
|
+
* A sequence reveals its hint from its first step. Returns false for no
|
|
11
|
+
* hotkey (`undefined`, `null`, or `''`).
|
|
11
12
|
*
|
|
12
13
|
* @example
|
|
13
14
|
* matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt']) // true
|
|
14
15
|
* matchesHeldModifiers('Alt+Shift+[KeyK]', ['Alt', 'Control']) // false
|
|
15
16
|
*/
|
|
16
17
|
function matchesHeldModifiers(hotkey, heldKeys, options = {}) {
|
|
18
|
+
if (!hotkey) return false;
|
|
17
19
|
if (heldKeys.includes("AltGraph")) return false;
|
|
18
20
|
const held = [...new Set(heldKeys.map(normalizeKeyName).filter((key) => MODIFIER_KEYS.has(key)))];
|
|
19
21
|
const first = isHotkeySequence(hotkey) ? hotkey[0] : hotkey;
|
package/dist/hotkey-manager.d.ts
CHANGED
|
@@ -385,10 +385,11 @@ export declare class HotkeyManager {
|
|
|
385
385
|
* only while focus is inside that element. An app hotkey matches
|
|
386
386
|
* registrations by id, so custom hotkeys apply.
|
|
387
387
|
*
|
|
388
|
-
* @param hotkey - A chord, sequence, or app hotkey
|
|
388
|
+
* @param hotkey - A chord, sequence, or app hotkey. No hotkey
|
|
389
|
+
* (`undefined`, `null`, or `''`) returns false.
|
|
389
390
|
* @returns True if a registration that can fire exists
|
|
390
391
|
*/
|
|
391
|
-
hasActiveRegistration(hotkey: RegisterableHotkey | AppHotkey): boolean;
|
|
392
|
+
hasActiveRegistration(hotkey: RegisterableHotkey | AppHotkey | null | undefined): boolean;
|
|
392
393
|
/**
|
|
393
394
|
* Destroys the manager and removes all listeners.
|
|
394
395
|
*/
|
package/dist/hotkey-manager.js
CHANGED
|
@@ -4,13 +4,13 @@ import { isKeyNameShape, keysEqual, normalizeKeyboardEvent } from "./_keyboard-e
|
|
|
4
4
|
import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
|
|
5
5
|
import { formatHotkey } from "./format.js";
|
|
6
6
|
import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
|
|
7
|
+
import { GLOBAL_SCOPE, chordHasKey, defaultHotkeyOptions, definedOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
|
|
7
8
|
import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
|
|
8
9
|
import { getTargetDocument, isDocumentLevel, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
|
|
9
10
|
import { createStore } from "./_store.js";
|
|
10
11
|
import { TargetListeners } from "./_target-listeners.js";
|
|
11
12
|
import { managerRef } from "./_manager-ref.js";
|
|
12
13
|
import { isRecordingEvent } from "./_recording-guard.js";
|
|
13
|
-
import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
|
|
14
14
|
|
|
15
15
|
//#region src/hotkey-manager.ts
|
|
16
16
|
/** Whether a registration can fire now. Without a manager, scopes don't apply. */
|
|
@@ -57,6 +57,31 @@ function chordString(chord, parsed) {
|
|
|
57
57
|
function generateId() {
|
|
58
58
|
return `hotkey_${++registrationIdCounter}`;
|
|
59
59
|
}
|
|
60
|
+
/** A handle for a registration that listens for nothing. */
|
|
61
|
+
function inactiveHandle(callback) {
|
|
62
|
+
let currentCallback = callback;
|
|
63
|
+
return {
|
|
64
|
+
id: generateId(),
|
|
65
|
+
unregister: () => {},
|
|
66
|
+
get callback() {
|
|
67
|
+
return currentCallback;
|
|
68
|
+
},
|
|
69
|
+
set callback(newCallback) {
|
|
70
|
+
currentCallback = newCallback;
|
|
71
|
+
},
|
|
72
|
+
setOptions: () => {},
|
|
73
|
+
get isActive() {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Defaults for a registration of the given kind. */
|
|
79
|
+
function registrationDefaults(kind) {
|
|
80
|
+
return {
|
|
81
|
+
...defaultHotkeyOptions,
|
|
82
|
+
...kind === "sequence" ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false }
|
|
83
|
+
};
|
|
84
|
+
}
|
|
60
85
|
/** Drops options that do not apply to a registration's kind. */
|
|
61
86
|
function optionsForKind(kind, options) {
|
|
62
87
|
if (kind === "chord") {
|
|
@@ -297,37 +322,19 @@ var HotkeyManager = class HotkeyManager {
|
|
|
297
322
|
}
|
|
298
323
|
register(input, callback, options = {}) {
|
|
299
324
|
const hotkey = simplifyHotkey(input);
|
|
300
|
-
if (isHotkeySequence(hotkey) && hotkey.length === 0
|
|
301
|
-
if (typeof document === "undefined" && !options.target) {
|
|
302
|
-
let currentCallback = callback;
|
|
303
|
-
return {
|
|
304
|
-
id: generateId(),
|
|
305
|
-
unregister: () => {},
|
|
306
|
-
get callback() {
|
|
307
|
-
return currentCallback;
|
|
308
|
-
},
|
|
309
|
-
set callback(newCallback) {
|
|
310
|
-
currentCallback = newCallback;
|
|
311
|
-
},
|
|
312
|
-
setOptions: () => {},
|
|
313
|
-
get isActive() {
|
|
314
|
-
return false;
|
|
315
|
-
}
|
|
316
|
-
};
|
|
317
|
-
}
|
|
325
|
+
if (hotkey == null || isHotkeySequence(hotkey) && hotkey.length === 0 || typeof document === "undefined" && !options.target) return inactiveHandle(callback);
|
|
318
326
|
const id = generateId();
|
|
319
327
|
const platform = options.platform ?? this.#platform;
|
|
320
328
|
const target = options.target ?? document;
|
|
321
329
|
const sequence = isHotkeySequence(hotkey);
|
|
330
|
+
const kind = sequence ? "sequence" : "chord";
|
|
322
331
|
const common = {
|
|
323
332
|
id,
|
|
324
333
|
callback,
|
|
325
334
|
options: {
|
|
326
|
-
...
|
|
327
|
-
...
|
|
328
|
-
|
|
329
|
-
platform,
|
|
330
|
-
ignoreInputs: options.ignoreInputs ?? "auto"
|
|
335
|
+
...registrationDefaults(kind),
|
|
336
|
+
...optionsForKind(kind, definedOptions(options)),
|
|
337
|
+
platform
|
|
331
338
|
},
|
|
332
339
|
target,
|
|
333
340
|
triggerCount: 0,
|
|
@@ -353,14 +360,15 @@ var HotkeyManager = class HotkeyManager {
|
|
|
353
360
|
parsedChord
|
|
354
361
|
};
|
|
355
362
|
}
|
|
363
|
+
const steps = registration.kind === "sequence" ? registration.parsedSequence : [registration.parsedChord];
|
|
364
|
+
if (!steps.every(chordHasKey)) return inactiveHandle(callback);
|
|
356
365
|
if (process.env.NODE_ENV !== "production") {
|
|
357
|
-
const steps = registration.kind === "sequence" ? registration.parsedSequence : [registration.parsedChord];
|
|
358
366
|
for (const { key } of steps) if (key !== void 0 && !isKeyNameShape(key)) console.warn(`Unknown key '${key}' in '${formatForMessage(registration.hotkey)}'. Key names are case-sensitive, such as 'Escape' and 'F1', so this hotkey may never fire.`);
|
|
359
367
|
}
|
|
360
368
|
const conflicting = this.#findConflictingRegistration(registration);
|
|
361
369
|
if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId));
|
|
362
|
-
this.registrations.setState((prev) => new Map(prev).set(id, registration));
|
|
363
370
|
this.#listeners.add(target, !!registration.options.capture, id);
|
|
371
|
+
this.registrations.setState((prev) => new Map(prev).set(id, registration));
|
|
364
372
|
const manager = this;
|
|
365
373
|
return {
|
|
366
374
|
get id() {
|
|
@@ -380,10 +388,15 @@ var HotkeyManager = class HotkeyManager {
|
|
|
380
388
|
manager.registrations.setState((prev) => {
|
|
381
389
|
const reg = prev.get(id);
|
|
382
390
|
if (!reg) return prev;
|
|
383
|
-
const
|
|
384
|
-
...reg.
|
|
385
|
-
|
|
391
|
+
const defaults = {
|
|
392
|
+
...registrationDefaults(reg.kind),
|
|
393
|
+
platform: reg.options.platform
|
|
386
394
|
};
|
|
395
|
+
const options = { ...reg.options };
|
|
396
|
+
const entries = Object.entries(optionsForKind(reg.kind, newOptions));
|
|
397
|
+
for (const [key, value] of entries) if (value !== void 0) options[key] = value;
|
|
398
|
+
else if (key in defaults) options[key] = defaults[key];
|
|
399
|
+
else delete options[key];
|
|
387
400
|
if (optionsEqual(reg.options, options)) return prev;
|
|
388
401
|
const capture = !!reg.options.capture;
|
|
389
402
|
if (capture !== !!options.capture) {
|
|
@@ -504,7 +517,7 @@ var HotkeyManager = class HotkeyManager {
|
|
|
504
517
|
this.#fire(registration, event);
|
|
505
518
|
continue;
|
|
506
519
|
}
|
|
507
|
-
if (!registration.options.requireReset || !registration.hasFired) {
|
|
520
|
+
if (!registration.options.requireReset || !registration.hasFired || !event.repeat) {
|
|
508
521
|
if (registration.options.requireReset) {
|
|
509
522
|
registration.hasFired = true;
|
|
510
523
|
registration.activeMatch = match.identity;
|
|
@@ -635,13 +648,13 @@ var HotkeyManager = class HotkeyManager {
|
|
|
635
648
|
};
|
|
636
649
|
}
|
|
637
650
|
/**
|
|
638
|
-
* Finds an existing registration on the same target and
|
|
651
|
+
* Finds an existing registration on the same target, scope, and event type whose steps
|
|
639
652
|
* resolve to the same identity. Each side keeps its own platform, so `Mod+S` on macOS
|
|
640
653
|
* and `Mod+S` on Windows are different bindings.
|
|
641
654
|
*/
|
|
642
655
|
#findConflictingRegistration(candidate) {
|
|
643
656
|
const steps = resolvedSteps(candidate);
|
|
644
|
-
for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
|
|
657
|
+
for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && registration.options.eventType === candidate.options.eventType && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
|
|
645
658
|
return null;
|
|
646
659
|
}
|
|
647
660
|
/**
|
|
@@ -689,10 +702,12 @@ var HotkeyManager = class HotkeyManager {
|
|
|
689
702
|
* only while focus is inside that element. An app hotkey matches
|
|
690
703
|
* registrations by id, so custom hotkeys apply.
|
|
691
704
|
*
|
|
692
|
-
* @param hotkey - A chord, sequence, or app hotkey
|
|
705
|
+
* @param hotkey - A chord, sequence, or app hotkey. No hotkey
|
|
706
|
+
* (`undefined`, `null`, or `''`) returns false.
|
|
693
707
|
* @returns True if a registration that can fire exists
|
|
694
708
|
*/
|
|
695
709
|
hasActiveRegistration(hotkey) {
|
|
710
|
+
if (!hotkey) return false;
|
|
696
711
|
const id = typeof hotkey === "object" && "~appHotkey" in hotkey ? hotkey.id : void 0;
|
|
697
712
|
for (const registration of this.registrations.state.values()) if (this.#isLive(registration) && targetHasFocus(registration.target) && (id === void 0 ? areHotkeysEqual(registration.hotkey, hotkey, registration.options.platform) : registration.options.appHotkeyId === id)) return true;
|
|
698
713
|
return false;
|
|
@@ -100,11 +100,14 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
100
100
|
for (const [entryIdentity, entry] of this.#heldEntries) if (entry.key === key) this.#heldEntries.delete(entryIdentity);
|
|
101
101
|
}
|
|
102
102
|
this.#reconcileModifiers(event);
|
|
103
|
-
if (MODIFIER_KEYS.has(key))
|
|
104
|
-
for (const [entryIdentity, entry] of this.#heldEntries) if (!MODIFIER_KEYS.has(entry.key)) this.#heldEntries.delete(entryIdentity);
|
|
105
|
-
}
|
|
103
|
+
if (MODIFIER_KEYS.has(key)) this.#releaseNonModifiers();
|
|
106
104
|
this.#syncState();
|
|
107
105
|
};
|
|
106
|
+
const onContextMenu = (event) => {
|
|
107
|
+
if (event.defaultPrevented || !event.isTrusted) return;
|
|
108
|
+
const reconciled = this.#reconcileModifiers(event);
|
|
109
|
+
if (this.#releaseNonModifiers() || reconciled) this.#syncState();
|
|
110
|
+
};
|
|
108
111
|
const onMouse = (event) => {
|
|
109
112
|
if (this.#reconcileModifiers(event)) this.#syncState();
|
|
110
113
|
};
|
|
@@ -139,6 +142,16 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
139
142
|
"blur",
|
|
140
143
|
onBlur,
|
|
141
144
|
false
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
"contextmenu",
|
|
148
|
+
onContextMenu,
|
|
149
|
+
false
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
"visibilitychange",
|
|
153
|
+
() => document.hidden && onBlur(),
|
|
154
|
+
false
|
|
142
155
|
]
|
|
143
156
|
];
|
|
144
157
|
for (const [type, listener, capture] of this.#listeners) window.addEventListener(type, listener, capture);
|
|
@@ -159,6 +172,15 @@ var KeyStateTracker = class KeyStateTracker {
|
|
|
159
172
|
}
|
|
160
173
|
return changed;
|
|
161
174
|
}
|
|
175
|
+
/** Forgets every held key that isn't a modifier. Returns whether any was held. */
|
|
176
|
+
#releaseNonModifiers() {
|
|
177
|
+
let changed = false;
|
|
178
|
+
for (const [identity, entry] of this.#heldEntries) if (!MODIFIER_KEYS.has(entry.key)) {
|
|
179
|
+
this.#heldEntries.delete(identity);
|
|
180
|
+
changed = true;
|
|
181
|
+
}
|
|
182
|
+
return changed;
|
|
183
|
+
}
|
|
162
184
|
/**
|
|
163
185
|
* Syncs the internal Set to the Store state.
|
|
164
186
|
*/
|
package/dist/match.js
CHANGED
|
@@ -4,6 +4,7 @@ import { normalizeKeyboardEvent } from "./_keyboard-event.js";
|
|
|
4
4
|
import { isHotkeySequence, isModifierKey, parseHotkey } from "./parse.js";
|
|
5
5
|
import { formatHotkey } from "./format.js";
|
|
6
6
|
import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
|
|
7
|
+
import { chordHasKey } from "./_registration.js";
|
|
7
8
|
|
|
8
9
|
//#region src/match.ts
|
|
9
10
|
/**
|
|
@@ -36,7 +37,9 @@ import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
|
|
|
36
37
|
* ```
|
|
37
38
|
*/
|
|
38
39
|
function matchesKeyboardEvent(event, chord, platform = detectPlatform()) {
|
|
39
|
-
|
|
40
|
+
const parsed = typeof chord === "string" ? parseHotkey(chord, platform) : chord;
|
|
41
|
+
if (!chordHasKey(parsed)) return false;
|
|
42
|
+
return matchKeyboardEvent(event, parsed, platform).matched;
|
|
40
43
|
}
|
|
41
44
|
/**
|
|
42
45
|
* Creates a keyboard event handler that calls the callback when the chord
|
package/dist/validate.d.ts
CHANGED
|
@@ -50,9 +50,13 @@ export declare function assertValidHotkey(hotkey: ValidatableHotkey): void;
|
|
|
50
50
|
*
|
|
51
51
|
* @example
|
|
52
52
|
* ```ts
|
|
53
|
-
* checkHotkey('
|
|
54
|
-
* // Console:
|
|
53
|
+
* checkHotkey('Control+escape')
|
|
54
|
+
* // Console: Hotkey 'Control+escape' warnings: Unknown key: 'escape'. Key names are case-sensitive, such as 'Escape' and 'F1'.
|
|
55
55
|
* // Returns: true
|
|
56
|
+
*
|
|
57
|
+
* checkHotkey('Ctl+K')
|
|
58
|
+
* // Console: Hotkey 'Ctl+K' errors: Unknown modifier: 'Ctl'
|
|
59
|
+
* // Returns: false
|
|
56
60
|
* ```
|
|
57
61
|
*/
|
|
58
62
|
export declare function checkHotkey(hotkey: ValidatableHotkey): boolean;
|
package/dist/validate.js
CHANGED
|
@@ -94,9 +94,13 @@ function assertValidHotkey(hotkey) {
|
|
|
94
94
|
*
|
|
95
95
|
* @example
|
|
96
96
|
* ```ts
|
|
97
|
-
* checkHotkey('
|
|
98
|
-
* // Console:
|
|
97
|
+
* checkHotkey('Control+escape')
|
|
98
|
+
* // Console: Hotkey 'Control+escape' warnings: Unknown key: 'escape'. Key names are case-sensitive, such as 'Escape' and 'F1'.
|
|
99
99
|
* // Returns: true
|
|
100
|
+
*
|
|
101
|
+
* checkHotkey('Ctl+K')
|
|
102
|
+
* // Console: Hotkey 'Ctl+K' errors: Unknown modifier: 'Ctl'
|
|
103
|
+
* // Returns: false
|
|
100
104
|
* ```
|
|
101
105
|
*/
|
|
102
106
|
function checkHotkey(hotkey) {
|
package/package.json
CHANGED
|
@@ -46,12 +46,11 @@ export const appHotkeys = defineAppHotkeys({
|
|
|
46
46
|
})
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Augment `Register` in the adapter module you import from, such as `declare module '@tanstack/react-hotkeys' { interface Register { appHotkeys: typeof appHotkeys } }`, to type ids. Lit, Alpine, and Ember have no provider: call `provideAppHotkeys(appHotkeys, options)` once at startup.
|
|
49
|
+
Augment `Register` in the adapter module you import from, such as `declare module '@tanstack/react-hotkeys' { interface Register { appHotkeys: typeof appHotkeys } }`, to type ids. Lit, Alpine, and Ember have no provider: call `provideAppHotkeys(appHotkeys, options)` once at startup. Pass saved data that loads later to `store.setOptions({ customHotkeys })` on the returned store; providers apply a changed `customHotkeys` prop themselves. `undefined` leaves custom hotkeys unchanged while data loads; `null` clears them, such as on sign-out.
|
|
50
50
|
|
|
51
51
|
## Pitfalls
|
|
52
52
|
|
|
53
53
|
- **Register the app hotkey, not its value.** Pass `appHotkeys['doc.save']`. Passing `.hotkey` registers the declared default, so the user's rebinding never applies, and the list reports the shortcut as `'inactive'`.
|
|
54
|
-
- **The provider reads `customHotkeys` once.** When saved shortcuts load asynchronously, render the provider after they arrive, or key it by user. Change shortcuts afterwards through the adapter's custom hotkeys API, not by updating the prop.
|
|
55
54
|
- **Save what `onCustomHotkeysChange` receives.** It holds only the shortcuts the user changed, so users still get new defaults. Saving every effective hotkey freezes today's defaults into their data.
|
|
56
55
|
- **Ids are a storage contract.** Saved data is keyed by id, and unknown ids are dropped with a warning, so renaming an id discards users' customizations.
|
|
57
56
|
- **`setCustomHotkey` returns conflicts and changes nothing when there are any.** Check the result before reporting success, or reject the key press earlier by calling `findConflicts` in the recorder's `validate`.
|
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
- **Reset between tests.** Call `HotkeyManager.resetInstance()`, plus `KeyStateTracker.resetInstance()` for held-key tests, in `afterEach`. Registrations and scope activations otherwise outlive a test that fails before unmounting.
|
|
5
5
|
- **Dispatch real events.** Use `new KeyboardEvent('keydown', { key: 's', code: 'KeyS', metaKey: true, bubbles: true, cancelable: true })`. Physical bindings such as `[KeyS]` match only `code`. Check `event.defaultPrevented` to assert the browser action was blocked.
|
|
6
6
|
- **Dispatch where focus is.** For a hotkey with a `target`, or to test typing in a field, focus the element and dispatch on it with `bubbles: true`.
|
|
7
|
-
- **Repeats and sequences.** `repeat: true` simulates holding a key; it fires chords again unless they set `requireReset`, and it never advances a sequence. Use fake timers to test a sequence's `timeout` (1000 ms by default).
|
|
7
|
+
- **Repeats and sequences.** `repeat: true` simulates holding a key; it fires chords again unless they set `requireReset`, and it never advances a sequence. A keydown without `repeat` is a new press, so it fires a `requireReset` chord even without a keyup in between. Use fake timers to test a sequence's `timeout` (1000 ms by default).
|