From 785066fe938e3ecee8e53f441e19cafdeebd58e5 Mon Sep 17 00:00:00 2001 From: Kade Angell Date: Tue, 17 Feb 2026 14:55:08 -0700 Subject: [PATCH 1/4] fix: add ignoreInputs support to SequenceManager and useHotkeySequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SequenceManager never checked the ignoreInputs option, so keyboard sequences like G+A and G+G would fire even when the user was typing in input, textarea, select, or contentEditable elements. Two issues were fixed: 1. SequenceManager (#handleKeyDown) now checks ignoreInputs before matching keys. A new #isInputElement() method mirrors the same logic from HotkeyManager. The ignoreInputs default is resolved at registration time using the first step of the sequence — Ctrl/Meta combos and Escape default to false (fire in inputs), while single keys and Shift/Alt combos default to true (ignored in inputs). 2. useHotkeySequence React hook was silently dropping the ignoreInputs option — it only forwarded timeout and platform to manager.register(). Now ignoreInputs is extracted, forwarded, and included in the effect dependency array. Added tests covering: single-key sequences ignored in input/textarea/ contentEditable by default, Mod sequences firing in inputs by default, explicit ignoreInputs: true/false overrides, and button-type inputs not being treated as text inputs. --- packages/hotkeys/src/sequence.ts | 60 ++++++ packages/hotkeys/tests/sequence.test.ts | 174 ++++++++++++++++++ .../react-hotkeys/src/useHotkeySequence.ts | 5 +- 3 files changed, 237 insertions(+), 2 deletions(-) diff --git a/packages/hotkeys/src/sequence.ts b/packages/hotkeys/src/sequence.ts index 32a30fb..7cc3232 100644 --- a/packages/hotkeys/src/sequence.ts +++ b/packages/hotkeys/src/sequence.ts @@ -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, +): 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. */ @@ -130,6 +145,10 @@ export class SequenceManager { parseHotkey(hotkey, platform), ) + const resolvedIgnoreInputs = + options.ignoreInputs ?? + getDefaultIgnoreInputsForSequence(parsedSequence) + const registration: SequenceRegistration = { id, sequence, @@ -142,6 +161,7 @@ export class SequenceManager { enabled: true, ...options, platform, + ignoreInputs: resolvedIgnoreInputs, }, currentIndex: 0, lastKeyTime: 0, @@ -194,6 +214,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. */ @@ -205,6 +258,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 diff --git a/packages/hotkeys/tests/sequence.test.ts b/packages/hotkeys/tests/sequence.test.ts index 98ecf0d..786708f 100644 --- a/packages/hotkeys/tests/sequence.test.ts +++ b/packages/hotkeys/tests/sequence.test.ts @@ -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() diff --git a/packages/react-hotkeys/src/useHotkeySequence.ts b/packages/react-hotkeys/src/useHotkeySequence.ts index 02b13ec..6dada72 100644 --- a/packages/react-hotkeys/src/useHotkeySequence.ts +++ b/packages/react-hotkeys/src/useHotkeySequence.ts @@ -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) @@ -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, @@ -88,5 +89,5 @@ export function useHotkeySequence( ) return unregister - }, [enabled, sequence, sequenceKey, timeout, platform]) + }, [enabled, sequence, sequenceKey, timeout, platform, ignoreInputs]) } From a7bf5b370ea59eac51898fded4a12b380d87fa0b Mon Sep 17 00:00:00 2001 From: Kade Angell Date: Tue, 17 Feb 2026 15:18:12 -0700 Subject: [PATCH 2/4] changeset --- .changeset/red-pens-taste.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/red-pens-taste.md diff --git a/.changeset/red-pens-taste.md b/.changeset/red-pens-taste.md new file mode 100644 index 0000000..4a62ddb --- /dev/null +++ b/.changeset/red-pens-taste.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-hotkeys': minor +'@tanstack/hotkeys': minor +--- + +Support for the ignoreInputs flag in useHotkeySequence From 350971b072571a37f41eb950727aae23f594042e Mon Sep 17 00:00:00 2001 From: Kade Angell Date: Tue, 17 Feb 2026 15:20:06 -0700 Subject: [PATCH 3/4] changeset update: patch bump, not minor bump --- .changeset/red-pens-taste.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/red-pens-taste.md b/.changeset/red-pens-taste.md index 4a62ddb..6bfd6ca 100644 --- a/.changeset/red-pens-taste.md +++ b/.changeset/red-pens-taste.md @@ -1,6 +1,6 @@ --- -'@tanstack/react-hotkeys': minor -'@tanstack/hotkeys': minor +'@tanstack/react-hotkeys': patch +'@tanstack/hotkeys': patch --- Support for the ignoreInputs flag in useHotkeySequence From 558ff184e214b0941a979c2e216b1bc92bbd797c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:55:48 +0000 Subject: [PATCH 4/4] ci: apply automated fixes --- docs/reference/classes/SequenceManager.md | 14 +++++++------- docs/reference/functions/createSequenceMatcher.md | 2 +- docs/reference/functions/getSequenceManager.md | 2 +- packages/hotkeys/src/sequence.ts | 3 +-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/reference/classes/SequenceManager.md b/docs/reference/classes/SequenceManager.md index 118cb6c..70d0d52 100644 --- a/docs/reference/classes/SequenceManager.md +++ b/docs/reference/classes/SequenceManager.md @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/docs/reference/functions/createSequenceMatcher.md b/docs/reference/functions/createSequenceMatcher.md index 035a860..4fd7be6 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.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. diff --git a/docs/reference/functions/getSequenceManager.md b/docs/reference/functions/getSequenceManager.md index 05b5dbd..51bd40c 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.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. diff --git a/packages/hotkeys/src/sequence.ts b/packages/hotkeys/src/sequence.ts index 7cc3232..b4c613d 100644 --- a/packages/hotkeys/src/sequence.ts +++ b/packages/hotkeys/src/sequence.ts @@ -146,8 +146,7 @@ export class SequenceManager { ) const resolvedIgnoreInputs = - options.ignoreInputs ?? - getDefaultIgnoreInputsForSequence(parsedSequence) + options.ignoreInputs ?? getDefaultIgnoreInputsForSequence(parsedSequence) const registration: SequenceRegistration = { id,