Skip to content

Commit 16e16fe

Browse files
committed
fix(key-bindings): Handle overlapping key bindings correctly
- When multiple keys are pressed in quick succession, it's possible for their keydown events to overlap before the corresponding keyup events fire. This can lead to incorrect binding matches if the logic doesn't account for the current state of pressed keys. The fix involves ensuring that the key event handling logic correctly identifies which keys are currently pressed and matches them against the defined bindings, even when overlaps occur. - Additionally, the skip logic is improved to prevent keys from getting stuck if the skip condition changes between keydown and keyup events.
1 parent 7c7641c commit 16e16fe

3 files changed

Lines changed: 104 additions & 14 deletions

File tree

src/components/common/controllers/key-bindings.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { simulateKeyboard } from '../utils.spec.js';
1010
import {
1111
addKeybindings,
1212
altKey,
13+
arrowDown,
1314
arrowUp,
1415
enterKey,
1516
shiftKey,
@@ -245,6 +246,69 @@ describe('Key bindings controller', () => {
245246
dispatch(repeatInstance, 'keydown', 'b', true);
246247
expect(repeatInstance.callCount).to.equal(1);
247248
});
249+
250+
it('should fire the correct binding when keys overlap (keyup not yet fired for previous key)', () => {
251+
// Simulate pressing ArrowDown a couple of times, then pressing ArrowUp before
252+
// ArrowDown's keyup fires — mimicking rapid navigation where keys overlap.
253+
let repeatInstance2: LitElement & {
254+
lastKey: string | undefined;
255+
downCount: number;
256+
upCount: number;
257+
};
258+
259+
const repeatTag2 = defineCE(
260+
class extends LitElement {
261+
public lastKey: string | undefined = undefined;
262+
public downCount = 0;
263+
public upCount = 0;
264+
265+
constructor() {
266+
super();
267+
addKeybindings(this)
268+
.set(
269+
arrowDown,
270+
() => {
271+
this.lastKey = arrowDown;
272+
this.downCount++;
273+
},
274+
{ repeat: true }
275+
)
276+
.set(
277+
arrowUp,
278+
() => {
279+
this.lastKey = arrowUp;
280+
this.upCount++;
281+
},
282+
{ repeat: true }
283+
);
284+
}
285+
}
286+
);
287+
288+
return fixture<typeof repeatInstance2>(
289+
html`<${unsafeStatic(repeatTag2)}></${unsafeStatic(repeatTag2)}>`
290+
).then((el) => {
291+
repeatInstance2 = el;
292+
293+
// Press ArrowDown twice (with repeat)
294+
dispatch(repeatInstance2, 'keydown', arrowDown);
295+
dispatch(repeatInstance2, 'keydown', arrowDown, true);
296+
297+
// Press ArrowUp while ArrowDown is still "held" (no keyup yet)
298+
dispatch(repeatInstance2, 'keydown', arrowUp);
299+
300+
expect(repeatInstance2.upCount).to.equal(1);
301+
expect(repeatInstance2.lastKey).to.equal(arrowUp);
302+
303+
// Release both keys
304+
dispatch(repeatInstance2, 'keyup', arrowDown);
305+
dispatch(repeatInstance2, 'keyup', arrowUp);
306+
307+
// Press ArrowDown again — should still work cleanly after the overlap
308+
dispatch(repeatInstance2, 'keydown', arrowDown);
309+
expect(repeatInstance2.downCount).to.equal(3);
310+
});
311+
});
248312
});
249313
});
250314

src/components/common/controllers/key-bindings.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const tabKey = 'Tab' as const;
2727

2828
/* Modifiers */
2929
export const altKey = 'Alt' as const;
30-
export const ctrlKey = 'Ctrl' as const;
30+
export const ctrlKey = 'Control' as const;
3131
export const metaKey = 'Meta' as const;
3232
export const shiftKey = 'Shift' as const;
3333

