diff --git a/.changeset/red-pens-taste.md b/.changeset/red-pens-taste.md new file mode 100644 index 0000000..6bfd6ca --- /dev/null +++ b/.changeset/red-pens-taste.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-hotkeys': patch +'@tanstack/hotkeys': patch +--- + +Support for the ignoreInputs flag in useHotkeySequence 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 32a30fb..b4c613d 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,9 @@ export class SequenceManager { parseHotkey(hotkey, platform), ) + const resolvedIgnoreInputs = + options.ignoreInputs ?? getDefaultIgnoreInputsForSequence(parsedSequence) + const registration: SequenceRegistration = { id, sequence, @@ -142,6 +160,7 @@ export class SequenceManager { enabled: true, ...options, platform, + ignoreInputs: resolvedIgnoreInputs, }, currentIndex: 0, lastKeyTime: 0, @@ -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. */ @@ -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 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]) }