Skip to content

Commit

Permalink
fix(cdk/testing): account for preventDefault in keyboard events
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto committed Jul 20, 2023
1 parent 73ba2c0 commit ca57ddb
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 18 deletions.
40 changes: 27 additions & 13 deletions src/cdk/testing/testbed/fake-events/type-in-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,40 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any[])

triggerFocus(element);

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

for (const key of keys) {
dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers);
dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers);
if (isInput && key.key && key.key.length === 1) {
if (enterValueIncrementally) {
(element as HTMLInputElement | HTMLTextAreaElement).value += key.key;
dispatchFakeEvent(element, 'input');
const downEvent = dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers);

// If the handler called `preventDefault` during `keydown`, the browser doesn't insert the
// value or dispatch `keypress` and `input` events. `keyup` is still dispatched.
if (!downEvent.defaultPrevented) {
const pressEvent = dispatchKeyboardEvent(
element,
'keypress',
key.keyCode,
key.key,
modifiers,
);

// If the handler called `preventDefault` during `keypress`, the browser doesn't insert the
// value or dispatch the `input` event. `keyup` is still dispatched.
if (!pressEvent.defaultPrevented && isInput && key.key && key.key.length === 1) {
if (enterValueIncrementally) {
element.value += key.key;
dispatchFakeEvent(element, 'input');
} else {
nonIncrementalValue += key.key;
}
}
}

dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers);
}

// Since we weren't dispatching `input` events while sending the keys, we have to do it now.
if (!enterValueIncrementally) {
// Since we weren't adding the value or dispatching `input` events, we do it all at once now.
if (!enterValueIncrementally && nonIncrementalValue.length > 0 && isInput) {
element.value = nonIncrementalValue;
dispatchFakeEvent(element, 'input');
}
}
Expand Down
69 changes: 66 additions & 3 deletions src/cdk/testing/tests/cross-environment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,19 @@ export function crossEnvironmentSpecs(
});

it('should send enter key', async () => {
const specialKey = await harness.specaialKey();
const specialKey = await harness.specialKey();
await harness.sendEnter();
expect(await specialKey.text()).toBe('enter');
});

it('should send comma key', async () => {
const specialKey = await harness.specaialKey();
const specialKey = await harness.specialKey();
await harness.sendComma();
expect(await specialKey.text()).toBe(',');
});

it('should send alt+j key', async () => {
const specialKey = await harness.specaialKey();
const specialKey = await harness.specialKey();
await harness.sendAltJ();
expect(await specialKey.text()).toBe('alt-j');
});
Expand Down Expand Up @@ -289,6 +289,69 @@ export function crossEnvironmentSpecs(
expect(await element.getText()).toBe('Has comma inside attribute');
});

it(
'should prevent the value from changing and dispatch the correct event sequence ' +
'when preventDefault is called on an input during `keydown`',
async () => {
const button = await harness.preventDefaultKeydownButton();
const input = await harness.preventDefaultInput();
const value = await harness.preventDefaultInputValues();

await button.click();
await input.sendKeys('321');

expect((await value.text()).split('|')).toEqual([
// Event sequence for 3
'keydown - 3 - <empty>',
'keypress - 3 - <empty>',
'input - <none> - 3',
'keyup - 3 - 3',

// Event sequence for 2 which calls preventDefault
'keydown - 2 - 3',
'keyup - 2 - 3',

// Event sequence for 1
'keydown - 1 - 3',
'keypress - 1 - 3',
'input - <none> - 31',
'keyup - 1 - 31',
]);
},
);

it(
'should prevent the value from changing and dispatch the correct event sequence ' +
'when preventDefault is called on an input during `keypress`',
async () => {
const button = await harness.preventDefaultKeypressButton();
const input = await harness.preventDefaultInput();
const value = await harness.preventDefaultInputValues();

await button.click();
await input.sendKeys('321');

expect((await value.text()).split('|')).toEqual([
// Event sequence for 3
'keydown - 3 - <empty>',
'keypress - 3 - <empty>',
'input - <none> - 3',
'keyup - 3 - 3',

// Event sequence for 2 which calls preventDefault
'keydown - 2 - 3',
'keypress - 2 - 3',
'keyup - 2 - 3',

// Event sequence for 1
'keydown - 1 - 3',
'keypress - 1 - 3',
'input - <none> - 31',
'keyup - 1 - 31',
]);
},
);