@@ -135,9 +135,20 @@ interface KeyBinding {
135135

136136
//#region Internal functions and constants
137137

138-
const MODIFIERS = new Set(['alt', 'ctrl', 'meta', 'shift']);
138+
const MODIFIERS = new Set(['alt', 'control', 'meta', 'shift']);
139139
const ALL_MODIFIER_VALUES = Array.from(MODIFIERS).sort();
140140

141+
/**
142+
* Maps internal modifier names to their corresponding `KeyboardEvent` boolean property names.
143+
* Needed because `ctrlKey` in the DOM is the property for the `'control'` modifier (not `'controlKey'`).
144+
*/
145+
export const MODIFIER_EVENT_KEYS: Record<string, string> = {
146+
alt: 'altKey',
147+
control: 'ctrlKey',
148+
meta: 'metaKey',
149+
shift: 'shiftKey',
150+
};
151+
141152
function normalizeKeys(keys: string | string[]): string[] {
142153
return asArray(keys).map((key) => key.toLowerCase());
143154
}
@@ -285,11 +296,9 @@ class KeyBindingController implements ReactiveController {
285296
return Boolean(getElementFromPath(this._skipSelector, event));
286297
}
287298

288-
if (isFunction(skip)) {
289-
return skip.call(this._host, event.target as Element, event);
290-
}
291-
292-
return false;
299+
return isFunction(skip)
300+
? skip.call(this._host, event.target as Element, event)
301+
: false;
293302
}
294303

295304
//#endregion
@@ -335,6 +344,11 @@ class KeyBindingController implements ReactiveController {
335344
*/
336345
private _handleKeyEvent(event: KeyboardEvent): void {
337346
if (this._shouldSkip(event)) {
347+
// Always clean up on keyup regardless of whether the event is otherwise skipped.
348+
const key = event.key.toLowerCase();
349+
if (!MODIFIERS.has(key) && isKeyup(event)) {
350+
this._pressedKeys.delete(key);
351+
}
338352
return;
339353
}
340354

@@ -345,14 +359,24 @@ class KeyBindingController implements ReactiveController {
345359
}
346360

347361
const activeModifiers = ALL_MODIFIER_VALUES.filter(
348-
(mod) => event[`${mod}Key` as keyof KeyboardEvent]
362+
(mod) => event[MODIFIER_EVENT_KEYS[mod] as keyof KeyboardEvent]
349363
);
350364

351365
const combination = createCombinationKey(
352366
Array.from(this._pressedKeys),
353367
activeModifiers
354368
);
355-
const binding = this._bindings.get(combination);
369+
let binding = this._bindings.get(combination);
370+
371+
// When multiple non-modifier keys are simultaneously in _pressedKeys (due to overlapping
372+
// key presses, e.g. pressing ArrowUp before ArrowDown's keyup fires), the full combination
373+
// won't match any single-key binding. Fall back to just the current key + modifiers so that
374+
// single-key bindings continue to fire even when other keys are still "held".
375+
if (!binding && this._pressedKeys.size > 1) {
376+
binding = this._bindings.get(
377+
createCombinationKey([key], activeModifiers)
378+
);
379+
}
356380

357381
if (binding && this._bindingMatches(binding, event)) {
358382
this._applyEventModifiers(binding, event);
@@ -445,9 +469,11 @@ class KeyBindingController implements ReactiveController {
445469

446470
return {
447471
unsubscribe: () => {
448-
this._observedElement?.removeEventListener('keydown', this);
449-
this._observedElement?.removeEventListener('keyup', this);
450-
this._observedElement = undefined;
472+
element.removeEventListener('keydown', this);
473+
element.removeEventListener('keyup', this);
474+
if (this._observedElement === element) {
475+
this._observedElement = undefined;
476+
}
451477
},
452478
};
453479
}

src/components/common/utils.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '@open-wc/testing';
88
import type { TemplateResult } from 'lit';
99
import { type CalendarDay, toCalendarDay } from '../calendar/model.js';
10-
import { parseKeys } from './controllers/key-bindings.js';
10+
import { MODIFIER_EVENT_KEYS, parseKeys } from './controllers/key-bindings.js';
1111
import type { IgcFormControl } from './mixins/forms/types.js';
1212
import { toKebabCase } from './util.js';
1313

@@ -348,7 +348,7 @@ export function simulateKeyboard(
348348
const eventOptions: Record<string, boolean> = {};
349349

350350
for (const each of modifiers) {
351-
eventOptions[`${each}Key`] = true;
351+
eventOptions[MODIFIER_EVENT_KEYS[each]] = true;
352352
}
353353

354354
for (const key of keys) {

0 commit comments

Comments
 (0)