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
6 changes: 6 additions & 0 deletions .changeset/red-pens-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/react-hotkeys': patch
'@tanstack/hotkeys': patch
---

Support for the ignoreInputs flag in useHotkeySequence
14 changes: 7 additions & 7 deletions docs/reference/classes/SequenceManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: SequenceManager

# Class: SequenceManager

Defined in: [sequence.ts:79](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L79)
Defined in: [sequence.ts:94](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L94)

Manages keyboard sequence matching for Vim-style shortcuts.

Expand Down Expand Up @@ -35,7 +35,7 @@ unregister()
destroy(): void;
```

Defined in: [sequence.ts:300](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L300)
Defined in: [sequence.ts:359](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L359)

Destroys the manager and removes all listeners.

Expand All @@ -51,7 +51,7 @@ Destroys the manager and removes all listeners.
getRegistrationCount(): number;
```

Defined in: [sequence.ts:293](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L293)
Defined in: [sequence.ts:352](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L352)

Gets the number of registered sequences.

Expand All @@ -70,7 +70,7 @@ register(
options): () => void;
```

Defined in: [sequence.ts:118](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L118)
Defined in: [sequence.ts:133](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L133)

Registers a hotkey sequence handler.

Expand Down Expand Up @@ -114,7 +114,7 @@ A function to unregister the sequence
resetAll(): void;
```

Defined in: [sequence.ts:283](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L283)
Defined in: [sequence.ts:342](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L342)

Resets all sequence progress.

Expand All @@ -130,7 +130,7 @@ Resets all sequence progress.
static getInstance(): SequenceManager;
```

Defined in: [sequence.ts:93](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L93)
Defined in: [sequence.ts:108](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L108)

Gets the singleton instance of SequenceManager.

Expand All @@ -146,7 +146,7 @@ Gets the singleton instance of SequenceManager.
static resetInstance(): void;
```

Defined in: [sequence.ts:103](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L103)
Defined in: [sequence.ts:118](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L118)

Resets the singleton instance. Useful for testing.

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.ts:332](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L332)
Defined in: [sequence.ts:391](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L391)

Creates a simple sequence matcher for one-off use.

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.ts:310](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L310)
Defined in: [sequence.ts:369](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence.ts#L369)

Gets the singleton SequenceManager instance.
Convenience function for accessing the manager.
Expand Down
59 changes: 59 additions & 0 deletions packages/hotkeys/src/sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ function generateSequenceId(): string {
return `sequence_${++sequenceIdCounter}`
}

/**
* Computes the default ignoreInputs value for a sequence based on its first step.
* Uses the same logic as HotkeyManager: Ctrl/Meta combos and Escape fire in inputs;
* single keys and Shift/Alt combos are ignored.
*/
function getDefaultIgnoreInputsForSequence(
parsedSequence: Array<ParsedHotkey>,
): boolean {
const firstStep = parsedSequence[0]
if (!firstStep) return true
if (firstStep.ctrl || firstStep.meta) return false
if (firstStep.key === 'Escape') return false
return true
}

/**
* Internal representation of a sequence registration.
*/
Expand Down Expand Up @@ -130,6 +145,9 @@ export class SequenceManager {
parseHotkey(hotkey, platform),
)

const resolvedIgnoreInputs =
options.ignoreInputs ?? getDefaultIgnoreInputsForSequence(parsedSequence)

const registration: SequenceRegistration = {
id,
sequence,
Expand All @@ -142,6 +160,7 @@ export class SequenceManager {
enabled: true,
...options,
platform,
ignoreInputs: resolvedIgnoreInputs,
},
currentIndex: 0,
lastKeyTime: 0,
Expand Down Expand Up @@ -194,6 +213,39 @@ export class SequenceManager {
}
}

/**
* Checks if an element is an input-like element that should be ignored.
*/
#isInputElement(element: EventTarget | null): boolean {
if (!element) {
return false
}

if (element instanceof HTMLInputElement) {
const type = element.type.toLowerCase()
if (type === 'button' || type === 'submit' || type === 'reset') {
return false
}
return true
}

if (
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
return true
}

if (element instanceof HTMLElement) {
const contentEditable = element.contentEditable
if (contentEditable === 'true' || contentEditable === '') {
return true
}
}

return false
}

/**
* Handles keydown events for sequence matching.
*/
Expand All @@ -205,6 +257,13 @@ export class SequenceManager {
continue
}

// Check if we should ignore input elements
if (registration.options.ignoreInputs !== false) {
if (this.#isInputElement(event.target)) {
continue
}
}

const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT

// Check if sequence has timed out
Expand Down
174 changes: 174 additions & 0 deletions packages/hotkeys/tests/sequence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,180 @@ describe('SequenceManager', () => {
})
})

