diff --git a/.changeset/clean-cups-arrive.md b/.changeset/clean-cups-arrive.md new file mode 100644 index 00000000..42db1c6a --- /dev/null +++ b/.changeset/clean-cups-arrive.md @@ -0,0 +1,5 @@ +--- +'@tanstack/hotkeys': patch +--- + +fix: detect ignored input elements across browser realms diff --git a/packages/hotkeys/src/manager.utils.ts b/packages/hotkeys/src/manager.utils.ts index a8c01b7b..ad60a33a 100644 --- a/packages/hotkeys/src/manager.utils.ts +++ b/packages/hotkeys/src/manager.utils.ts @@ -46,32 +46,36 @@ export function getDefaultIgnoreInputs(parsedHotkey: ParsedHotkey): boolean { * Mod+S and Escape fire when the user has tabbed to a form button. */ export function isInputElement(element: EventTarget | null): boolean { - if (!element) { + if ( + !element || + !('tagName' in element) || + typeof element.tagName !== 'string' + ) { return false } - if (element instanceof HTMLInputElement) { - const type = element.type.toLowerCase() + // Do not use instanceof here: elements from another realm (for example an + // iframe) have different DOM constructors than the current global object. + const tagName = element.tagName.toLowerCase() + + if (tagName === 'input') { + const type = + 'type' in element && typeof element.type === 'string' + ? element.type.toLowerCase() + : '' if (type === 'button' || type === 'submit' || type === 'reset') { return false } return true } - if ( - element instanceof HTMLTextAreaElement || - element instanceof HTMLSelectElement - ) { + if (tagName === 'textarea' || tagName === 'select') { return true } // Check for contenteditable elements (includes "true", "", "plaintext-only", // and inherited contenteditable from ancestor elements) - if (element instanceof HTMLElement && element.isContentEditable) { - return true - } - - return false + return 'isContentEditable' in element && element.isContentEditable === true } /** diff --git a/packages/hotkeys/tests/manager.utils.test.ts b/packages/hotkeys/tests/manager.utils.test.ts index 073ee287..d30a9292 100644 --- a/packages/hotkeys/tests/manager.utils.test.ts +++ b/packages/hotkeys/tests/manager.utils.test.ts @@ -81,6 +81,35 @@ describe('manager.utils', () => { const div = document.createElement('div') expect(isInputElement(div)).toBe(false) }) + + it('should not rely on current-realm DOM constructors', () => { + const input = document.createElement('input') + input.type = 'text' + const button = document.createElement('input') + button.type = 'button' + const textarea = document.createElement('textarea') + const select = document.createElement('select') + const editable = document.createElement('div') + editable.contentEditable = 'true' + const regular = document.createElement('div') + + vi.stubGlobal('HTMLInputElement', class {}) + vi.stubGlobal('HTMLTextAreaElement', class {}) + vi.stubGlobal('HTMLSelectElement', class {}) + vi.stubGlobal('HTMLElement', class {}) + + try { + expect(input).not.toBeInstanceOf(HTMLInputElement) + expect(isInputElement(input)).toBe(true) + expect(isInputElement(button)).toBe(false) + expect(isInputElement(textarea)).toBe(true) + expect(isInputElement(select)).toBe(true) + expect(isInputElement(editable)).toBe(true) + expect(isInputElement(regular)).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) }) describe('getActiveElementForListenerTarget', () => {