From 1d75ed7f473335395d6d865794087674de8f68e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Mar 2026 15:00:38 +0000 Subject: [PATCH 1/3] fix: check document.activeElement in addition to event.target for ignoreInputs Some libraries like React Aria's Autocomplete intercept keydown events from input elements and re-dispatch them on a different element (like a list item). In these cases, event.target is the re-dispatched target, not the actual focused input. By also checking document.activeElement, we can properly detect when the user is typing in an input even if the event has been re-dispatched. Fixes #83 Co-authored-by: kevinvandy656 --- packages/hotkeys/src/hotkey-manager.ts | 8 +- packages/hotkeys/src/sequence-manager.ts | 8 +- packages/hotkeys/tests/hotkey-manager.test.ts | 76 ++++++++++++++++ .../hotkeys/tests/sequence-manager.test.ts | 91 +++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/packages/hotkeys/src/hotkey-manager.ts b/packages/hotkeys/src/hotkey-manager.ts index 0714d45c..e5e619d3 100644 --- a/packages/hotkeys/src/hotkey-manager.ts +++ b/packages/hotkeys/src/hotkey-manager.ts @@ -456,7 +456,13 @@ export class HotkeyManager { // Check if we should ignore input elements (defaults to true) if (registration.options.ignoreInputs !== false) { - if (isInputElement(event.target)) { + // Check both event.target and document.activeElement because some libraries + // (e.g. React Aria's Autocomplete) intercept keydown events from inputs and + // re-dispatch them on a different element (like a list item). In such cases, + // event.target is the list element, but document.activeElement is still the input. + const activeElement = + typeof document !== 'undefined' ? document.activeElement : null + if (isInputElement(event.target) || isInputElement(activeElement)) { // Don't ignore if the hotkey is explicitly scoped to this input element if (event.target !== registration.target) { continue diff --git a/packages/hotkeys/src/sequence-manager.ts b/packages/hotkeys/src/sequence-manager.ts index f910e922..a33993b9 100644 --- a/packages/hotkeys/src/sequence-manager.ts +++ b/packages/hotkeys/src/sequence-manager.ts @@ -461,7 +461,13 @@ export class SequenceManager { // Check if we should ignore input elements (defaults to true) if (registration.options.ignoreInputs !== false) { - if (isInputElement(event.target)) { + // Check both event.target and document.activeElement because some libraries + // (e.g. React Aria's Autocomplete) intercept keydown events from inputs and + // re-dispatch them on a different element (like a list item). In such cases, + // event.target is the list element, but document.activeElement is still the input. + const activeElement = + typeof document !== 'undefined' ? document.activeElement : null + if (isInputElement(event.target) || isInputElement(activeElement)) { // Don't ignore if the sequence is explicitly scoped to this input element if (event.target !== registration.target) { continue diff --git a/packages/hotkeys/tests/hotkey-manager.test.ts b/packages/hotkeys/tests/hotkey-manager.test.ts index f57a85c8..0851e613 100644 --- a/packages/hotkeys/tests/hotkey-manager.test.ts +++ b/packages/hotkeys/tests/hotkey-manager.test.ts @@ -1054,6 +1054,82 @@ describe('HotkeyManager', () => { callback.mockClear() } }) + + it('should ignore hotkeys when activeElement is an input but event.target is different (React Aria pattern)', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + + manager.register('Q', callback, { platform: 'mac' }) + + const input = document.createElement('input') + input.type = 'text' + const listItem = document.createElement('li') + document.body.appendChild(input) + document.body.appendChild(listItem) + + input.focus() + + const event = new KeyboardEvent('keydown', { + key: 'q', + bubbles: true, + }) + Object.defineProperty(event, 'target', { + value: listItem, + writable: false, + configurable: true, + }) + Object.defineProperty(event, 'currentTarget', { + value: document, + writable: false, + configurable: true, + }) + document.dispatchEvent(event) + + expect(callback).not.toHaveBeenCalled() + + document.body.removeChild(input) + document.body.removeChild(listItem) + }) + + it('should fire Mod hotkeys when activeElement is an input but event.target is different (React Aria pattern)', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + + manager.register('Mod+S', callback, { platform: 'mac' }) + + const input = document.createElement('input') + input.type = 'text' + const listItem = document.createElement('li') + document.body.appendChild(input) + document.body.appendChild(listItem) + + input.focus() + + const event = new KeyboardEvent('keydown', { + key: 's', + metaKey: true, + bubbles: true, + }) + Object.defineProperty(event, 'target', { + value: listItem, + writable: false, + configurable: true, + }) + Object.defineProperty(event, 'currentTarget', { + value: document, + writable: false, + configurable: true, + }) + document.dispatchEvent(event) + + expect(callback).toHaveBeenCalledWith( + event, + expect.objectContaining({ hotkey: 'Mod+S' }), + ) + + document.body.removeChild(input) + document.body.removeChild(listItem) + }) }) describe('conflict detection', () => { diff --git a/packages/hotkeys/tests/sequence-manager.test.ts b/packages/hotkeys/tests/sequence-manager.test.ts index 30058b74..4ef6865e 100644 --- a/packages/hotkeys/tests/sequence-manager.test.ts +++ b/packages/hotkeys/tests/sequence-manager.test.ts @@ -416,6 +416,97 @@ describe('SequenceManager', () => { document.body.removeChild(input) }) + + it('should ignore sequences when activeElement is an input but event.target is different (React Aria pattern)', () => { + const manager = SequenceManager.getInstance() + const callback = vi.fn() + + manager.register(['G', 'G'], callback) + + const input = document.createElement('input') + input.type = 'text' + const listItem = document.createElement('li') + document.body.appendChild(input) + document.body.appendChild(listItem) + + input.focus() + + for (let i = 0; i < 2; i++) { + const event = new KeyboardEvent('keydown', { + key: 'g', + bubbles: true, + }) + Object.defineProperty(event, 'target', { + value: listItem, + writable: false, + configurable: true, + }) + Object.defineProperty(event, 'currentTarget', { + value: document, + writable: false, + configurable: true, + }) + document.dispatchEvent(event) + } + + expect(callback).not.toHaveBeenCalled() + + document.body.removeChild(input) + document.body.removeChild(listItem) + }) + + it('should fire Mod sequences when activeElement is an input but event.target is different (React Aria pattern)', () => { + const manager = SequenceManager.getInstance() + const callback = vi.fn() + + manager.register(['Mod+K', 'S'], callback, { platform: 'mac' }) + + const input = document.createElement('input') + input.type = 'text' + const listItem = document.createElement('li') + document.body.appendChild(input) + document.body.appendChild(listItem) + + input.focus() + + const event1 = new KeyboardEvent('keydown', { + key: 'k', + metaKey: true, + bubbles: true, + }) + Object.defineProperty(event1, 'target', { + value: listItem, + writable: false, + configurable: true, + }) + Object.defineProperty(event1, 'currentTarget', { + value: document, + writable: false, + configurable: true, + }) + document.dispatchEvent(event1) + + const event2 = new KeyboardEvent('keydown', { + key: 's', + bubbles: true, + }) + Object.defineProperty(event2, 'target', { + value: listItem, + writable: false, + configurable: true, + }) + Object.defineProperty(event2, 'currentTarget', { + value: document, + writable: false, + configurable: true, + }) + document.dispatchEvent(event2) + + expect(callback).toHaveBeenCalledTimes(1) + + document.body.removeChild(input) + document.body.removeChild(listItem) + }) }) describe('target option', () => { From 366c183d14faeff7bcdeb2949a52b56bf1cabe4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Mar 2026 15:03:21 +0000 Subject: [PATCH 2/3] chore: add changeset for fix Co-authored-by: kevinvandy656 --- .changeset/fix-ignore-inputs-active-element.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-ignore-inputs-active-element.md diff --git a/.changeset/fix-ignore-inputs-active-element.md b/.changeset/fix-ignore-inputs-active-element.md new file mode 100644 index 00000000..3163416e --- /dev/null +++ b/.changeset/fix-ignore-inputs-active-element.md @@ -0,0 +1,7 @@ +--- +"@tanstack/hotkeys": patch +--- + +fix: check `document.activeElement` in addition to `event.target` for `ignoreInputs` option + +Some libraries like React Aria's Autocomplete intercept keydown events from input elements and re-dispatch them on a different element (like a list item). In these cases, `event.target` is the re-dispatched target, not the actual focused input. By also checking `document.activeElement`, we can properly detect when the user is typing in an input even if the event has been re-dispatched. From 65000d809017ff578ca4e8ccc01ba20402e10efb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:04:50 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- .changeset/fix-ignore-inputs-active-element.md | 2 +- docs/reference/classes/HotkeyManager.md | 8 ++++---- docs/reference/classes/SequenceManager.md | 8 ++++---- docs/reference/functions/createSequenceMatcher.md | 2 +- docs/reference/functions/getHotkeyManager.md | 2 +- docs/reference/functions/getSequenceManager.md | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.changeset/fix-ignore-inputs-active-element.md b/.changeset/fix-ignore-inputs-active-element.md index 3163416e..fb98322a 100644 --- a/.changeset/fix-ignore-inputs-active-element.md +++ b/.changeset/fix-ignore-inputs-active-element.md @@ -1,5 +1,5 @@ --- -"@tanstack/hotkeys": patch +'@tanstack/hotkeys': patch --- fix: check `document.activeElement` in addition to `event.target` for `ignoreInputs` option diff --git a/docs/reference/classes/HotkeyManager.md b/docs/reference/classes/HotkeyManager.md index 0e1ab64d..0da4d62c 100644 --- a/docs/reference/classes/HotkeyManager.md +++ b/docs/reference/classes/HotkeyManager.md @@ -63,7 +63,7 @@ for (const [id, reg] of manager.registrations.state) { destroy(): void; ``` -Defined in: [hotkey-manager.ts:702](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L702) +Defined in: [hotkey-manager.ts:708](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L708) Destroys the manager and removes all listeners. @@ -79,7 +79,7 @@ Destroys the manager and removes all listeners. getRegistrationCount(): number; ``` -Defined in: [hotkey-manager.ts:673](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L673) +Defined in: [hotkey-manager.ts:679](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L679) Gets the number of registered hotkeys. @@ -95,7 +95,7 @@ Gets the number of registered hotkeys. isRegistered(hotkey, target?): boolean; ``` -Defined in: [hotkey-manager.ts:684](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L684) +Defined in: [hotkey-manager.ts:690](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L690) Checks if a specific hotkey is registered. @@ -186,7 +186,7 @@ handle.unregister() triggerRegistration(id): boolean; ``` -Defined in: [hotkey-manager.ts:637](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L637) +Defined in: [hotkey-manager.ts:643](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L643) Triggers a registration's callback programmatically from devtools. Creates a synthetic KeyboardEvent and invokes the callback. diff --git a/docs/reference/classes/SequenceManager.md b/docs/reference/classes/SequenceManager.md index e4573534..137e2805 100644 --- a/docs/reference/classes/SequenceManager.md +++ b/docs/reference/classes/SequenceManager.md @@ -28,7 +28,7 @@ Subscribe to this to observe registration changes. destroy(): void; ``` -Defined in: [sequence-manager.ts:634](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L634) +Defined in: [sequence-manager.ts:640](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L640) Destroys the manager and removes all listeners. @@ -44,7 +44,7 @@ Destroys the manager and removes all listeners. getRegistrationCount(): number; ``` -Defined in: [sequence-manager.ts:627](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L627) +Defined in: [sequence-manager.ts:633](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L633) Gets the number of registered sequences. @@ -101,7 +101,7 @@ A handle to update or unregister the sequence resetAll(): void; ``` -Defined in: [sequence-manager.ts:570](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L570) +Defined in: [sequence-manager.ts:576](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L576) Resets all sequence progress. @@ -117,7 +117,7 @@ Resets all sequence progress. triggerSequence(id): boolean; ``` -Defined in: [sequence-manager.ts:585](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L585) +Defined in: [sequence-manager.ts:591](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L591) Triggers a sequence's callback programmatically from devtools. Creates a synthetic KeyboardEvent from the last key in the sequence. diff --git a/docs/reference/functions/createSequenceMatcher.md b/docs/reference/functions/createSequenceMatcher.md index bc5e7843..36bb46bd 100644 --- a/docs/reference/functions/createSequenceMatcher.md +++ b/docs/reference/functions/createSequenceMatcher.md @@ -9,7 +9,7 @@ title: createSequenceMatcher function createSequenceMatcher(sequence, options): object; ``` -Defined in: [sequence-manager.ts:673](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L673) +Defined in: [sequence-manager.ts:679](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L679) Creates a simple sequence matcher for one-off use. diff --git a/docs/reference/functions/getHotkeyManager.md b/docs/reference/functions/getHotkeyManager.md index fdd5e2a2..3ea91f04 100644 --- a/docs/reference/functions/getHotkeyManager.md +++ b/docs/reference/functions/getHotkeyManager.md @@ -9,7 +9,7 @@ title: getHotkeyManager function getHotkeyManager(): HotkeyManager; ``` -Defined in: [hotkey-manager.ts:718](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L718) +Defined in: [hotkey-manager.ts:724](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L724) Gets the singleton HotkeyManager instance. Convenience function for accessing the manager. diff --git a/docs/reference/functions/getSequenceManager.md b/docs/reference/functions/getSequenceManager.md index 1f1d1bb0..4a6010f6 100644 --- a/docs/reference/functions/getSequenceManager.md +++ b/docs/reference/functions/getSequenceManager.md @@ -9,7 +9,7 @@ title: getSequenceManager function getSequenceManager(): SequenceManager; ``` -Defined in: [sequence-manager.ts:647](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L647) +Defined in: [sequence-manager.ts:653](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L653) Gets the singleton SequenceManager instance. Convenience function for accessing the manager.