@tanstack/hotkeys 0.16.1 → 0.18.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/_event-target.js +12 -1
- package/dist/app-hotkeys.js +2 -3
- package/dist/hotkey-manager.d.ts +29 -3
- package/dist/hotkey-manager.js +46 -6
- package/dist/index.d.ts +2 -2
- package/package.json +5 -3
- package/skills/app-hotkeys/SKILL.md +62 -0
- package/skills/app-hotkeys/references/recording.md +9 -0
- package/skills/hotkeys-core/SKILL.md +73 -0
- package/skills/hotkeys-core/references/scopes.md +13 -0
- package/skills/hotkeys-core/references/testing.md +7 -0
package/dist/_event-target.js
CHANGED
|
@@ -17,6 +17,17 @@ function getTargetDocument(target) {
|
|
|
17
17
|
return target.ownerDocument;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
+
* Whether key presses reach a listener on `target` right now. Document and
|
|
21
|
+
* window listeners receive every key press in their document. An element
|
|
22
|
+
* receives them only while focus is inside it, including inside a shadow
|
|
23
|
+
* root it contains.
|
|
24
|
+
*/
|
|
25
|
+
function targetHasFocus(target) {
|
|
26
|
+
if (isWindow(target) || target.nodeType === 9) return true;
|
|
27
|
+
const active = target.getRootNode().activeElement;
|
|
28
|
+
return !!active && target.contains(active);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
20
31
|
* Checks if an element is an input-like element that should be ignored for hotkeys.
|
|
21
32
|
*
|
|
22
33
|
* This includes:
|
|
@@ -191,4 +202,4 @@ function isEventForTarget(event, target) {
|
|
|
191
202
|
}
|
|
192
203
|
|
|
193
204
|
//#endregion
|
|
194
|
-
export { getActiveElementForListenerTarget, getTargetDocument, isEventForTarget, isInputElement, isTypingEvent, shouldIgnoreInputEvent };
|
|
205
|
+
export { getActiveElementForListenerTarget, getTargetDocument, isEventForTarget, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };
|
package/dist/app-hotkeys.js
CHANGED
|
@@ -227,13 +227,12 @@ var AppHotkeysStore = class {
|
|
|
227
227
|
if (list) list.push(registration);
|
|
228
228
|
else byId.set(id, [registration]);
|
|
229
229
|
}
|
|
230
|
-
const manager = getHotkeyManager();
|
|
231
230
|
return Object.values(this.store.state.appHotkeys).map((appHotkey) => {
|
|
232
231
|
const registered = byId.get(appHotkey.id) ?? [];
|
|
233
|
-
const
|
|
232
|
+
const has = (status) => registered.some((r) => r.status === status);
|
|
234
233
|
return {
|
|
235
234
|
appHotkey,
|
|
236
|
-
status: appHotkey.hotkey === null ? "unbound" : registered.length === 0 ? "inactive" :
|
|
235
|
+
status: appHotkey.hotkey === null ? "unbound" : registered.length === 0 ? "inactive" : has("active") ? "active" : has("suspended") ? "suspended" : "disabled",
|
|
237
236
|
isCustom: isCustomHotkey(appHotkey, this.#platform),
|
|
238
237
|
registrations: registered
|
|
239
238
|
};
|
package/dist/hotkey-manager.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ConflictBehavior, HotkeyCallback, HotkeyCallbackContextFor, HotkeyChord, HotkeyMeta, HotkeySequence, ParsedHotkeyChord, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
|
|
2
|
-
import { AppHotkeysStore } from "./app-hotkeys.js";
|
|
2
|
+
import { AppHotkey, AppHotkeysStore } from "./app-hotkeys.js";
|
|
3
3
|
import { Store } from "@tanstack/store";
|
|
4
4
|
//#region src/hotkey-manager.d.ts
|
|
5
5
|
/** An element, document, or window that hotkeys can listen on. */
|
|
@@ -41,9 +41,14 @@ export interface HotkeyOptions {
|
|
|
41
41
|
platform?: 'mac' | 'windows' | 'linux';
|
|
42
42
|
/**
|
|
43
43
|
* Prevent the default browser action when the hotkey matches. For a
|
|
44
|
-
* sequence, every step that advances it does. Defaults to true
|
|
44
|
+
* sequence, every step that advances it does. Defaults to true.
|
|
45
|
+
*
|
|
46
|
+
* `'always'` also prevents it while a chord can't fire because it is
|
|
47
|
+
* disabled or its scope is inactive or suspended, unless another hotkey
|
|
48
|
+
* handles the key. It never stops propagation for such a chord. Sequences
|
|
49
|
+
* treat `'always'` as `true`.
|
|
45
50
|
*/
|
|
46
|
-
preventDefault?: boolean;
|
|
51
|
+
preventDefault?: boolean | 'always';
|
|
47
52
|
/**
|
|
48
53
|
* Stop event propagation when the hotkey matches. For a sequence, only the
|
|
49
54
|
* last step does, so earlier steps reach other listeners. Defaults to true
|
|
@@ -118,8 +123,18 @@ export interface HotkeySequenceRegistration extends RegistrationBase<HotkeySeque
|
|
|
118
123
|
}
|
|
119
124
|
/** A registered hotkey in the HotkeyManager. */
|
|
120
125
|
export type HotkeyRegistration = HotkeyChordRegistration | HotkeySequenceRegistration;
|
|
126
|
+
/**
|
|
127
|
+
* Whether a registration is on. Focus isn't checked: a registration on an
|
|
128
|
+
* element also needs focus inside that element to fire.
|
|
129
|
+
* - `'active'`: enabled, and its scope is active
|
|
130
|
+
* - `'suspended'`: enabled, but its scope is inactive or suspended by an exclusive scope
|
|
131
|
+
* - `'disabled'`: registered with `enabled: false`
|
|
132
|
+
*/
|
|
133
|
+
export type HotkeyRegistrationStatus = 'active' | 'suspended' | 'disabled';
|
|
121
134
|
interface RegistrationViewBase {
|
|
122
135
|
id: string;
|
|
136
|
+
/** Whether the registration is enabled and in an active scope. */
|
|
137
|
+
status: HotkeyRegistrationStatus;
|
|
123
138
|
target: Target;
|
|
124
139
|
triggerCount: number;
|
|
125
140
|
hasFired: boolean;
|
|
@@ -372,6 +387,17 @@ export declare class HotkeyManager {
|
|
|
372
387
|
* @returns True if a matching registration exists
|
|
373
388
|
*/
|
|
374
389
|
isRegistered(hotkey: RegisterableHotkey, target?: Target): boolean;
|
|
390
|
+
/**
|
|
391
|
+
* Checks whether a hotkey can fire now: at least one registration is
|
|
392
|
+
* enabled, in an active scope, and listening where key presses go. A
|
|
393
|
+
* registration on `document` or `window` always is; one on an element is
|
|
394
|
+
* only while focus is inside that element. An app hotkey matches
|
|
395
|
+
* registrations by id, so custom hotkeys apply.
|
|
396
|
+
*
|
|
397
|
+
* @param hotkey - A chord, sequence, or app hotkey
|
|
398
|
+
* @returns True if a registration that can fire exists
|
|
399
|
+
*/
|
|
400
|
+
hasActiveRegistration(hotkey: RegisterableHotkey | AppHotkey): boolean;
|
|
375
401
|
/**
|
|
376
402
|
* Destroys the manager and removes all listeners.
|
|
377
403
|
*/
|
package/dist/hotkey-manager.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./
|
|
|
5
5
|
import { formatHotkey } from "./format.js";
|
|
6
6
|
import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
|
|
7
7
|
import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
|
|
8
|
-
import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
|
|
8
|
+
import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js";
|
|
9
9
|
import { TargetListeners } from "./_target-listeners.js";
|
|
10
10
|
import { managerRef } from "./_manager-ref.js";
|
|
11
11
|
import { isRecordingEvent } from "./_recording-guard.js";
|
|
@@ -13,10 +13,16 @@ import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConfl
|
|
|
13
13
|
import { Store } from "@tanstack/store";
|
|
14
14
|
|
|
15
15
|
//#region src/hotkey-manager.ts
|
|
16
|
+
/** Whether a registration can fire now. Without a manager, scopes don't apply. */
|
|
17
|
+
function getRegistrationStatus(options, manager) {
|
|
18
|
+
if (options.enabled === false) return "disabled";
|
|
19
|
+
return manager?.isScopeActive(options.scope) === false ? "suspended" : "active";
|
|
20
|
+
}
|
|
16
21
|
/** Creates a public view from an internal registration, stripping the callback. */
|
|
17
22
|
function toHotkeyRegistrationView(reg) {
|
|
18
23
|
const base = {
|
|
19
24
|
id: reg.id,
|
|
25
|
+
status: getRegistrationStatus(reg.options, managerRef.current),
|
|
20
26
|
target: reg.target,
|
|
21
27
|
triggerCount: reg.triggerCount,
|
|
22
28
|
hasFired: reg.hasFired
|
|
@@ -270,7 +276,20 @@ var HotkeyManager = class HotkeyManager {
|
|
|
270
276
|
}
|
|
271
277
|
/** Whether a registration can fire now: enabled, in an active scope. */
|
|
272
278
|
#isLive(registration) {
|
|
273
|
-
return registration.options
|
|
279
|
+
return getRegistrationStatus(registration.options, this) === "active";
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Whether a hotkey that can fire would handle this key on any listener the
|
|
283
|
+
* event reaches, including other targets and phases. A sequence counts when
|
|
284
|
+
* the key matches its next step or its first step.
|
|
285
|
+
*/
|
|
286
|
+
#handledByLiveHotkey(event, eventType) {
|
|
287
|
+
const path = event.composedPath();
|
|
288
|
+
for (const registration of this.registrations.state.values()) {
|
|
289
|
+
if (!this.#isLive(registration) || registration.options.eventType !== eventType || !path.includes(registration.target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, registration.target, registration.target)) continue;
|
|
290
|
+
if ((registration.kind === "chord" ? [registration.parsedChord] : [registration.parsedSequence[registration.currentIndex], registration.parsedSequence[0]]).some((step) => matchKeyboardEvent(event, step, registration.options.platform).matched)) return true;
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
274
293
|
}
|
|
275
294
|
/** Notifies subscribers after in-place updates such as counts and sequence progress. */
|
|
276
295
|
#notify() {
|
|
@@ -455,14 +474,20 @@ var HotkeyManager = class HotkeyManager {
|
|
|
455
474
|
if (isRecordingEvent(event) || this.#sequenceOwned.get(event)) return;
|
|
456
475
|
const matches = /* @__PURE__ */ new Map();
|
|
457
476
|
let bestScore = 0;
|
|
477
|
+
let reserved = false;
|
|
458
478
|
for (const registration of registrations) {
|
|
459
|
-
|
|
479
|
+
const live = this.#isLive(registration);
|
|
480
|
+
if (!live && registration.options.preventDefault !== "always" || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
|
|
460
481
|
const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform);
|
|
461
|
-
if (result.matched)
|
|
462
|
-
|
|
463
|
-
|
|
482
|
+
if (!result.matched) continue;
|
|
483
|
+
if (!live) {
|
|
484
|
+
reserved = true;
|
|
485
|
+
continue;
|
|
464
486
|
}
|
|
487
|
+
matches.set(registration.id, result);
|
|
488
|
+
bestScore = Math.max(bestScore, result.score);
|
|
465
489
|
}
|
|
490
|
+
if (reserved && matches.size === 0 && !this.#handledByLiveHotkey(event, eventType)) event.preventDefault();
|
|
466
491
|
let bestRank = -1;
|
|
467
492
|
for (const registration of registrations) if (matches.get(registration.id)?.score === bestScore) bestRank = Math.max(bestRank, this.#scopeRank(registration.options.scope));
|
|
468
493
|
for (const snapshot of registrations) {
|
|
@@ -679,6 +704,21 @@ var HotkeyManager = class HotkeyManager {
|
|
|
679
704
|
return false;
|
|
680
705
|
}
|
|
681
706
|
/**
|
|
707
|
+
* Checks whether a hotkey can fire now: at least one registration is
|
|
708
|
+
* enabled, in an active scope, and listening where key presses go. A
|
|
709
|
+
* registration on `document` or `window` always is; one on an element is
|
|
710
|
+
* only while focus is inside that element. An app hotkey matches
|
|
711
|
+
* registrations by id, so custom hotkeys apply.
|
|
712
|
+
*
|
|
713
|
+
* @param hotkey - A chord, sequence, or app hotkey
|
|
714
|
+
* @returns True if a registration that can fire exists
|
|
715
|
+
*/
|
|
716
|
+
hasActiveRegistration(hotkey) {
|
|
717
|
+
const id = typeof hotkey === "object" && "~appHotkey" in hotkey ? hotkey.id : void 0;
|
|
718
|
+
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;
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
682
722
|
* Destroys the manager and removes all listeners.
|
|
683
723
|
*/
|
|
684
724
|
destroy() {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CanonicalModifier, EditingKey, FunctionKey, IndividualKey, Key, LetterKey, LogicalKey, Modifier, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PhysicalKey, PhysicalKeyCode, PunctuationKey } from "./key.types.js";
|
|
2
2
|
import { ConflictBehavior, FourModifierHotkeyChord, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyMeta, HotkeySequence, HotkeySequenceCallbackContext, ParsedHotkeyChord, ParsedModifiers, RawHotkeyChord, RawModifiers, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, SingleModifierHotkeyChord, ThreeModifierHotkeyChord, TwoModifierHotkeyChord } from "./hotkey.types.js";
|
|
3
|
-
import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyManager, HotkeyOptions, HotkeyOptionsFor, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationView, HotkeyScopesState, HotkeySequenceOptions, HotkeySequenceRegistration, HotkeySequenceRegistrationView, Target, getHotkeyManager, toHotkeyRegistrationView } from "./hotkey-manager.js";
|
|
3
|
+
import { ActivateHotkeyScopeOptions, HotkeyChordOptions, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyManager, HotkeyOptions, HotkeyOptionsFor, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationStatus, HotkeyRegistrationView, HotkeyScopesState, HotkeySequenceOptions, HotkeySequenceRegistration, HotkeySequenceRegistrationView, Target, getHotkeyManager, toHotkeyRegistrationView } from "./hotkey-manager.js";
|
|
4
4
|
import { AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CustomHotkeys, DefinedAppHotkeys, Register, RegisteredAppHotkeys, defineAppHotkeys, getAppHotkeyRegistrationOptions, isAppHotkey, isCustomHotkey, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey } from "./app-hotkeys.js";
|
|
5
5
|
import { ALL_KEYS, EDITING_KEYS, FUNCTION_KEYS, LETTER_KEYS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, NAVIGATION_KEYS, NUMBER_KEYS, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, isSingleLetterKey, normalizeKeyName } from "./constants.js";
|
|
6
6
|
import { KEY_DISPLAY_SYMBOLS, LINUX_MODIFIER_LABELS, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, PUNCTUATION_KEY_DISPLAY_LABELS, WINDOWS_MODIFIER_LABELS } from "./display-labels.js";
|
|
@@ -13,4 +13,4 @@ import { HeldModifierOptions, matchesHeldModifiers } from "./hint.js";
|
|
|
13
13
|
import { HotkeyConflict, HotkeyConflictOptions, findHotkeyConflicts } from "./conflicts.js";
|
|
14
14
|
import { KeyStateTracker, KeyStateTrackerState, getKeyStateTracker } from "./key-state-tracker.js";
|
|
15
15
|
import { HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, RecorderKeyMode, RecorderOptions, RecorderRejection } from "./hotkey-recorder.js";
|
|
16
|
-
export { ALL_KEYS, ActivateHotkeyScopeOptions, AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CanonicalModifier, ConflictBehavior, CreateHotkeyChordHandlerOptions, CustomHotkeys, DEFAULT_SEQUENCE_TIMEOUT, DefinedAppHotkeys, DisplayHotkey, DisplayHotkeyChord, EDITING_KEYS, EditingKey, FUNCTION_KEYS, FormatDisplayOptions, FourModifierHotkeyChord, FunctionKey, HeldModifierOptions, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyChordOptions, HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyConflict, HotkeyConflictOptions, HotkeyManager, HotkeyMeta, HotkeyOptions, HotkeyOptionsFor, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationView, HotkeyScopesState, HotkeySequence, HotkeySequenceCallbackContext, HotkeySequenceOptions, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, HotkeySequenceRegistration, HotkeySequenceRegistrationView, IndividualKey, KEY_DISPLAY_SYMBOLS, Key, KeyStateTracker, KeyStateTrackerState, KeyboardEventMatch, LETTER_KEYS, LINUX_MODIFIER_LABELS, LetterKey, LogicalKey, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, Modifier, MultiHotkeyChordHandler, NAVIGATION_KEYS, NUMBER_KEYS, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, PUNCTUATION_KEY_DISPLAY_LABELS, ParsedHotkeyChord, ParsedModifiers, PhysicalKey, PhysicalKeyCode, PunctuationKey, RawHotkeyChord, RawModifiers, RecorderKeyMode, RecorderOptions, RecorderRejection, Register, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, RegisteredAppHotkeys, SingleModifierHotkeyChord, Target, ThreeModifierHotkeyChord, TwoModifierHotkeyChord, ValidatableHotkey, ValidationResult, WINDOWS_MODIFIER_LABELS, areHotkeysEqual, assertValidHotkey, checkHotkey, createHotkeyChordHandler, createMultiHotkeyChordHandler, createSequenceMatcher, defineAppHotkeys, detectPlatform, findHotkeyConflicts, formatForDisplay, formatHotkey, getAppHotkeyRegistrationOptions, getHotkeyManager, getKeyStateTracker, hasNonModifierKey, isAppHotkey, isCustomHotkey, isHotkeySequence, isModifierKey, isSingleLetterKey, matchesHeldModifiers, matchesKeyboardEvent, normalizeHotkey, normalizeKeyName, parseHotkey, parseKeyboardEvent, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey, resolveModifier, simplifyHotkey, toHotkeyRegistrationView, validateHotkey };
|
|
16
|
+
export { ALL_KEYS, ActivateHotkeyScopeOptions, AppHotkey, AppHotkeyChordOptions, AppHotkeyDefaultKind, AppHotkeyId, AppHotkeyInput, AppHotkeyListItem, AppHotkeyOptions, AppHotkeySequenceOptions, AppHotkeyStatus, AppHotkeys, AppHotkeysState, AppHotkeysStore, AppHotkeysStoreOptions, CanonicalModifier, ConflictBehavior, CreateHotkeyChordHandlerOptions, CustomHotkeys, DEFAULT_SEQUENCE_TIMEOUT, DefinedAppHotkeys, DisplayHotkey, DisplayHotkeyChord, EDITING_KEYS, EditingKey, FUNCTION_KEYS, FormatDisplayOptions, FourModifierHotkeyChord, FunctionKey, HeldModifierOptions, Hotkey, HotkeyCallback, HotkeyCallbackContext, HotkeyCallbackContextFor, HotkeyChord, HotkeyChordCallbackContext, HotkeyChordOptions, HotkeyChordRecorderOptions, HotkeyChordRecorderValidationContext, HotkeyChordRegistration, HotkeyChordRegistrationView, HotkeyConflict, HotkeyConflictOptions, HotkeyManager, HotkeyMeta, HotkeyOptions, HotkeyOptionsFor, HotkeyRecorder, HotkeyRecorderCommitKeys, HotkeyRecorderOptions, HotkeyRecorderState, HotkeyRecorderValidationContext, HotkeyRegistration, HotkeyRegistrationHandle, HotkeyRegistrationStatus, HotkeyRegistrationView, HotkeyScopesState, HotkeySequence, HotkeySequenceCallbackContext, HotkeySequenceOptions, HotkeySequenceRecorderOptions, HotkeySequenceRecorderValidationContext, HotkeySequenceRegistration, HotkeySequenceRegistrationView, IndividualKey, KEY_DISPLAY_SYMBOLS, Key, KeyStateTracker, KeyStateTrackerState, KeyboardEventMatch, LETTER_KEYS, LINUX_MODIFIER_LABELS, LetterKey, LogicalKey, MAC_MODIFIER_LABELS, MAC_MODIFIER_SYMBOLS, MODIFIER_ALIASES, MODIFIER_KEYS, MODIFIER_ORDER, Modifier, MultiHotkeyChordHandler, NAVIGATION_KEYS, NUMBER_KEYS, NamedKey, NavigationKey, NonPunctuationKey, NormalizedKeyboardEvent, NumberKey, PUNCTUATION_CODE_MAP, PUNCTUATION_KEYS, PUNCTUATION_KEY_DISPLAY_LABELS, ParsedHotkeyChord, ParsedModifiers, PhysicalKey, PhysicalKeyCode, PunctuationKey, RawHotkeyChord, RawModifiers, RecorderKeyMode, RecorderOptions, RecorderRejection, Register, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence, RegisteredAppHotkeys, SingleModifierHotkeyChord, Target, ThreeModifierHotkeyChord, TwoModifierHotkeyChord, ValidatableHotkey, ValidationResult, WINDOWS_MODIFIER_LABELS, areHotkeysEqual, assertValidHotkey, checkHotkey, createHotkeyChordHandler, createMultiHotkeyChordHandler, createSequenceMatcher, defineAppHotkeys, detectPlatform, findHotkeyConflicts, formatForDisplay, formatHotkey, getAppHotkeyRegistrationOptions, getHotkeyManager, getKeyStateTracker, hasNonModifierKey, isAppHotkey, isCustomHotkey, isHotkeySequence, isModifierKey, isSingleLetterKey, matchesHeldModifiers, matchesKeyboardEvent, normalizeHotkey, normalizeKeyName, parseHotkey, parseKeyboardEvent, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey, resolveModifier, simplifyHotkey, toHotkeyRegistrationView, validateHotkey };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/hotkeys",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"keyboard",
|
|
22
22
|
"shortcuts",
|
|
23
23
|
"keybindings",
|
|
24
|
-
"typescript"
|
|
24
|
+
"typescript",
|
|
25
|
+
"tanstack-intent"
|
|
25
26
|
],
|
|
26
27
|
"type": "module",
|
|
27
28
|
"types": "./dist/index.d.ts",
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
"node": ">=20"
|
|
36
37
|
},
|
|
37
38
|
"files": [
|
|
38
|
-
"dist"
|
|
39
|
+
"dist",
|
|
40
|
+
"skills"
|
|
39
41
|
],
|
|
40
42
|
"dependencies": {
|
|
41
43
|
"@tanstack/store": "^0.11.2"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: app-hotkeys
|
|
3
|
+
description: >-
|
|
4
|
+
Use when an app built with TanStack Hotkeys needs a keyboard shortcut cheat
|
|
5
|
+
sheet or help dialog, a command palette whose commands may have shortcuts, a
|
|
6
|
+
settings screen where users rebind, unbind, or reset shortcuts, recording the
|
|
7
|
+
shortcut a user presses, conflict checks between shortcuts, or saving custom
|
|
8
|
+
shortcuts to storage.
|
|
9
|
+
metadata:
|
|
10
|
+
purpose: >-
|
|
11
|
+
Declare every shortcut once with defineAppHotkeys, then build cheat sheets,
|
|
12
|
+
rebinding, conflict checks, and persistence from those declarations instead
|
|
13
|
+
of a parallel shortcut registry.
|
|
14
|
+
type: core
|
|
15
|
+
library: '@tanstack/hotkeys'
|
|
16
|
+
library_version: '0.16.0'
|
|
17
|
+
sources:
|
|
18
|
+
- src/app-hotkeys.ts
|
|
19
|
+
- src/hotkey-recorder.ts
|
|
20
|
+
- src/conflicts.ts
|
|
21
|
+
- TanStack/hotkeys:docs/framework/react/guides/app-hotkeys.md
|
|
22
|
+
- TanStack/hotkeys:docs/framework/react/guides/cheat-sheet-ui-helpers.md
|
|
23
|
+
- TanStack/hotkeys:docs/framework/react/guides/custom-hotkey-recording.md
|
|
24
|
+
- 'TanStack/hotkeys:packages/react-hotkeys/tests/skills/shortcut-settings/**'
|
|
25
|
+
requires:
|
|
26
|
+
- hotkeys-core
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# App hotkeys
|
|
30
|
+
|
|
31
|
+
This skill builds on `hotkeys-core`. The adapter skill's app hotkeys reference shows the wiring in your framework.
|
|
32
|
+
|
|
33
|
+
App hotkeys are the library's shortcut catalog. Use them instead of a separate shortcut config, context, or hand-written cheat sheet. One module declares every shortcut. The root provider holds the user's custom hotkeys. Components register the declared app hotkeys with their callbacks. Plain hotkeys with `meta` are enough when a list only needs what is mounted right now.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { defineAppHotkeys } from '@tanstack/hotkeys'
|
|
37
|
+
|
|
38
|
+
export const appHotkeys = defineAppHotkeys({
|
|
39
|
+
'doc.save': { hotkey: 'Mod+S', meta: { name: 'Save' }, scope: 'document' },
|
|
40
|
+
'comment.post': {
|
|
41
|
+
hotkey: 'Mod+S',
|
|
42
|
+
meta: { name: 'Post comment' },
|
|
43
|
+
scope: 'comments',
|
|
44
|
+
},
|
|
45
|
+
'theme.toggle': { hotkey: null, meta: { name: 'Toggle theme' } },
|
|
46
|
+
})
|
|
47
|
+
```
|
|
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.
|
|
50
|
+
|
|
51
|
+
## Pitfalls
|
|
52
|
+
|
|
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
|
+
- **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
|
+
- **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
|
+
- **`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`.
|
|
58
|
+
- **Declare scopes on app hotkeys that share keys.** An app hotkey without a scope, or in `'global'`, conflicts with every other app hotkey on the same keys, so rebinding is rejected.
|
|
59
|
+
- **Conflict checks treat physical and logical bindings as different.** A recorded `Mod+[KeyP]` doesn't conflict with a declared `Mod+P`, although Command+P runs both. Declaring rebindable defaults in the recorder's form, such as `Mod+[KeyP]`, makes them comparable.
|
|
60
|
+
- **A cheat sheet dialog usually gets an exclusive scope.** Register the hotkey that opened it inside the dialog too, so the same keys close it.
|
|
61
|
+
|
|
62
|
+
When building the "press a new shortcut" control, read [recording](references/recording.md).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Recording a shortcut
|
|
2
|
+
|
|
3
|
+
Use the adapter's recorder instead of a `keydown` listener that builds a string. The recorder already handles the hard parts:
|
|
4
|
+
|
|
5
|
+
- It pauses registered hotkeys while recording, so pressing `Mod+S` to record it doesn't save. Leave your hotkeys enabled.
|
|
6
|
+
- It records `Mod` rather than Command or Control, and key positions such as `Mod+[KeyS]`, so Option+S records `Alt+[KeyS]` instead of `ß`. Save the recorded value as it is; `formatForDisplay` hides the brackets.
|
|
7
|
+
- Escape cancels. Backspace or Delete removes the last step, or with nothing recorded calls `onClear`, which changes nothing on its own: decide whether clearing unbinds (`setCustomHotkey(id, null)`) or resets (`resetCustomHotkey(id)`).
|
|
8
|
+
- With `maxSteps: Infinity`, the user can record a sequence, which commits on Enter or after `idleTimeoutMs`.
|
|
9
|
+
- `detectConflicts: true` checks only hotkeys registered right now. For app hotkeys, call `findConflicts(id, hotkey)` in `validate` and return a message to reject; a rejected recording keeps listening.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hotkeys-core
|
|
3
|
+
description: >-
|
|
4
|
+
Use when adding, fixing, or reviewing keyboard shortcuts with TanStack
|
|
5
|
+
Hotkeys: replacing keydown listeners or another shortcut library, deciding
|
|
6
|
+
when a hotkey works (enabled, scopes, targets), dialogs that own the
|
|
7
|
+
keyboard, sequences, or testing hotkeys. Load the framework adapter's skill
|
|
8
|
+
with it.
|
|
9
|
+
metadata:
|
|
10
|
+
purpose: >-
|
|
11
|
+
Framework-neutral rules for TanStack Hotkeys: the work the library already
|
|
12
|
+
does, how hotkeys turn on and off, and the pitfalls that don't surface as
|
|
13
|
+
errors.
|
|
14
|
+
type: core
|
|
15
|
+
library: '@tanstack/hotkeys'
|
|
16
|
+
library_version: '0.16.0'
|
|
17
|
+
sources:
|
|
18
|
+
- src/hotkey-manager.ts
|
|
19
|
+
- src/match.ts
|
|
20
|
+
- TanStack/hotkeys:docs/guides/sensible-defaults.md
|
|
21
|
+
- TanStack/hotkeys:docs/framework/react/guides/hotkeys.md
|
|
22
|
+
- TanStack/hotkeys:docs/framework/react/guides/sequences.md
|
|
23
|
+
- TanStack/hotkeys:docs/framework/react/guides/hotkeys-provider-and-scopes.md
|
|
24
|
+
- 'TanStack/hotkeys:packages/react-hotkeys/tests/skills/editor-shortcuts/**'
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# TanStack Hotkeys
|
|
28
|
+
|
|
29
|
+
The library owns the keyboard. Register every shortcut with your framework adapter's hotkey API, such as `useHotkey`, `createHotkey`, `injectHotkey`, or `@hotkey`, in the component that owns the action. Import everything from the adapter package; it re-exports `@tanstack/hotkeys`. The adapter's skill covers its bindings.
|
|
30
|
+
|
|
31
|
+
## Use the library instead of rebuilding it
|
|
32
|
+
|
|
33
|
+
| Instead of | Use |
|
|
34
|
+
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| A `keydown` listener that checks `event.key`, `metaKey`, or `ctrlKey` | A registered hotkey such as `'Mod+S'`. Inside a widget's own key handler, `matchesKeyboardEvent(event, 'Mod+Enter')`. |
|
|
36
|
+
| `isMac` checks and `⌘`/`Ctrl` label maps | `Mod` in the hotkey, and `formatForDisplay(hotkey)` for labels |
|
|
37
|
+
| Skipping events from text fields, calling `preventDefault` | The defaults: single keys and Shift/Alt chords are skipped while typing, `Mod` chords and `Escape` still fire, and matched keys are prevented |
|
|
38
|
+
| A shortcut registry, service, context, or wrapper hook | The adapter API, called where each action lives. For one list of every shortcut, load `@tanstack/hotkeys#app-hotkeys`. |
|
|
39
|
+
| A "shortcuts enabled" flag or topmost-dialog tracking | Scopes, with `exclusive` for dialogs |
|
|
40
|
+
| A hand-written shortcut list for a help dialog | `meta: { name }` and the adapter's registrations list, or app hotkeys |
|
|
41
|
+
| `keydown`/`keyup` state that tracks held keys | The adapter's held-key and hint APIs |
|
|
42
|
+
| Capturing the next key press for a settings screen | The adapter's recorder |
|
|
43
|
+
|
|
44
|
+
A workaround loses what the library tracks: scopes stop suspending it, recorders stop pausing it, devtools stop listing it, and layout and platform handling disappear.
|
|
45
|
+
|
|
46
|
+
## Turn hotkeys on and off
|
|
47
|
+
|
|
48
|
+
Use the first mechanism that fits:
|
|
49
|
+
|
|
50
|
+
1. **Lifetime.** A hotkey registered in a component exists while that component is mounted. A dialog's `Escape` belongs in the dialog.
|
|
51
|
+
2. **`enabled`.** A condition inside a mounted component.
|
|
52
|
+
3. **Scope.** A group that turns on and off together, such as a dialog, pane, or tab. An `exclusive` scope suspends every other scope, including global hotkeys, while it is active.
|
|
53
|
+
4. **`target`.** Only while focus is inside one element, which must be focusable (`tabindex="0"`).
|
|
54
|
+
|
|
55
|
+
When nesting scopes, stacking dialogs, or keeping some hotkeys working during an exclusive scope, read [scopes](references/scopes.md).
|
|
56
|
+
|
|
57
|
+
## Pitfalls
|
|
58
|
+
|
|
59
|
+
- **A disabled hotkey doesn't block the browser.** With `enabled: false`, `Mod+S` opens the Save Page dialog. Add `preventDefault: 'always'` to keep the action blocked while the hotkey can't fire, whether it's disabled or its scope is off. Skip it for hotkeys turned off to give the key back to the browser, such as `Mod+C`, `Mod+Z`, `Tab`, or single keys behind a user's off switch. Don't keep the hotkey enabled and return early from the callback; the hotkey would also stay `active` in app hotkey lists and show its hint.
|
|
60
|
+
- **Modifiers match exactly.** `ArrowDown` never fires while Shift is held, so register `Shift+ArrowDown` instead of checking `event.shiftKey`. Shift that a symbol needs is the exception: `?` matches Shift+/.
|
|
61
|
+
- **Chords repeat while held.** Set `requireReset: true` on toggles, or holding the keys flickers them.
|
|
62
|
+
- **Letters follow the typed character.** For position-based controls such as WASD, use physical bindings such as `'[KeyW]'`. Keep letters for mnemonic commands such as `Mod+S`.
|
|
63
|
+
- **Each open exclusive scope needs its own name.** A reusable dialog with a fixed scope name breaks when one dialog opens another: both share one scope and both handle `Escape`. Build the name per instance.
|
|
64
|
+
- **A chord on a sequence's first key runs every time the sequence starts.** With `G` and `['G', 'G']`, the first `G` runs the chord. Later keys belong to the sequence. To show "waiting for the next key", read `matchedStepCount` from the adapter's registrations list.
|
|
65
|
+
- **Single-character shortcuts need an off switch.** For WCAG 2.1.4, `K`, `?`, and `['G', 'H']` need a user setting passed as `enabled`, or an app hotkey users can remap.
|
|
66
|
+
- **react-hotkeys-hook names don't carry over.** `useHotkeys` and `HotkeysProvider` share names, not signatures: there's no dependency array, `preventDefault` is on by default, and sequences are arrays. Remove the old library, so two libraries don't handle the same keys.
|
|
67
|
+
- **Install only the adapter.** A separately installed `@tanstack/hotkeys` at another version is a second copy, and scopes don't apply across copies.
|
|
68
|
+
|
|
69
|
+
When writing tests for hotkeys, read [testing](references/testing.md).
|
|
70
|
+
|
|
71
|
+
## API discovery
|
|
72
|
+
|
|
73
|
+
Read the installed declarations for exact signatures: the adapter's `dist/*.d.ts`, then the core's `hotkey.types.d.ts` (hotkey strings) and `hotkey-manager.d.ts` (`HotkeyOptions`). With pnpm, the core package sits beside the adapter: `ls "$(realpath node_modules/@tanstack/react-hotkeys)/../hotkeys/dist"`, using your adapter's name.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Scopes
|
|
2
|
+
|
|
3
|
+
- A scope is a name, not an instance. Two mounted providers with the same scope share it, and it stays active until both are gone.
|
|
4
|
+
- Hotkeys outside every scoped provider are in `'global'`, which is always active unless an exclusive scope suspends it.
|
|
5
|
+
- When hotkeys in two active scopes match the same key, a nested scope beats the scopes around it; otherwise the most recently activated scope wins. Only the winning scope's hotkeys run.
|
|
6
|
+
- The same hotkey in two scopes is not a duplicate, so there's no conflict warning.
|
|
7
|
+
- An `exclusive` scope suspends every other scope except scopes nested in it and those in `exclusive: { allow: ['global'] }`. With several exclusive scopes active, the one with the highest precedence owns the keyboard, so a dialog opened from a dialog suspends the first until it closes.
|
|
8
|
+
- A provider with `enabled: false` turns off its scope and every scope nested in it while staying mounted, for tabs and panes that stay rendered.
|
|
9
|
+
- A hotkey's `scope` option, or an app hotkey's declared `scope`, overrides the provider's.
|
|
10
|
+
- Scopes follow the component tree, not the DOM, so a portaled dialog keeps its scope.
|
|
11
|
+
- The hotkey that opens an exclusive dialog is suspended with the rest of the page. To close the dialog with the same keys, register that hotkey inside the dialog too.
|
|
12
|
+
- To show which scope owns the keyboard, use the adapter's scope state API, such as `useHotkeyScopesState()` in React. Don't subscribe to `manager.scopes` by hand.
|
|
13
|
+
- To list only hotkeys that are on, filter the adapter's registrations by `status === 'active'`. `'suspended'` means the hotkey's scope is inactive or suspended, and `'disabled'` means `enabled: false`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Testing hotkeys
|
|
2
|
+
|
|
3
|
+
- **Set the platform.** Test DOMs don't report a real platform (happy-dom on macOS detects `windows`), so `Mod` changes meaning between machines. Pass `platform: 'mac'` in the provider's `defaultOptions.hotkey`, or on the hotkey.
|
|
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
|
+
- **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
|
+
- **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).
|