describe('ignoreInputs option', () => {
/**
* Helper to dispatch a keyboard event from a specific element
*/
function dispatchKeyFromElement(
element: HTMLElement,
key: string,
options: {
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
metaKey?: boolean
} = {},
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key,
ctrlKey: options.ctrlKey ?? false,
shiftKey: options.shiftKey ?? false,
altKey: options.altKey ?? false,
metaKey: options.metaKey ?? false,
bubbles: true,
})
Object.defineProperty(event, 'target', {
value: element,
writable: false,
configurable: true,
})
Object.defineProperty(event, 'currentTarget', {
value: document,
writable: false,
configurable: true,
})
document.dispatchEvent(event)
return event
}

it('should ignore single-key sequences in input elements by default', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['G', 'G'], callback)

const input = document.createElement('input')
document.body.appendChild(input)

dispatchKeyFromElement(input, 'g')
dispatchKeyFromElement(input, 'g')

expect(callback).not.toHaveBeenCalled()

document.body.removeChild(input)
})

it('should ignore single-key sequences in textarea elements by default', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['G', 'G'], callback)

const textarea = document.createElement('textarea')
document.body.appendChild(textarea)

dispatchKeyFromElement(textarea, 'g')
dispatchKeyFromElement(textarea, 'g')

expect(callback).not.toHaveBeenCalled()

document.body.removeChild(textarea)
})

it('should ignore single-key sequences in contenteditable elements by default', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['G', 'G'], callback)

const div = document.createElement('div')
div.contentEditable = 'true'
document.body.appendChild(div)

dispatchKeyFromElement(div, 'g')
dispatchKeyFromElement(div, 'g')

expect(callback).not.toHaveBeenCalled()

document.body.removeChild(div)
})

it('should fire sequences starting with Mod key in inputs by default', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['Mod+K', 'S'], callback, { platform: 'mac' })

const input = document.createElement('input')
document.body.appendChild(input)

dispatchKeyFromElement(input, 'k', { metaKey: true })
dispatchKeyFromElement(input, 's')

expect(callback).toHaveBeenCalledTimes(1)

document.body.removeChild(input)
})

it('should respect explicit ignoreInputs: true even for Mod sequences', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['Mod+K', 'S'], callback, {
platform: 'mac',
ignoreInputs: true,
})

const input = document.createElement('input')
document.body.appendChild(input)

dispatchKeyFromElement(input, 'k', { metaKey: true })
dispatchKeyFromElement(input, 's')

expect(callback).not.toHaveBeenCalled()

document.body.removeChild(input)
})

it('should respect explicit ignoreInputs: false for single-key sequences', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['G', 'G'], callback, { ignoreInputs: false })

const input = document.createElement('input')
document.body.appendChild(input)

dispatchKeyFromElement(input, 'g')
dispatchKeyFromElement(input, 'g')

expect(callback).toHaveBeenCalledTimes(1)

document.body.removeChild(input)
})

it('should fire single-key sequences outside of input elements', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['G', 'G'], callback)

// dispatch from a non-input element
dispatchKey('g')
dispatchKey('g')

expect(callback).toHaveBeenCalledTimes(1)
})

it('should not ignore button-type inputs', () => {
const manager = SequenceManager.getInstance()
const callback = vi.fn()

manager.register(['G', 'G'], callback)

const button = document.createElement('input')
button.type = 'button'
document.body.appendChild(button)

dispatchKeyFromElement(button, 'g')
dispatchKeyFromElement(button, 'g')

expect(callback).toHaveBeenCalledTimes(1)

document.body.removeChild(button)
})
})

describe('longer sequences', () => {
it('should match three-key sequences', () => {
const manager = SequenceManager.getInstance()
Expand Down
5 changes: 3 additions & 2 deletions packages/react-hotkeys/src/useHotkeySequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function useHotkeySequence(
const { enabled = true, ...sequenceOptions } = mergedOptions

// Extract options for stable dependencies
const { timeout, platform } = sequenceOptions
const { timeout, platform, ignoreInputs } = sequenceOptions

// Use refs to keep callback stable
const callbackRef = useRef(callback)
Expand All @@ -80,6 +80,7 @@ export function useHotkeySequence(
const registerOptions: SequenceOptions = { enabled: true }
if (timeout !== undefined) registerOptions.timeout = timeout
if (platform !== undefined) registerOptions.platform = platform
if (ignoreInputs !== undefined) registerOptions.ignoreInputs = ignoreInputs

const unregister = manager.register(
sequence,
Expand All @@ -88,5 +89,5 @@ export function useHotkeySequence(
)

return unregister
}, [enabled, sequence, sequenceKey, timeout, platform])
}, [enabled, sequence, sequenceKey, timeout, platform, ignoreInputs])
}