@tanstack/hotkeys 0.18.0 → 0.19.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.
@@ -1,78 +1,169 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
2
 
3
3
  //#region src/_event-target.ts
4
- function isNode(target) {
5
- return typeof target?.nodeType === "number";
4
+ function isElement(node) {
5
+ return node?.nodeType === 1;
6
6
  }
7
- function isElement(target) {
8
- return isNode(target) && target.nodeType === 1;
9
- }
10
- function isWindow(target) {
11
- return "document" in target;
7
+ /** Whether a target is a document or window, which receive every key press in their document. */
8
+ function isDocumentLevel(target) {
9
+ return "document" in target || target.nodeType === 9;
12
10
  }
13
11
  /** Returns the document a listener target belongs to, in any frame. */
14
12
  function getTargetDocument(target) {
15
- if (isWindow(target)) return target.document;
16
- if (target.nodeType === 9) return target;
17
- return target.ownerDocument;
13
+ return "document" in target ? target.document : target.ownerDocument ?? target;
14
+ }
15
+ /** The focused element, looking inside open shadow roots. */
16
+ function getDeepActiveElement(doc) {
17
+ let active = doc.activeElement;
18
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
19
+ return active;
20
+ }
21
+ /** The next node a key event bubbles to: a slot, a shadow host, or the parent. */
22
+ function composedParent(node) {
23
+ return node.assignedSlot ?? node.parentNode ?? (node.host || null);
18
24
  }
19
25
  /**
20
26
  * Whether key presses reach a listener on `target` right now. Document and
21
27
  * window listeners receive every key press in their document. An element
22
28
  * receives them only while focus is inside it, including inside a shadow
23
- * root it contains.
29
+ * root it contains or on content slotted into it.
24
30
  */
25
31
  function targetHasFocus(target) {
26
- if (isWindow(target) || target.nodeType === 9) return true;
27
- const active = target.getRootNode().activeElement;
28
- return !!active && target.contains(active);
32
+ if (isDocumentLevel(target)) return true;
33
+ for (let node = getDeepActiveElement(target.ownerDocument); node; node = composedParent(node)) if (node === target) return true;
34
+ return false;
29
35
  }
36
+ const VERTICAL = ["ArrowUp", "ArrowDown"];
37
+ const HORIZONTAL = ["ArrowLeft", "ArrowRight"];
38
+ const ENTER = ["Enter"];
39
+ const SPACE = ["Space"];
40
+ const ACTIVATION = ["Enter", "Space"];
41
+ const TYPEAHEAD = "typeahead";
42
+ const MENU = [
43
+ "ArrowUp",
44
+ "ArrowDown",
45
+ "ArrowLeft",
46
+ "ArrowRight",
47
+ "Home",
48
+ "End",
49
+ "Enter",
50
+ "Space",
51
+ TYPEAHEAD
52
+ ];
53
+ const COMBOBOX = [
54
+ "ArrowUp",
55
+ "ArrowDown",
56
+ "Home",
57
+ "End",
58
+ "Enter",
59
+ "Space",
60
+ TYPEAHEAD
61
+ ];
62
+ const GRID = [
63
+ "ArrowUp",
64
+ "ArrowDown",
65
+ "ArrowLeft",
66
+ "ArrowRight",
67
+ "Home",
68
+ "End",
69
+ "PageUp",
70
+ "PageDown",
71
+ "Enter",
72
+ "Space"
73
+ ];
74
+ const RADIOS = [
75
+ "ArrowUp",
76
+ "ArrowDown",
77
+ "ArrowLeft",
78
+ "ArrowRight",
79
+ "Space"
80
+ ];
81
+ const NAVIGATION = [
82
+ "ArrowUp",
83
+ "ArrowDown",
84
+ "ArrowLeft",
85
+ "ArrowRight",
86
+ "Home",
87
+ "End",
88
+ "PageUp",
89
+ "PageDown"
90
+ ];
91
+ const SPIN = [
92
+ "ArrowUp",
93
+ "ArrowDown",
94
+ "Home",
95
+ "End",
96
+ "PageUp",
97
+ "PageDown"
98
+ ];
99
+ const CARET = [
100
+ "ArrowLeft",
101
+ "ArrowRight",
102
+ "Home",
103
+ "End"
104
+ ];
30
105
  /**
31
- * Checks if an element is an input-like element that should be ignored for hotkeys.
32
- *
33
- * This includes:
34
- * - HTMLInputElement (all input types except button, submit, reset)
35
- * - HTMLTextAreaElement
36
- * - HTMLSelectElement
37
- * - Elements with contentEditable enabled
106
+ * Input types that aren't text fields, with the keys each one keeps. Browsers
107
+ * report unknown types as `'text'`.
108
+ */
109
+ const INPUT_KEYS = {
110
+ button: ACTIVATION,
111
+ submit: ACTIVATION,
112
+ reset: ACTIVATION,
113
+ image: ACTIVATION,
114
+ color: ACTIVATION,
115
+ file: ACTIVATION,
116
+ checkbox: SPACE,
117
+ radio: RADIOS,
118
+ range: NAVIGATION
119
+ };
120
+ function getRole(element) {
121
+ return element.getAttribute("role")?.trim().split(/\s+/)[0];
122
+ }
123
+ /**
124
+ * Checks if an element is a text field that hotkeys should not type over:
125
+ * - text-entry `<input>` types (text, number, date, and similar) that aren't read-only
126
+ * - `<textarea>` elements that aren't read-only, and `<select>` elements
127
+ * - elements with `role="textbox"` or `role="searchbox"`
128
+ * - contenteditable elements
38
129
  *
39
- * Button-type inputs (button, submit, reset) are excluded so hotkeys like
40
- * Mod+S and Escape fire when the user has tabbed to a form button.
130
+ * Checkbox, radio, range, color, file, and button-type inputs are not text
131
+ * fields. Hotkeys skip only the keys those controls use themselves.
41
132
  */
42
133
  function isInputElement(element) {
43
134
  if (!isElement(element)) return false;
44
- if (element.localName === "input") {
45
- const type = element.type.toLowerCase();
46
- return type !== "button" && type !== "submit" && type !== "reset";
135
+ switch (element.localName) {
136
+ case "input": {
137
+ const input = element;
138
+ return !INPUT_KEYS[input.type] && !input.readOnly;
139
+ }
140
+ case "textarea": return !element.readOnly;
141
+ case "select": return true;
47
142
  }
48
- if (element.localName === "textarea" || element.localName === "select") return true;
49
- return element.isContentEditable === true;
143
+ const role = getRole(element);
144
+ return role === "textbox" || role === "searchbox" || element.isContentEditable;
50
145
  }
51
146
  /**
52
- * Returns the focused element for the document associated with where hotkey
53
- * listeners are attached (listener root). Use this instead of the global
54
- * `document.activeElement` so registrations scoped to an iframe (or another
55
- * document) read focus from the correct tree.
147
+ * Whether the focused custom element may be a text field whose contents can't
148
+ * be seen from outside:
149
+ * - a host with focus inside a closed shadow root. Without a `tabindex` it
150
+ * can't hold focus itself, and an open root would have shown the focused
151
+ * element inside it.
152
+ * - a form-associated element without a `role` attribute naming another control
56
153
  */
57
- function getActiveElementForListenerTarget(target) {
58
- if (typeof document === "undefined") return null;
59
- return getTargetDocument(target).activeElement;
154
+ function isHiddenTextField(element) {
155
+ if (!element.localName.includes("-")) return false;
156
+ return element.constructor.formAssociated === true ? !element.hasAttribute("role") : !element.hasAttribute("tabindex");
60
157
  }
61
- const VERTICAL = ["ArrowUp", "ArrowDown"];
62
- const HORIZONTAL = ["ArrowLeft", "ArrowRight"];
63
- const ARROWS = [...VERTICAL, ...HORIZONTAL];
64
- const EDGES = ["Home", "End"];
65
- const PAGES = ["PageUp", "PageDown"];
66
- const ACTIVATION = ["Enter", "Space"];
67
- const TYPEAHEAD = "typeahead";
68
158
  /**
69
159
  * Returns the keys a control handles itself: activation for buttons and
70
160
  * links, and navigation for ARIA widgets, following the WAI-ARIA Authoring
71
161
  * Practices keyboard patterns (https://www.w3.org/WAI/ARIA/apg/patterns/).
162
+ * Editable text fields never get here: they block every key that types text.
72
163
  */
73
164
  function getOwnedKeys(element) {
74
165
  const orientation = element.getAttribute("aria-orientation");
75
- switch (element.getAttribute("role")?.trim().split(/\s+/)[0]) {
166
+ switch (getRole(element)) {
76
167
  case "button":
77
168
  case "tab":
78
169
  case "option":
@@ -80,64 +171,47 @@ function getOwnedKeys(element) {
80
171
  case "menuitem":
81
172
  case "menuitemcheckbox":
82
173
  case "menuitemradio": return ACTIVATION;
83
- case "link": return ["Enter"];
174
+ case "link": return ENTER;
84
175
  case "checkbox":
85
176
  case "switch":
86
- case "radio": return ["Space"];
177
+ case "radio": return SPACE;
87
178
  case "menu":
88
179
  case "menubar":
89
- case "tree": return [
90
- ...ARROWS,
91
- ...EDGES,
92
- ...ACTIVATION,
93
- TYPEAHEAD
94
- ];
180
+ case "tree": return MENU;
95
181
  case "listbox": return [
96
182
  ...orientation === "horizontal" ? HORIZONTAL : VERTICAL,
97
- ...EDGES,
98
- ...ACTIVATION,
99
- TYPEAHEAD
100
- ];
101
- case "combobox": return [
102
- ...VERTICAL,
103
- ...EDGES,
104
- ...ACTIVATION,
183
+ "Home",
184
+ "End",
185
+ "Enter",
186
+ "Space",
105
187
  TYPEAHEAD
106
188
  ];
189
+ case "combobox": return COMBOBOX;
107
190
  case "tablist": return [
108
191
  ...orientation === "vertical" ? VERTICAL : HORIZONTAL,
109
- ...EDGES,
110
- ...ACTIVATION
111
- ];
112
- case "toolbar": return [...orientation === "vertical" ? VERTICAL : HORIZONTAL, ...EDGES];
113
- case "grid":
114
- case "treegrid": return [
115
- ...ARROWS,
116
- ...EDGES,
117
- ...PAGES,
118
- ...ACTIVATION
192
+ "Home",
193
+ "End",
194
+ "Enter",
195
+ "Space"
119
196
  ];
120
- case "radiogroup": return [...ARROWS, "Space"];
121
- case "slider": return [
122
- ...ARROWS,
123
- ...EDGES,
124
- ...PAGES
125
- ];
126
- case "spinbutton": return [
127
- ...VERTICAL,
128
- ...EDGES,
129
- ...PAGES
197
+ case "toolbar": return [
198
+ ...orientation === "vertical" ? VERTICAL : HORIZONTAL,
199
+ "Home",
200
+ "End"
130
201
  ];
202
+ case "grid":
203
+ case "treegrid": return GRID;
204
+ case "radiogroup": return RADIOS;
205
+ case "slider": return NAVIGATION;
206
+ case "spinbutton": return SPIN;
131
207
  }
132
208
  switch (element.localName) {
133
209
  case "button":
134
210
  case "summary": return ACTIVATION;
135
- case "input": {
136
- const type = element.type;
137
- return type === "button" || type === "submit" || type === "reset" ? ACTIVATION : [];
138
- }
211
+ case "input": return INPUT_KEYS[element.type] ?? CARET;
212
+ case "textarea": return NAVIGATION;
139
213
  case "a":
140
- case "area": return element.hasAttribute("href") ? ["Enter"] : [];
214
+ case "area": return element.hasAttribute("href") ? ENTER : [];
141
215
  }
142
216
  return [];
143
217
  }
@@ -150,56 +224,60 @@ function ownsKey(element, event, key) {
150
224
  if (event.ctrlKey || event.metaKey || event.altKey) return false;
151
225
  const owned = getOwnedKeys(element);
152
226
  if (key === "Enter" || key === "Space") return !event.shiftKey && owned.includes(key);
153
- return owned.includes(key) || owned.includes(TYPEAHEAD) && /^\S$/u.test(event.key);
227
+ return owned.includes(key) || owned.includes(TYPEAHEAD) && /^.$/u.test(event.key);
154
228
  }
155
229
  /**
156
- * Whether the focused element or an element on the event's path passes `test`.
157
- * Only elements inside the registration target count: walking outward from
158
- * the event's origin stops at the registration target, so a hotkey targeted
159
- * at a control, or at an element inside it, is not blocked by that control.
230
+ * Finds the first element on the event's way out that blocks it, walking out
231
+ * from the focused element. A key event starts there in a browser. One sent
232
+ * by a test may start at an ancestor of the focused element or at the
233
+ * document, and the walk from focus passes through those too. Only an event
234
+ * that starts where focus can't be seen, such as inside a closed shadow root,
235
+ * uses its own path.
160
236
  */
161
- function someElementInsideTarget(event, listenerTarget, registrationTarget, test) {
162
- const scoped = !isWindow(registrationTarget) && registrationTarget.nodeType !== 9;
163
- const focused = getActiveElementForListenerTarget(listenerTarget);
164
- if (focused && focused !== registrationTarget && (!scoped || registrationTarget.contains(focused)) && test(focused)) return true;
165
- const path = event.composedPath();
166
- for (const element of path.length > 0 ? path : [event.target]) {
167
- if (element === registrationTarget) break;
168
- if (isElement(element) && test(element)) return true;
169
- }
170
- return false;
237
+ function findBlocker(event, doc, test) {
238
+ const focused = getDeepActiveElement(doc);
239
+ let elements = [];
240
+ for (let node = focused; node; node = composedParent(node)) if (isElement(node)) elements.push(node);
241
+ const origin = event.composedPath()[0];
242
+ if (isElement(origin) && !elements.includes(origin)) elements = event.composedPath().filter(isElement);
243
+ const index = elements.findIndex((element) => test(element) || element === focused && isHiddenTextField(element));
244
+ return {
245
+ elements,
246
+ index
247
+ };
171
248
  }
172
- /**
173
- * Returns whether a hotkey should skip an event because a focused control
174
- * inside the registration target handles the key itself:
175
- * - text inputs, textareas, selects, and contenteditable elements (typing)
176
- * - buttons, links, and `<summary>` elements (Enter and Space activation)
177
- * - ARIA widgets such as menus, listboxes, tabs, and grids (navigation keys)
178
- */
179
- function shouldIgnoreInputEvent(event, listenerTarget, registrationTarget) {
180
- const key = normalizeKeyName(event.key);
181
- return someElementInsideTarget(event, listenerTarget, registrationTarget, (element) => isInputElement(element) || ownsKey(element, event, key));
249
+ /** Whether the blocking element is inside `target`. A target never blocks itself. */
250
+ function blocks({ elements, index }, target) {
251
+ return index >= 0 && (isDocumentLevel(target) || index < elements.indexOf(target));
182
252
  }
253
+ const ignoreCache = /* @__PURE__ */ new WeakMap();
183
254
  /**
184
- * Returns whether an event comes from a text input, textarea, select, or
185
- * contenteditable element in the target's document. Unlike
186
- * `shouldIgnoreInputEvent`, buttons, links, and ARIA widgets don't count.
255
+ * Returns whether a hotkey on `target` should skip an event because a control
256
+ * inside the target handles the key itself:
257
+ * - text fields, including ARIA text roles and custom elements that hide one (typing)
258
+ * - buttons, links, `<summary>`, and button-like inputs (Enter and Space activation)
259
+ * - checkbox, radio, and range inputs, and ARIA widgets such as menus,
260
+ * listboxes, tabs, and grids (the keys they use)
261
+ *
262
+ * A hotkey targeted at a control receives that control's keys.
187
263
  */
188
- function isTypingEvent(event, target) {
189
- return someElementInsideTarget(event, target, target, isInputElement);
264
+ function shouldIgnoreInputEvent(event, target) {
265
+ let blocker = ignoreCache.get(event);
266
+ if (!blocker) {
267
+ const key = normalizeKeyName(event.key);
268
+ blocker = findBlocker(event, getTargetDocument(target), (element) => isInputElement(element) || ownsKey(element, event, key));
269
+ ignoreCache.set(event, blocker);
270
+ }
271
+ return blocks(blocker, target);
190
272
  }
191
273
  /**
192
- * Checks if an event is for the given target (originated from or bubbled to it).
193
- *
194
- * For document/window targets, also accepts the document's root element as
195
- * currentTarget to handle Brave and other browsers where currentTarget may be
196
- * documentElement instead of document when listeners are attached to document.
274
+ * Returns whether an event comes from a text field inside `target`, as
275
+ * `isInputElement` and hidden custom text fields define them. Buttons, links,
276
+ * and other controls don't count.
197
277
  */
198
- function isEventForTarget(event, target) {
199
- if (event.currentTarget === target) return true;
200
- if (isWindow(target) || target.nodeType === 9) return event.currentTarget === getTargetDocument(target).documentElement;
201
- return isNode(event.target) && target.contains(event.target);
278
+ function isTypingEvent(event, target) {
279
+ return blocks(findBlocker(event, getTargetDocument(target), isInputElement), target);
202
280
  }
203
281
 
204
282
  //#endregion
205
- export { getActiveElementForListenerTarget, getTargetDocument, isEventForTarget, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };
283
+ export { getTargetDocument, isDocumentLevel, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };
package/dist/_match.js CHANGED
@@ -22,7 +22,7 @@ function matchKeyboardEvent(event, hotkey, platform = detectPlatform()) {
22
22
  const direct = keysEqual(normalized.key, parsed.key);
23
23
  const implicitShift = direct && isPrintableKey(normalized.key) && !/^\p{Letter}$/u.test(normalized.key) && !parsed.shift;
24
24
  if (direct && modifiersMatch(normalized, parsed, implicitShift)) return matched("key", implicitShift ? 2 : 3);
25
- if (isAsciiLetter(normalized.key) || isLatinLetter(normalized.key) && normalized.code.startsWith("Key") && !normalized.alt) return noMatch;
25
+ if (!positionStandsIn(normalized)) return noMatch;
26
26
  if (normalized.altGraph) return noMatch;
27
27
  if (!modifiersMatch(normalized, parsed, false)) return noMatch;
28
28
  const fallbackKey = codeToLogicalKey(normalized.code);
@@ -39,6 +39,28 @@ function matchKeyboardEvent(event, hotkey, platform = detectPlatform()) {
39
39
  };
40
40
  }
41
41
  }
42
+ /**
43
+ * Whether a key's conventional position may stand in for what it typed. Only
44
+ * output that can't name a shortcut falls back:
45
+ * - non-Latin letters and symbols, such as Russian `ы` or macOS Option's `–`
46
+ * - dead keys
47
+ * - anything on the digit row, where AZERTY types `&` and `é` for 1 and 2
48
+ * - ASCII symbols typed with Shift, such as `!` for Shift+1
49
+ * - Latin letters on punctuation keys with Control or Command, so German
50
+ * Cmd+ü still fires `Mod+[` (German types `[` with Option)
51
+ *
52
+ * A named key (`End` from a NumLock-off numpad), any other ASCII character,
53
+ * or a Latin letter typed on its own is the key the user meant, wherever it
54
+ * sits. AZERTY's `+` on the US `/` position is `+`, not `/`.
55
+ */
56
+ function positionStandsIn({ key, code, ctrl, meta, shift, alt }) {
57
+ if (key !== "Dead" && !isPrintableKey(key)) return false;
58
+ if (code.startsWith("Digit")) return true;
59
+ if (isAsciiLetter(key)) return false;
60
+ if (/^[!-~]$/.test(key)) return shift;
61
+ if (!isLatinLetter(key) || alt) return true;
62
+ return (ctrl || meta) && !code.startsWith("Key");
63
+ }
42
64
  /** Compares modifiers, excluding synthetic AltGr flags and optionally allowing Shift used to type a glyph. */
43
65
  function modifiersMatch(event, parsed, allowImplicitShift) {
44
66
  const ctrl = event.altGraph ? false : event.ctrl;
@@ -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, targetHasFocus } from "./_event-target.js";
8
+ import { getTargetDocument, isDocumentLevel, 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";
@@ -286,7 +286,7 @@ var HotkeyManager = class HotkeyManager {
286
286
  #handledByLiveHotkey(event, eventType) {
287
287
  const path = event.composedPath();
288
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;
289
+ if (!this.#isLive(registration) || registration.options.eventType !== eventType || !path.includes(registration.target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, registration.target)) continue;
290
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
291
  }
292
292
  return false;
@@ -477,7 +477,7 @@ var HotkeyManager = class HotkeyManager {
477
477
  let reserved = false;
478
478
  for (const registration of registrations) {
479
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;
480
+ if (!live && registration.options.preventDefault !== "always" || registration.options.eventType !== eventType || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target)) continue;
481
481
  const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform);
482
482
  if (!result.matched) continue;
483
483
  if (!live) {
@@ -533,7 +533,7 @@ var HotkeyManager = class HotkeyManager {
533
533
  for (const registration of this.registrations.state.values()) {
534
534
  if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
535
535
  const target = registration.target;
536
- if (path.includes(target.nodeType === 1 ? target : getTargetDocument(target)) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
536
+ if (path.includes(isDocumentLevel(target) ? getTargetDocument(target) : target) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
537
537
  }
538
538
  return false;
539
539
  }
@@ -556,7 +556,7 @@ var HotkeyManager = class HotkeyManager {
556
556
  const candidates = /* @__PURE__ */ new Map();
557
557
  let bestScore = 0;
558
558
  for (const registration of registrations) {
559
- if (!isEventForTarget(event, target) || !this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
559
+ if (!this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target) || registration.options.eventType !== eventType) continue;
560
560
  const timeout = registration.options.timeout ?? 1e3;
561
561
  if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
562
562
  registration.currentIndex = 0;
@@ -101,7 +101,7 @@ var HotkeyRecorder = class {
101
101
  * and records chords according to `maxSteps`.
102
102
  */
103
103
  start() {
104
- if (this.#keydownHandler) return;
104
+ if (this.#keydownHandler || typeof document === "undefined") return;
105
105
  this.#clearIdleTimer();
106
106
  this.#events = [];
107
107
  beginRecording(this);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",