Skip to content

Commit ca57ddb

Browse files
committed
fix(cdk/testing): account for preventDefault in keyboard events
Currently we try to mimic the user typing in the `typeInElement` utility by incrementally setting the value and dispatching the same sequence of events. The problem is that we weren't accounting for `preventDefault` which can block some keys from being assigned and some events from firing. This leads to inconsistencies between tests and the sequence of events triggered by a user. It is especially noticeable in components like the chip input where some keys act as separators. These changes update the logic to take `preventDefault` into account and try to mimic the native event sequence as closely as possible. Fixes #27475.
1 parent 73ba2c0 commit ca57ddb

File tree

5 files changed

+128
-18
lines changed

5 files changed

+128
-18
lines changed

src/cdk/testing/testbed/fake-events/type-in-element.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,26 +112,40 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any[])
112112

113113
triggerFocus(element);
114114

115-
// When we aren't entering the value incrementally, assign it all at once ahead
116-
// of time so that any listeners to the key events below will have access to it.
117-
if (!enterValueIncrementally) {
118-
(element as HTMLInputElement).value = keys.reduce((value, key) => value + (key.key || ''), '');
119-
}
115+
let nonIncrementalValue = '';
120116

121117
for (const key of keys) {
122-
dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers);
123-
dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers);
124-
if (isInput && key.key && key.key.length === 1) {
125-
if (enterValueIncrementally) {
126-
(element as HTMLInputElement | HTMLTextAreaElement).value += key.key;
127-
dispatchFakeEvent(element, 'input');
118+
const downEvent = dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers);
119+
120+
// If the handler called `preventDefault` during `keydown`, the browser doesn't insert the
121+
// value or dispatch `keypress` and `input` events. `keyup` is still dispatched.
122+
if (!downEvent.defaultPrevented) {
123+
const pressEvent = dispatchKeyboardEvent(
124+
element,
125+
'keypress',
126+
key.keyCode,
127+
key.key,
128+
modifiers,
129+
);
130+
131+
// If the handler called `preventDefault` during `keypress`, the browser doesn't insert the
132+
// value or dispatch the `input` event. `keyup` is still dispatched.
133+
if (!pressEvent.defaultPrevented && isInput && key.key && key.key.length === 1) {
134+
if (enterValueIncrementally) {
135+
element.value += key.key;
136+
dispatchFakeEvent(element, 'input');
137+
} else {
138+
nonIncrementalValue += key.key;
139+
}
128140
}
129141
}
142+
130143
dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers);
131144
}
132145

133-
// Since we weren't dispatching `input` events while sending the keys, we have to do it now.
134-
if (!enterValueIncrementally) {
146+
// Since we weren't adding the value or dispatching `input` events, we do it all at once now.
147+
if (!enterValueIncrementally && nonIncrementalValue.length > 0 && isInput) {
148+
element.value = nonIncrementalValue;
135149
dispatchFakeEvent(element, 'input');
136150
}
137151
}

src/cdk/testing/tests/cross-environment.spec.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,19 +204,19 @@ export function crossEnvironmentSpecs(
204204
});
205205

206206
it('should send enter key', async () => {
207-
const specialKey = await harness.specaialKey();
207+
const specialKey = await harness.specialKey();
208208
await harness.sendEnter();
209209
expect(await specialKey.text()).toBe('enter');
210210
});
211211

212212
it('should send comma key', async () => {
213-
const specialKey = await harness.specaialKey();
213+
const specialKey = await harness.specialKey();
214214
await harness.sendComma();
215215
expect(await specialKey.text()).toBe(',');
216216
});
217217

218218
it('should send alt+j key', async () => {
219-
const specialKey = await harness.specaialKey();
219+
const specialKey = await harness.specialKey();
220220
await harness.sendAltJ();
221221
expect(await specialKey.text()).toBe('alt-j');
222222
});
@@ -289,6 +289,69 @@ export function crossEnvironmentSpecs(
289289
expect(await element.getText()).toBe('Has comma inside attribute');
290290
});
291291

292+
it(
293+
'should prevent the value from changing and dispatch the correct event sequence ' +
294+
'when preventDefault is called on an input during `keydown`',
295+
async () => {
296+
const button = await harness.preventDefaultKeydownButton();
297+
const input = await harness.preventDefaultInput();
298+
const value = await harness.preventDefaultInputValues();
299+
300+
await button.click();
301+
await input.sendKeys('321');
302+
303+
expect((await value.text()).split('|')).toEqual([
304+
// Event sequence for 3
305+
'keydown - 3 - <empty>',
306+
'keypress - 3 - <empty>',
307+
'input - <none> - 3',
308+
'keyup - 3 - 3',
309+
310+
// Event sequence for 2 which calls preventDefault
311+
'keydown - 2 - 3',
312+
'keyup - 2 - 3',
313+
314+
// Event sequence for 1
315+
'keydown - 1 - 3',
316+
'keypress - 1 - 3',
317+
'input - <none> - 31',
318+
'keyup - 1 - 31',
319+
]);
320+
},
321+
);
322+
323+
it(
324+
'should prevent the value from changing and dispatch the correct event sequence ' +
325+
'when preventDefault is called on an input during `keypress`',
326+
async () => {
327+
const button = await harness.preventDefaultKeypressButton();
328+
const input = await harness.preventDefaultInput();
329+
const value = await harness.preventDefaultInputValues();
330+
331+
await button.click();
332+
await input.sendKeys('321');
333+
334+
expect((await value.text()).split('|')).toEqual([
335+
// Event sequence for 3
336+
'keydown - 3 - <empty>',
337+
'keypress - 3 - <empty>',
338+
'input - <none> - 3',
339+
'keyup - 3 - 3',
340+
341+
// Event sequence for 2 which calls preventDefault
342+
'keydown - 2 - 3',
343+
'keypress - 2 - 3',
344+
'keyup - 2 - 3',
345+
346+
// Event sequence for 1
347+
'keydown - 1 - 3',
348+
'keypress - 1 - 3',
349+
'input - <none> - 31',
350+
'keyup - 1 - 31',
351+
]);
352+
},
353+
);
354+
292355
if (!skipAsyncTests) {
293356
it('should wait for async operation to complete', async () => {
294357
const asyncCounter = await harness.asyncCounter();

src/cdk/testing/tests/harnesses/main-component-harness.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export class MainComponentHarness extends ComponentHarness {
3939
readonly multiSelectChangeEventCounter = this.locatorFor('#multi-select-change-counter');
4040
readonly numberInput = this.locatorFor('#number-input');
4141
readonly numberInputValue = this.locatorFor('#number-input-value');
42+
readonly preventDefaultInput = this.locatorFor('#prevent-default-input');
43+
readonly preventDefaultInputValues = this.locatorFor('#prevent-default-input-values');
44+
readonly preventDefaultKeydownButton = this.locatorFor('#prevent-default-keydown');
45+
readonly preventDefaultKeypressButton = this.locatorFor('#prevent-default-keypress');
4246
readonly contextmenuTestResult = this.locatorFor('.contextmenu-test-result');
4347
readonly contenteditable = this.locatorFor('#contenteditable');
4448
// Allow null for element
@@ -68,7 +72,7 @@ export class MainComponentHarness extends ComponentHarness {
6872
SubComponentHarness.with({title: 'List of test tools', itemCount: 4}),
6973
);
7074
readonly lastList = this.locatorFor(SubComponentHarness.with({selector: ':last-child'}));
71-
readonly specaialKey = this.locatorFor('.special-key');
75+
readonly specialKey = this.locatorFor('.special-key');
7276

7377
readonly requiredAncestorRestrictedSubcomponent = this.locatorFor(
7478
SubComponentHarness.with({ancestor: '.other'}),

src/cdk/testing/tests/test-main-component.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>
5050

5151
<input id="number-input" type="number" aria-label="Enter a number" [formControl]="numberControl">
5252
<div id="number-input-value">Number value: {{numberControl.value}}</div>
53+
54+
<button
55+
id="prevent-default-keydown"
56+
(click)="preventDefaultEventType = 'keydown'">Prevent default on keydown</button>
57+
<button
58+
id="prevent-default-keypress"
59+
(click)="preventDefaultEventType = 'keypress'">Prevent default keypress</button>
60+
<input
61+
id="prevent-default-input"
62+
type="text"
63+
(keydown)="preventDefaultListener($event)"
64+
(keypress)="preventDefaultListener($event)"
65+
(input)="preventDefaultListener($event)"
66+
(keyup)="preventDefaultListener($event)">
67+
<div id="prevent-default-input-values">{{preventDefaultValues.join('|')}}</div>
5368
</div>
5469
<div class="subcomponents">
5570
<test-sub class="test-special" title="test tools" [items]="testTools"></test-sub>

src/cdk/testing/tests/test-main-component.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {COMMA, ENTER} from '@angular/cdk/keycodes';
9+
import {COMMA, ENTER, TWO} from '@angular/cdk/keycodes';
1010
import {_supportsShadowDom} from '@angular/cdk/platform';
1111
import {FormControl} from '@angular/forms';
1212
import {
@@ -48,6 +48,8 @@ export class TestMainComponent implements OnDestroy {
4848
clickResult = {x: -1, y: -1};
4949
rightClickResult = {x: -1, y: -1, button: -1};
5050
numberControl = new FormControl<number | null>(null);
51+
preventDefaultEventType: string | null = null;
52+
preventDefaultValues: string[] = [];
5153

5254
@ViewChild('clickTestElement') clickTestElement: ElementRef<HTMLElement>;
5355
@ViewChild('taskStateResult') taskStateResultElement: ElementRef<HTMLElement>;
@@ -117,6 +119,18 @@ export class TestMainComponent implements OnDestroy {
117119
this.customEventData = JSON.stringify({message: event.message, value: event.value});
118120
}
119121

122+
preventDefaultListener(event: Event) {
123+
// `input` events don't have a key
124+
const key = event.type === 'input' ? '<none>' : (event as KeyboardEvent).key;
125+
const input = event.target as HTMLInputElement;
126+
127+
if (event.type === this.preventDefaultEventType && (event as KeyboardEvent).keyCode === TWO) {
128+
event.preventDefault();
129+
}
130+
131+
this.preventDefaultValues.push(`${event.type} - ${key} - ${input.value || '<empty>'}`);
132+
}
133+
120134
runTaskOutsideZone() {
121135
this._zone.runOutsideAngular(() =>
122136
setTimeout(() => {

0 commit comments

Comments
 (0)