Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-ignore-inputs-active-element.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions docs/reference/classes/HotkeyManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/classes/SequenceManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/createSequenceMatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/getHotkeyManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/getSequenceManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion packages/hotkeys/src/hotkey-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/hotkeys/src/sequence-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions packages/hotkeys/tests/hotkey-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
91 changes: 91 additions & 0 deletions packages/hotkeys/tests/sequence-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading