Skip to content

Commit d9786b8

Browse files
authored
refactor: Simplified key bindings controller (#2167)
* refactor: Simplified key bindings controller - Dropped the `keydownRepeat` option in favor of a single `repeat` option that applies to all key events. - Removed some helper functions and simplified the `parseKeys` function to use a single pass. - Updated the affected components to use the new `repeat` option. - Added tests and fixed edge case - API documentation, global blur handling, and event matching logic in KeyBindingsController
1 parent 27da237 commit d9786b8

File tree

9 files changed

+379
-184
lines changed

9 files changed

+379
-184
lines changed

src/components/calendar/calendar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export default class IgcCalendarComponent extends EventEmitterMixin<
248248
addKeybindings(this, {
249249
skip: this._shouldSkipKeyboardEvent,
250250
ref: this._contentRef,
251-
bindingDefaults: { triggers: ['keydownRepeat'] },
251+
bindingDefaults: { repeat: true },
252252
})
253253
.set(arrowLeft, this._handleArrowKey.bind(this, 'day', -1))
254254
.set(arrowRight, this._handleArrowKey.bind(this, 'day', 1))

src/components/combo/controllers/navigation.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,7 @@ export class ComboNavigationController<T extends object> {
207207
this.combo.addController(this as ReactiveController);
208208
this._config = config;
209209

210-
const bindingDefaults = {
211-
triggers: ['keydownRepeat'],
212-
} as KeyBindingOptions;
210+
const bindingDefaults = { repeat: true } as KeyBindingOptions;
213211

214212
const skip = (): boolean => this.combo.disabled;
215213

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

Lines changed: 212 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -17,153 +17,250 @@ import {
1717
} from './key-bindings.js';
1818

1919
describe('Key bindings controller', () => {
20-
let tag: string;
21-
let instance: LitElement & {
22-
key: string;
23-
event: KeyboardEvent;
24-
input: HTMLInputElement;
25-
};
26-
27-
before(() => {
28-
tag = defineCE(
29-
class extends LitElement {
30-
public key?: string;
31-
public event?: KeyboardEvent;
32-
33-
public get input() {
34-
return this.renderRoot.querySelector('input')!;
35-
}
20+
describe('addKeybindings', () => {
21+
let tag: string;
22+
let instance: LitElement & {
23+
key: string;
24+
event: KeyboardEvent;
25+
input: HTMLInputElement;
26+
};
27+
28+
before(() => {
29+
tag = defineCE(
30+
class extends LitElement {
31+
public key?: string;
32+
public event?: KeyboardEvent;
33+
34+
public get input() {
35+
return this.renderRoot.querySelector('input')!;
36+
}
3637

37-
constructor() {
38-
super();
39-
addKeybindings(this, {
40-
skip: () => !!this.hidden,
41-
})
42-
.setActivateHandler(this.handleKeyboardEvent)
43-
.set('a', this.handleKeyboardEvent, { triggers: ['keydown'] })
44-
.set('s', this.handleKeyboardEvent, { triggers: ['keyup'] })
45-
.set('k', this.handleKeyboardEvent)
46-
.set('d', this.handleKeyboardEvent, {
47-
triggers: ['keydown', 'keyup'],
48-
preventDefault: true,
49-
stopPropagation: true,
38+
constructor() {
39+
super();
40+
addKeybindings(this, {
41+
skip: () => !!this.hidden,
5042
})
51-
.set([shiftKey, 'c'], this.handleKeyboardEvent)
52-
.set([shiftKey, altKey, arrowUp], this.handleKeyboardEvent);
43+
.setActivateHandler(this.handleKeyboardEvent)
44+
.set('a', this.handleKeyboardEvent, { triggers: ['keydown'] })
45+
.set('s', this.handleKeyboardEvent, { triggers: ['keyup'] })
46+
.set('k', this.handleKeyboardEvent)
47+
.set('d', this.handleKeyboardEvent, {
48+
triggers: ['keydown', 'keyup'],
49+
preventDefault: true,
50+
stopPropagation: true,
51+
})
52+
.set([shiftKey, 'c'], this.handleKeyboardEvent)
53+
.set([shiftKey, altKey, arrowUp], this.handleKeyboardEvent);
5354

54-
addKeybindings(this).set('x', this.handleKeyboardEvent);
55-
}
55+
addKeybindings(this).set('x', this.handleKeyboardEvent);
56+
}
5657

57-
private handleKeyboardEvent(event: KeyboardEvent) {
58-
this.key = event.key;
59-
this.event = event;
60-
}
58+
private handleKeyboardEvent(event: KeyboardEvent) {
59+
this.key = event.key;
60+
this.event = event;
61+
}
6162

62-
protected override render() {
63-
return html`<input />`;
63+
protected override render() {
64+
return html`<input />`;
65+
}
6466
}
65-
}
66-
);
67-
});
67+
);
68+
});
6869

69-
function dispatch(node: Element, type: 'keydown' | 'keyup', key: string) {
70-
node.dispatchEvent(
71-
new KeyboardEvent(type, {
72-
key,
73-
bubbles: true,
74-
composed: true,
75-
cancelable: true,
76-
})
77-
);
78-
}
79-
80-
beforeEach(async () => {
81-
const tagName = unsafeStatic(tag);
82-
instance = await fixture(html`<${tagName}></${tagName}>`);
83-
});
70+
beforeEach(async () => {
71+
const tagName = unsafeStatic(tag);
72+
instance = await fixture(html`<${tagName}></${tagName}>`);
73+
});
8474

85-
describe('Triggers', () => {
86-
it('keydown - keydown event works', async () => {
87-
dispatch(instance, 'keydown', 'a');
88-
expect(instance.key).to.equal('a');
89-
expect(instance.event.type).to.equal('keydown');
75+
describe('Triggers', () => {
76+
it('keydown - keydown event works', async () => {
77+
dispatch(instance, 'keydown', 'a');
78+
expect(instance.key).to.equal('a');
79+
expect(instance.event.type).to.equal('keydown');
80+
});
81+
82+
it('keydown - keyup event is ignored', async () => {
83+
dispatch(instance, 'keyup', 'a');
84+
expect(instance.key).to.be.undefined;
85+
expect(instance.event).to.be.undefined;
86+
});
87+
88+
it('keyup - keyup event works', async () => {
89+
dispatch(instance, 'keyup', 's');
90+
expect(instance.key).to.equal('s');
91+
expect(instance.event.type).to.equal('keyup');
92+
});
93+
94+
it('keyup - keydown event is ignored', async () => {
95+
dispatch(instance, 'keydown', 's');
96+
expect(instance.key).to.be.undefined;
97+
expect(instance.event).to.be.undefined;
98+
});
9099
});
91100

92-
it('keydown - keyup event is ignored', async () => {
93-
dispatch(instance, 'keyup', 'a');
101+
it('should honor default skip condition', () => {
102+
simulateKeyboard(instance.input, 'x');
94103
expect(instance.key).to.be.undefined;
95104
expect(instance.event).to.be.undefined;
96105
});
97106

98-
it('keyup - keyup event works', async () => {
99-
dispatch(instance, 'keyup', 's');
100-
expect(instance.key).to.equal('s');
107+
it('should honor skip condition', () => {
108+
instance.hidden = true;
109+
110+
simulateKeyboard(instance, 'k');
111+
expect(instance.key).to.be.undefined;
112+
expect(instance.event).to.be.undefined;
113+
});
114+
115+
it('event handler modifiers - prevent default', () => {
116+
dispatch(instance, 'keydown', 'd');
117+
expect(instance.event.type).to.equal('keydown');
118+
expect(instance.event.defaultPrevented).to.be.true;
119+
120+
dispatch(instance, 'keyup', 'd');
101121
expect(instance.event.type).to.equal('keyup');
122+
expect(instance.event.defaultPrevented).to.be.true;
123+
});
124+
125+
it('event handler modifiers - stop propagation', () => {
126+
let parentHandlerCalled = false;
127+
const handler = () => {
128+
parentHandlerCalled = true;
129+
};
130+
const container = instance.parentElement!;
131+
container.addEventListener('keydown', handler, { once: true });
132+
container.addEventListener('keyup', handler, { once: true });
133+
134+
simulateKeyboard(instance, 'd');
135+
expect(parentHandlerCalled).to.be.false;
136+
expect(instance.key).to.equal('d');
137+
});
138+
139+
it('activation keys', () => {
140+
for (const key of [enterKey, spaceBar]) {
141+
simulateKeyboard(instance, key);
142+
expect(instance.key).to.equal(key.toLowerCase());
143+
}
102144
});
103145

104-
it('keyup - keydown event is ignored', async () => {
105-
dispatch(instance, 'keydown', 's');
146+
it('combinations', () => {
147+
simulateKeyboard(instance, shiftKey);
106148
expect(instance.key).to.be.undefined;
107-
expect(instance.event).to.be.undefined;
149+
150+
simulateKeyboard(instance, 'c');
151+
expect(instance.key).to.be.undefined;
152+
153+
simulateKeyboard(instance, [shiftKey, 'c']);
154+
expect(instance.key).to.equal('c');
155+
156+
simulateKeyboard(instance, [shiftKey, altKey, arrowUp]);
157+
expect(instance.key).to.equal(arrowUp.toLowerCase());
108158
});
109159
});
110160

111-
it('should honor default skip condition', () => {
112-
simulateKeyboard(instance.input, 'x');
113-
expect(instance.key).to.be.undefined;
114-
expect(instance.event).to.be.undefined;
115-
});
161+
describe('multi non-modifier key combinations', () => {
162+
let multiTag: string;
163+
let multiInstance: LitElement & { callCount: number };
116164

117-
it('should honor skip condition', () => {
118-
instance.hidden = true;
165+
before(() => {
166+
multiTag = defineCE(
167+
class extends LitElement {
168+
public callCount = 0;
119169

120-
simulateKeyboard(instance, 'k');
121-
expect(instance.key).to.be.undefined;
122-
expect(instance.event).to.be.undefined;
123-
});
170+
constructor() {
171+
super();
172+
addKeybindings(this).set(['x', 'z'], () => this.callCount++);
173+
}
174+
}
175+
);
176+
});
124177

125-
it('event handler modifiers - prevent default', () => {
126-
dispatch(instance, 'keydown', 'd');
127-
expect(instance.event.type).to.equal('keydown');
128-
expect(instance.event.defaultPrevented).to.be.true;
178+
beforeEach(async () => {
179+
const tagName = unsafeStatic(multiTag);
180+
multiInstance = await fixture(html`<${tagName}></${tagName}>`);
181+
});
129182

130-
dispatch(instance, 'keyup', 'd');
131-
expect(instance.event.type).to.equal('keyup');
132-
expect(instance.event.defaultPrevented).to.be.true;
133-
});
183+
it('should not fire when only one of the keys is pressed', () => {
184+
dispatch(multiInstance, 'keydown', 'x');
185+
expect(multiInstance.callCount).to.equal(0);
134186

135-
it('event handler modifiers - stop propagation', () => {
136-
let parentHandlerCalled = false;
137-
const handler = () => {
138-
parentHandlerCalled = true;
139-
};
140-
const container = instance.parentElement!;
141-
container.addEventListener('keydown', handler, { once: true });
142-
container.addEventListener('keyup', handler, { once: true });
187+
dispatch(multiInstance, 'keyup', 'x');
143188

144-
simulateKeyboard(instance, 'd');
145-
expect(parentHandlerCalled).to.be.false;
146-
expect(instance.key).to.equal('d');
147-
});
189+
dispatch(multiInstance, 'keydown', 'z');
190+
expect(multiInstance.callCount).to.equal(0);
191+
});
148192

149-
it('activation keys', () => {
150-
for (const key of [enterKey, spaceBar]) {
151-
simulateKeyboard(instance, key);
152-
expect(instance.key).to.equal(key.toLowerCase());
153-
}
193+
it('should fire when all keys are held simultaneously', () => {
194+
dispatch(multiInstance, 'keydown', 'x');
195+
dispatch(multiInstance, 'keydown', 'z');
196+
expect(multiInstance.callCount).to.equal(1);
197+
});
198+
199+
it('should clear pressed keys on window blur', () => {
200+
// Hold 'x', then switch away without releasing it
201+
dispatch(multiInstance, 'keydown', 'x');
202+
window.dispatchEvent(new FocusEvent('blur'));
203+
204+
// 'x' should no longer be tracked; pressing 'z' alone should not fire
205+
dispatch(multiInstance, 'keydown', 'z');
206+
expect(multiInstance.callCount).to.equal(0);
207+
});
154208
});
155209

156-
it('combinations', () => {
157-
simulateKeyboard(instance, shiftKey);
158-
expect(instance.key).to.be.undefined;
210+
describe('repeat option', () => {
211+
let repeatTag: string;
212+
let repeatInstance: LitElement & { callCount: number };
213+
214+
before(() => {
215+
repeatTag = defineCE(
216+
class extends LitElement {
217+
public callCount = 0;
218+
219+
constructor() {
220+
super();
221+
addKeybindings(this)
222+
.set('a', () => this.callCount++)
223+
.set('b', () => this.callCount++, { repeat: true });
224+
}
225+
}
226+
);
227+
});
228+
229+
beforeEach(async () => {
230+
const tagName = unsafeStatic(repeatTag);
231+
repeatInstance = await fixture(html`<${tagName}></${tagName}>`);
232+
});
159233

160-
simulateKeyboard(instance, 'c');
161-
expect(instance.key).to.be.undefined;
234+
it('should not fire on repeated keydown by default', () => {
235+
dispatch(repeatInstance, 'keydown', 'a', true);
236+
expect(repeatInstance.callCount).to.equal(0);
237+
});
162238

163-
simulateKeyboard(instance, [shiftKey, 'c']);
164-
expect(instance.key).to.equal('c');
239+
it('should fire on initial keydown regardless of repeat option', () => {
240+
dispatch(repeatInstance, 'keydown', 'b');
241+
expect(repeatInstance.callCount).to.equal(1);
242+
});
165243

166-
simulateKeyboard(instance, [shiftKey, altKey, arrowUp]);
167-
expect(instance.key).to.equal(arrowUp.toLowerCase());
244+
it('should fire on repeated keydown when repeat is enabled', () => {
245+
dispatch(repeatInstance, 'keydown', 'b', true);
246+
expect(repeatInstance.callCount).to.equal(1);
247+
});
168248
});
169249
});
250+
251+
function dispatch(
252+
node: Element,
253+
type: 'keydown' | 'keyup',
254+
key: string,
255+
repeat = false
256+
) {
257+
node.dispatchEvent(
258+
new KeyboardEvent(type, {
259+
key,
260+
bubbles: true,
261+
composed: true,
262+
cancelable: true,
263+
repeat,
264+
})
265+
);
266+
}

0 commit comments

Comments
 (0)