if (!skipAsyncTests) {
it('should wait for async operation to complete', async () => {
const asyncCounter = await harness.asyncCounter();
Expand Down
6 changes: 5 additions & 1 deletion src/cdk/testing/tests/harnesses/main-component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export class MainComponentHarness extends ComponentHarness {
readonly multiSelectChangeEventCounter = this.locatorFor('#multi-select-change-counter');
readonly numberInput = this.locatorFor('#number-input');
readonly numberInputValue = this.locatorFor('#number-input-value');
readonly preventDefaultInput = this.locatorFor('#prevent-default-input');
readonly preventDefaultInputValues = this.locatorFor('#prevent-default-input-values');
readonly preventDefaultKeydownButton = this.locatorFor('#prevent-default-keydown');
readonly preventDefaultKeypressButton = this.locatorFor('#prevent-default-keypress');
readonly contextmenuTestResult = this.locatorFor('.contextmenu-test-result');
readonly contenteditable = this.locatorFor('#contenteditable');
// Allow null for element
Expand Down Expand Up @@ -68,7 +72,7 @@ export class MainComponentHarness extends ComponentHarness {
SubComponentHarness.with({title: 'List of test tools', itemCount: 4}),
);
readonly lastList = this.locatorFor(SubComponentHarness.with({selector: ':last-child'}));
readonly specaialKey = this.locatorFor('.special-key');
readonly specialKey = this.locatorFor('.special-key');

readonly requiredAncestorRestrictedSubcomponent = this.locatorFor(
SubComponentHarness.with({ancestor: '.other'}),
Expand Down
15 changes: 15 additions & 0 deletions src/cdk/testing/tests/test-main-component.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>

<input id="number-input" type="number" aria-label="Enter a number" [formControl]="numberControl">
<div id="number-input-value">Number value: {{numberControl.value}}</div>

<button
id="prevent-default-keydown"
(click)="preventDefaultEventType = 'keydown'">Prevent default on keydown</button>
<button
id="prevent-default-keypress"
(click)="preventDefaultEventType = 'keypress'">Prevent default keypress</button>
<input
id="prevent-default-input"
type="text"
(keydown)="preventDefaultListener($event)"
(keypress)="preventDefaultListener($event)"
(input)="preventDefaultListener($event)"
(keyup)="preventDefaultListener($event)">
<div id="prevent-default-input-values">{{preventDefaultValues.join('|')}}</div>
</div>
<div class="subcomponents">
<test-sub class="test-special" title="test tools" [items]="testTools"></test-sub>
Expand Down
16 changes: 15 additions & 1 deletion src/cdk/testing/tests/test-main-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {COMMA, ENTER} from '@angular/cdk/keycodes';
import {COMMA, ENTER, TWO} from '@angular/cdk/keycodes';
import {_supportsShadowDom} from '@angular/cdk/platform';
import {FormControl} from '@angular/forms';
import {
Expand Down Expand Up @@ -48,6 +48,8 @@ export class TestMainComponent implements OnDestroy {
clickResult = {x: -1, y: -1};
rightClickResult = {x: -1, y: -1, button: -1};
numberControl = new FormControl<number | null>(null);
preventDefaultEventType: string | null = null;
preventDefaultValues: string[] = [];

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

preventDefaultListener(event: Event) {
// `input` events don't have a key
const key = event.type === 'input' ? '<none>' : (event as KeyboardEvent).key;
const input = event.target as HTMLInputElement;

if (event.type === this.preventDefaultEventType && (event as KeyboardEvent).keyCode === TWO) {
event.preventDefault();
}

this.preventDefaultValues.push(`${event.type} - ${key} - ${input.value || '<empty>'}`);
}

runTaskOutsideZone() {
this._zone.runOutsideAngular(() =>
setTimeout(() => {
Expand Down

0 comments on commit ca57ddb

Please sign in to comment.