diff --git a/.changeset/big-buses-cross.md b/.changeset/big-buses-cross.md new file mode 100644 index 00000000..8fdbffa7 --- /dev/null +++ b/.changeset/big-buses-cross.md @@ -0,0 +1,5 @@ +--- +'@tanstack/hotkeys': patch +--- + +Recover stale held modifier keys from subsequent keyboard and mouse events when their keyup was missed without a window blur. diff --git a/docs/framework/react/guides/key-state-tracking.md b/docs/framework/react/guides/key-state-tracking.md index d0b9027d..0ee9fae4 100644 --- a/docs/framework/react/guides/key-state-tracking.md +++ b/docs/framework/react/guides/key-state-tracking.md @@ -214,6 +214,12 @@ On macOS, when a modifier key is held and a non-modifier key is pressed, the OS When the browser window loses focus, the tracker clears all held keys. Otherwise a key released after tabbing away would appear "stuck" forever. +### Missed modifier releases + +System shortcuts and browser tools can consume a modifier's `keyup` event without blurring the page. On the next keyboard event, mouse movement, or mouse button press, the tracker checks that event's modifier flags and removes any tracked Control, Alt, Shift, or Meta keys that are no longer active. This updates `useKeyHold`, `useHeldKeys`, and `useHeldKeyCodes` together. Modifiers still reported as active remain held, including when both left and right modifier keys are pressed. + +Recovery requires another event that reports the modifier state or a window blur. If neither arrives, the tracker cannot tell a missed release from a continued hold. It does not expire held keys after a timeout or infer physical key codes from modifier flags alone. + ## Under the hood All three hooks subscribe to the singleton `KeyStateTracker` via `@tanstack/react-store`. The tracker manages its own event listeners on `document` and maintains state in a TanStack Store, which the hooks subscribe to reactively. diff --git a/docs/reference/classes/KeyStateTracker.md b/docs/reference/classes/KeyStateTracker.md index 45d7c87f..851be019 100644 --- a/docs/reference/classes/KeyStateTracker.md +++ b/docs/reference/classes/KeyStateTracker.md @@ -60,7 +60,7 @@ Use this to subscribe to state changes or access current state. areAllKeysHeld(keys): boolean; ``` -Defined in: [key-state-tracker.ts:241](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L241) +Defined in: [key-state-tracker.ts:279](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L279) Checks if all of the given keys are currently held. @@ -86,7 +86,7 @@ True if all of the keys are currently held destroy(): void; ``` -Defined in: [key-state-tracker.ts:248](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L248) +Defined in: [key-state-tracker.ts:286](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L286) Destroys the tracker and removes all listeners. @@ -102,7 +102,7 @@ Destroys the tracker and removes all listeners. getHeldKeys(): string[]; ``` -Defined in: [key-state-tracker.ts:208](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L208) +Defined in: [key-state-tracker.ts:246](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L246) Gets an array of currently held key names. @@ -120,7 +120,7 @@ Array of key names currently being pressed isAnyKeyHeld(keys): boolean; ``` -Defined in: [key-state-tracker.ts:231](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L231) +Defined in: [key-state-tracker.ts:269](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L269) Checks if any of the given keys are currently held. @@ -146,7 +146,7 @@ True if any of the keys are currently held isKeyHeld(key): boolean; ``` -Defined in: [key-state-tracker.ts:218](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L218) +Defined in: [key-state-tracker.ts:256](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L256) Checks if a specific key is currently being held. @@ -172,7 +172,7 @@ True if the key is currently held static getInstance(): KeyStateTracker; ``` -Defined in: [key-state-tracker.ts:86](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L86) +Defined in: [key-state-tracker.ts:87](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L87) Gets the singleton instance of KeyStateTracker. @@ -188,7 +188,7 @@ Gets the singleton instance of KeyStateTracker. static resetInstance(): void; ``` -Defined in: [key-state-tracker.ts:96](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L96) +Defined in: [key-state-tracker.ts:97](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L97) Resets the singleton instance. Useful for testing. diff --git a/docs/reference/functions/getKeyStateTracker.md b/docs/reference/functions/getKeyStateTracker.md index 22af4a4f..8babde53 100644 --- a/docs/reference/functions/getKeyStateTracker.md +++ b/docs/reference/functions/getKeyStateTracker.md @@ -7,7 +7,7 @@ title: getKeyStateTracker function getKeyStateTracker(): KeyStateTracker; ``` -Defined in: [key-state-tracker.ts:259](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L259) +Defined in: [key-state-tracker.ts:297](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L297) Gets the singleton KeyStateTracker instance. Convenience function for accessing the tracker. diff --git a/packages/hotkeys/src/key-state-tracker.ts b/packages/hotkeys/src/key-state-tracker.ts index 4ceeffe1..804363f9 100644 --- a/packages/hotkeys/src/key-state-tracker.ts +++ b/packages/hotkeys/src/key-state-tracker.ts @@ -74,6 +74,7 @@ export class KeyStateTracker { #heldEntries: Map = new Map() #keydownListener: ((event: KeyboardEvent) => void) | null = null #keyupListener: ((event: KeyboardEvent) => void) | null = null + #mouseListener: ((event: MouseEvent) => void) | null = null #blurListener: (() => void) | null = null private constructor() { @@ -109,12 +110,14 @@ export class KeyStateTracker { } this.#keydownListener = (event: KeyboardEvent) => { + let changed = this.#reconcileModifiers(event) const key = normalizeKeyName(event.key) const identity = event.code || `key:${key}` if (!this.#heldEntries.has(identity)) { this.#heldEntries.set(identity, { key, code: event.code }) - this.#syncState() + changed = true } + if (changed) this.#syncState() } this.#keyupListener = (event: KeyboardEvent) => { @@ -129,6 +132,8 @@ export class KeyStateTracker { } } + this.#reconcileModifiers(event) + // When a modifier key is released, clear any non-modifier keys still // marked as held. On macOS, the OS intercepts modifier+key combos // (e.g. Cmd+S) and swallows the keyup event for the non-modifier key, @@ -144,6 +149,10 @@ export class KeyStateTracker { this.#syncState() } + this.#mouseListener = (event: MouseEvent) => { + if (this.#reconcileModifiers(event)) this.#syncState() + } + // Clear all keys when window loses focus (keys might be released while not focused) this.#blurListener = () => { if (this.#heldEntries.size > 0) { @@ -154,9 +163,32 @@ export class KeyStateTracker { document.addEventListener('keydown', this.#keydownListener, true) document.addEventListener('keyup', this.#keyupListener, true) + document.addEventListener('mousemove', this.#mouseListener, true) + document.addEventListener('mousedown', this.#mouseListener, true) window.addEventListener('blur', this.#blurListener) } + /** + * Removes modifiers whose release was missed without a window blur. + * Event modifier state is shared by left/right keys, so a true flag keeps + * both physical entries until their individual keyup events arrive. + */ + #reconcileModifiers(event: KeyboardEvent | MouseEvent): boolean { + let changed = false + for (const [identity, entry] of this.#heldEntries) { + const released = + (entry.key === 'Control' && !event.ctrlKey) || + (entry.key === 'Alt' && !event.altKey) || + (entry.key === 'Shift' && !event.shiftKey) || + (entry.key === 'Meta' && !event.metaKey) + if (released) { + this.#heldEntries.delete(identity) + changed = true + } + } + return changed + } + /** * Syncs the internal Set to the Store state. */ @@ -194,6 +226,12 @@ export class KeyStateTracker { this.#keyupListener = null } + if (this.#mouseListener) { + document.removeEventListener('mousemove', this.#mouseListener, true) + document.removeEventListener('mousedown', this.#mouseListener, true) + this.#mouseListener = null + } + if (this.#blurListener) { window.removeEventListener('blur', this.#blurListener) this.#blurListener = null diff --git a/packages/hotkeys/tests/key-state-tracker.test.ts b/packages/hotkeys/tests/key-state-tracker.test.ts index 40827148..124cb2f4 100644 --- a/packages/hotkeys/tests/key-state-tracker.test.ts +++ b/packages/hotkeys/tests/key-state-tracker.test.ts @@ -9,11 +9,13 @@ function dispatchKey( type: 'keydown' | 'keyup', key: string, code?: string, + modifiers: KeyboardEventInit = {}, ): KeyboardEvent { const event = new KeyboardEvent(type, { key, code: code ?? key, bubbles: true, + ...modifiers, }) document.dispatchEvent(event) return event @@ -26,6 +28,7 @@ describe('KeyStateTracker', () => { afterEach(() => { KeyStateTracker.resetInstance() + vi.restoreAllMocks() }) describe('singleton pattern', () => { @@ -72,9 +75,12 @@ describe('KeyStateTracker', () => { it('should track multiple keys', () => { const tracker = KeyStateTracker.getInstance() - dispatchKey('keydown', 'Control') - dispatchKey('keydown', 'Shift') - dispatchKey('keydown', 'a') + dispatchKey('keydown', 'Control', undefined, { ctrlKey: true }) + dispatchKey('keydown', 'Shift', undefined, { + ctrlKey: true, + shiftKey: true, + }) + dispatchKey('keydown', 'a', undefined, { ctrlKey: true, shiftKey: true }) const heldKeys = tracker.getHeldKeys() expect(heldKeys).toContain('Control') @@ -94,8 +100,11 @@ describe('KeyStateTracker', () => { it('should check if all keys are held', () => { const tracker = KeyStateTracker.getInstance() - dispatchKey('keydown', 'Control') - dispatchKey('keydown', 'Shift') + dispatchKey('keydown', 'Control', undefined, { ctrlKey: true }) + dispatchKey('keydown', 'Shift', undefined, { + ctrlKey: true, + shiftKey: true, + }) expect(tracker.areAllKeysHeld(['Control', 'Shift'])).toBe(true) expect(tracker.areAllKeysHeld(['Control', 'Shift', 'Alt'])).toBe(false) @@ -164,9 +173,12 @@ describe('KeyStateTracker', () => { it('should track multiple codes simultaneously', () => { const tracker = KeyStateTracker.getInstance() - dispatchKey('keydown', 'Control', 'ControlLeft') - dispatchKey('keydown', 'Shift', 'ShiftRight') - dispatchKey('keydown', 'a', 'KeyA') + dispatchKey('keydown', 'Control', 'ControlLeft', { ctrlKey: true }) + dispatchKey('keydown', 'Shift', 'ShiftRight', { + ctrlKey: true, + shiftKey: true, + }) + dispatchKey('keydown', 'a', 'KeyA', { ctrlKey: true, shiftKey: true }) expect(tracker.store.state.heldCodes).toEqual({ Control: 'ControlLeft', @@ -178,8 +190,8 @@ describe('KeyStateTracker', () => { it('should clear non-modifier codes when modifier is released', () => { const tracker = KeyStateTracker.getInstance() - dispatchKey('keydown', 'Meta', 'MetaLeft') - dispatchKey('keydown', 's', 'KeyS') + dispatchKey('keydown', 'Meta', 'MetaLeft', { metaKey: true }) + dispatchKey('keydown', 's', 'KeyS', { metaKey: true }) expect(tracker.store.state.heldCodes).toEqual({ Meta: 'MetaLeft', S: 'KeyS', @@ -209,8 +221,8 @@ describe('KeyStateTracker', () => { // Simulate Cmd+S: keydown Meta, keydown S, then only keyup Meta // (macOS swallows the keyup for S) - dispatchKey('keydown', 'Meta') - dispatchKey('keydown', 's') + dispatchKey('keydown', 'Meta', undefined, { metaKey: true }) + dispatchKey('keydown', 's', undefined, { metaKey: true }) expect(tracker.getHeldKeys()).toContain('Meta') expect(tracker.getHeldKeys()).toContain('S') @@ -224,8 +236,8 @@ describe('KeyStateTracker', () => { it('should clear non-modifier keys when Control is released', () => { const tracker = KeyStateTracker.getInstance() - dispatchKey('keydown', 'Control') - dispatchKey('keydown', 'c') + dispatchKey('keydown', 'Control', undefined, { ctrlKey: true }) + dispatchKey('keydown', 'c', undefined, { ctrlKey: true }) dispatchKey('keyup', 'Control') expect(tracker.isKeyHeld('Control')).toBe(false) @@ -236,8 +248,8 @@ describe('KeyStateTracker', () => { it('should clear non-modifier keys when Alt is released', () => { const tracker = KeyStateTracker.getInstance() - dispatchKey('keydown', 'Alt') - dispatchKey('keydown', 'Tab') + dispatchKey('keydown', 'Alt', undefined, { altKey: true }) + dispatchKey('keydown', 'Tab', undefined, { altKey: true }) dispatchKey('keyup', 'Alt') expect(tracker.isKeyHeld('Alt')).toBe(false) @@ -249,10 +261,13 @@ describe('KeyStateTracker', () => { const tracker = KeyStateTracker.getInstance() // Ctrl+Shift+S — release Ctrl, Shift should remain - dispatchKey('keydown', 'Control') - dispatchKey('keydown', 'Shift') - dispatchKey('keydown', 's') - dispatchKey('keyup', 'Control') + dispatchKey('keydown', 'Control', undefined, { ctrlKey: true }) + dispatchKey('keydown', 'Shift', undefined, { + ctrlKey: true, + shiftKey: true, + }) + dispatchKey('keydown', 's', undefined, { ctrlKey: true, shiftKey: true }) + dispatchKey('keyup', 'Control', undefined, { shiftKey: true }) expect(tracker.isKeyHeld('Control')).toBe(false) expect(tracker.isKeyHeld('Shift')).toBe(true) @@ -273,6 +288,152 @@ describe('KeyStateTracker', () => { }) }) + describe('recovery after missed modifier releases', () => { + const modifiers = [ + ['Control', 'ControlLeft', { ctrlKey: true }], + ['Alt', 'AltLeft', { altKey: true }], + ['Shift', 'ShiftLeft', { shiftKey: true }], + ['Meta', 'MetaLeft', { metaKey: true }], + ] as const + + describe.each(modifiers)('%s', (modifier, code, flags) => { + it.each(['keydown', 'keyup', 'mousemove', 'mousedown'])( + 'recovers on %s without a modifier keyup or blur', + (type) => { + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', modifier, code, flags) + // The OS consumed the modifier release without blurring the page. + // A later event reports that no modifier is active. + if (type === 'keydown' || type === 'keyup') { + dispatchKey(type, 'a', 'KeyA') + } else { + document.dispatchEvent(new MouseEvent(type, { bubbles: true })) + } + expect(tracker.isKeyHeld(modifier)).toBe(false) + expect(tracker.store.state.heldKeys).not.toContain(modifier) + expect(tracker.store.state.heldCodes).not.toHaveProperty(modifier) + }, + ) + + it('preserves a held modifier across keyboard and mouse events', () => { + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', modifier, code, flags) + dispatchKey('keydown', 'a', 'KeyA', flags) + dispatchKey('keyup', 'a', 'KeyA', flags) + const state = tracker.store.state + document.dispatchEvent(new MouseEvent('mousemove', flags)) + document.dispatchEvent(new MouseEvent('mousedown', flags)) + expect(tracker.store.state).toBe(state) + expect(tracker.store.state.heldCodes).toEqual({ [modifier]: code }) + }) + }) + + it('keeps the other physical modifier held until its own release', () => { + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', 'Shift', 'ShiftLeft', { shiftKey: true }) + dispatchKey('keydown', 'Shift', 'ShiftRight', { shiftKey: true }) + dispatchKey('keyup', 'Shift', 'ShiftRight', { shiftKey: true }) + expect(tracker.store.state).toEqual({ + heldKeys: ['Shift'], + heldCodes: { Shift: 'ShiftLeft' }, + }) + dispatchKey('keyup', 'Shift', 'ShiftLeft') + expect(tracker.store.state).toEqual({ heldKeys: [], heldCodes: {} }) + }) + + it('clears both stale physical modifiers and code-less modifiers', () => { + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', 'Shift', 'ShiftLeft', { shiftKey: true }) + dispatchKey('keydown', 'Shift', 'ShiftRight', { shiftKey: true }) + dispatchKey('keydown', 'Meta', '', { shiftKey: true, metaKey: true }) + document.dispatchEvent(new MouseEvent('mousemove')) + expect(tracker.store.state).toEqual({ heldKeys: [], heldCodes: {} }) + }) + + it('removes only released modifiers and publishes one final state per keyboard event', () => { + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', 'Control', 'ControlLeft', { ctrlKey: true }) + dispatchKey('keydown', 'Shift', 'ShiftLeft', { + ctrlKey: true, + shiftKey: true, + }) + const listener = vi.fn() + const subscription = tracker.store.subscribe(() => + listener(tracker.store.state), + ) + dispatchKey('keydown', 'a', 'KeyA', { shiftKey: true }) + expect(listener).toHaveBeenCalledExactlyOnceWith({ + heldKeys: ['Shift', 'A'], + heldCodes: { Shift: 'ShiftLeft', A: 'KeyA' }, + }) + subscription.unsubscribe() + }) + + it('recovers on a repeated keydown even when no new key entry is added', () => { + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', 'Shift', 'ShiftLeft', { shiftKey: true }) + dispatchKey('keydown', 'a', 'KeyA', { shiftKey: true }) + dispatchKey('keydown', 'a', 'KeyA', { repeat: true }) + expect(tracker.store.state).toEqual({ + heldKeys: ['A'], + heldCodes: { A: 'KeyA' }, + }) + }) + + it('does not infer physical keys or discard held non-modifiers from mouse state', () => { + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', 'a', 'KeyA') + const state = tracker.store.state + document.dispatchEvent(new MouseEvent('mousemove', { shiftKey: true })) + document.dispatchEvent(new MouseEvent('mousemove')) + expect(tracker.store.state).toBe(state) + expect(tracker.store.state.heldCodes).toEqual({ A: 'KeyA' }) + }) + + it('publishes mouse recovery once and removes listeners when destroyed', () => { + const addListener = vi.spyOn(document, 'addEventListener') + const removeListener = vi.spyOn(document, 'removeEventListener') + const tracker = KeyStateTracker.getInstance() + dispatchKey('keydown', 'Shift', 'ShiftLeft', { shiftKey: true }) + const listener = vi.fn() + const subscription = tracker.store.subscribe(listener) + document.dispatchEvent(new MouseEvent('mousemove')) + document.dispatchEvent(new MouseEvent('mousemove')) + document.dispatchEvent(new MouseEvent('mousedown')) + expect(listener).toHaveBeenCalledTimes(1) + tracker.destroy() + for (const [type, handler, options] of addListener.mock.calls) { + if (type === 'mousemove' || type === 'mousedown') { + expect(removeListener).toHaveBeenCalledWith(type, handler, options) + } + } + listener.mockClear() + dispatchKey('keydown', 'Shift', 'ShiftLeft', { shiftKey: true }) + document.dispatchEvent(new MouseEvent('mousemove')) + document.dispatchEvent(new MouseEvent('mousedown')) + expect(listener).not.toHaveBeenCalled() + expect(tracker.store.state).toEqual({ heldKeys: [], heldCodes: {} }) + subscription.unsubscribe() + }) + + it('recovers before a child mouse handler that stops propagation', () => { + const tracker = KeyStateTracker.getInstance() + const child = document.createElement('div') + document.body.append(child) + child.addEventListener('mousedown', (event) => { + event.stopPropagation() + expect(tracker.isKeyHeld('Shift')).toBe(false) + }) + try { + dispatchKey('keydown', 'Shift', 'ShiftLeft', { shiftKey: true }) + child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + expect(tracker.isKeyHeld('Shift')).toBe(false) + } finally { + child.remove() + } + }) + }) + describe('case insensitivity', () => { it('should normalize key names', () => { const tracker = KeyStateTracker.getInstance() diff --git a/packages/lit-hotkeys/tests/hotkey-hint.spec.ts b/packages/lit-hotkeys/tests/hotkey-hint.spec.ts index 1a40a47a..9cb821ce 100644 --- a/packages/lit-hotkeys/tests/hotkey-hint.spec.ts +++ b/packages/lit-hotkeys/tests/hotkey-hint.spec.ts @@ -16,12 +16,12 @@ it('tracks relevant modifiers, reactive inputs, and disconnect without unrelated const hint = new HotkeyHintController(host, () => binding) hint.hostConnected() document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft' }), + new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft', altKey: true }), ) expect(hint.value).toBe(true) expect(host.requestUpdate).toHaveBeenCalledOnce() document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'x', code: 'KeyX' }), + new KeyboardEvent('keydown', { key: 'x', code: 'KeyX', altKey: true }), ) expect(host.requestUpdate).toHaveBeenCalledOnce() binding = 'Control+[KeyS]' diff --git a/packages/react-hotkeys/tests/useKeyHold.test.tsx b/packages/react-hotkeys/tests/useKeyHold.test.tsx new file mode 100644 index 00000000..21d95b8d --- /dev/null +++ b/packages/react-hotkeys/tests/useKeyHold.test.tsx @@ -0,0 +1,41 @@ +import { afterEach, expect, it } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import { KeyStateTracker } from '@tanstack/hotkeys' +import { useKeyHold } from '../src' + +afterEach(() => { + cleanup() + KeyStateTracker.resetInstance() +}) + +it.each(['keyboard', 'mouse'])( + 'updates after a missed modifier keyup when the next %s event reports its release', + (input) => { + const { result } = renderHook(() => useKeyHold('Shift')) + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Shift', + code: 'ShiftLeft', + shiftKey: true, + bubbles: true, + }), + ) + }) + expect(result.current).toBe(true) + + // Simulate a missing release with no blur, followed by ordinary input. + act(() => { + document.dispatchEvent( + input === 'keyboard' + ? new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + bubbles: true, + }) + : new MouseEvent('mousemove', { bubbles: true }), + ) + }) + expect(result.current).toBe(false) + }, +) diff --git a/packages/svelte-hotkeys/tests/getHotkeyHint.test.ts b/packages/svelte-hotkeys/tests/getHotkeyHint.test.ts index 46be1d09..73faa092 100644 --- a/packages/svelte-hotkeys/tests/getHotkeyHint.test.ts +++ b/packages/svelte-hotkeys/tests/getHotkeyHint.test.ts @@ -11,20 +11,25 @@ it('selects visibility, tracks binding changes and cleans up subscriptions', () flushSync() expect(notify).toHaveBeenLastCalledWith(false) document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft' }), + new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft', altKey: true }), ) flushSync() expect(notify).toHaveBeenLastCalledWith(true) const count = notify.mock.calls.length document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'x', code: 'KeyX' }), + new KeyboardEvent('keydown', { key: 'x', code: 'KeyX', altKey: true }), ) flushSync() expect(notify).toHaveBeenCalledTimes(count) app.setBinding('Alt+Control+[KeyS]') flushSync() document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Control', code: 'ControlLeft' }), + new KeyboardEvent('keydown', { + key: 'Control', + code: 'ControlLeft', + ctrlKey: true, + altKey: true, + }), ) flushSync() expect(notify).toHaveBeenLastCalledWith(true) @@ -32,7 +37,11 @@ it('selects visibility, tracks binding changes and cleans up subscriptions', () flushSync() expect(notify).toHaveBeenLastCalledWith(false) document.dispatchEvent( - new KeyboardEvent('keyup', { key: 'Control', code: 'ControlLeft' }), + new KeyboardEvent('keyup', { + key: 'Control', + code: 'ControlLeft', + altKey: true, + }), ) flushSync() expect(notify).toHaveBeenLastCalledWith(true)