Skip to content

Commit 61eb92a

Browse files
crisbetommalerba
authored andcommitted
fix(cdk/testing): unable to assign value to number inputs using sendKeys (#22395)
Currently the `UnitTestElement` simulates typing into an input by assigning the value character-by-character and dispatching fake events along the way. The problem is that for input types that require the value to be in a particular format (e.g. `number`, `color`, `date`) doing so will temporarily assign an invalid value which will be rejected by the browser with a warning like `The specified value "12." cannot be parsed, or is out of range.`. This can become a problem for some common use cases like the `ReactiveFormsModule` where a directive might be keeping track of the value by looking at the DOM inside of an `input` event (e.g. the `FormControl` directive does this). These changes resolve the issue by looking at the type of the input, and if it's a type that requires a specific format, we assign the value immediately. Fixes #22129. (cherry picked from commit 2f177b6)
1 parent 11ff7ba commit 61eb92a

File tree

8 files changed

+56
-6
lines changed

8 files changed

+56
-6
lines changed

src/cdk/keycodes/keycodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export const SEMICOLON = 186; // Firefox (Gecko) fires 59 for SEMICOLON
116116
export const EQUALS = 187; // Firefox (Gecko) fires 61 for EQUALS
117117
export const COMMA = 188;
118118
export const DASH = 189; // Firefox (Gecko) fires 173 for DASH/MINUS
119+
export const PERIOD = 190;
119120
export const SLASH = 191;
120121
export const APOSTROPHE = 192;
121122
export const TILDE = 192;

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@
77
*/
88

99
import {ModifierKeys} from '@angular/cdk/testing';
10+
import {PERIOD} from '@angular/cdk/keycodes';
1011
import {dispatchFakeEvent, dispatchKeyboardEvent} from './dispatch-events';
1112
import {triggerFocus} from './element-focus';
1213

14+
/** Input types for which the value can be entered incrementally. */
15+
const incrementalInputTypes =
16+
new Set(['text', 'email', 'hidden', 'password', 'search', 'tel', 'url']);
17+
1318
/**
1419
* Checks whether the given Element is a text input element.
1520
* @docs-private
1621
*/
1722
export function isTextInput(element: Element): element is HTMLInputElement | HTMLTextAreaElement {
1823
const nodeName = element.nodeName.toLowerCase();
19-
return nodeName === 'input' || nodeName === 'textarea' ;
24+
return nodeName === 'input' || nodeName === 'textarea';
2025
}
2126

2227
/**
@@ -51,21 +56,47 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any) {
5156
modifiers = {};
5257
rest = modifiersAndKeys;
5358
}
59+
const isInput = isTextInput(element);
60+
const inputType = element.getAttribute('type') || 'text';
5461
const keys: {keyCode?: number, key?: string}[] = rest
5562
.map(k => typeof k === 'string' ?
5663
k.split('').map(c => ({keyCode: c.toUpperCase().charCodeAt(0), key: c})) : [k])
5764
.reduce((arr, k) => arr.concat(k), []);
5865

66+
// We simulate the user typing in a value by incrementally assigning the value below. The problem
67+
// is that for some input types, the browser won't allow for an invalid value to be set via the
68+
// `value` property which will always be the case when going character-by-character. If we detect
69+
// such an input, we have to set the value all at once or listeners to the `input` event (e.g.
70+
// the `ReactiveFormsModule` uses such an approach) won't receive the correct value.
71+
const enterValueIncrementally = inputType === 'number' && keys.length > 0 ?
72+
// The value can be set character by character in number inputs if it doesn't have any decimals.
73+
keys.every(key => key.key !== '.' && key.keyCode !== PERIOD) :
74+
incrementalInputTypes.has(inputType);
75+
5976
triggerFocus(element);
77+
78+
// When we aren't entering the value incrementally, assign it all at once ahead
79+
// of time so that any listeners to the key events below will have access to it.
80+
if (!enterValueIncrementally) {
81+
(element as HTMLInputElement).value = keys.reduce((value, key) => value + (key.key || ''), '');
82+
}
83+
6084
for (const key of keys) {
6185
dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers);
6286
dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers);
63-
if (isTextInput(element) && key.key && key.key.length === 1) {
64-
element.value += key.key;
65-
dispatchFakeEvent(element, 'input');
87+
if (isInput && key.key && key.key.length === 1) {
88+
if (enterValueIncrementally) {
89+
(element as HTMLInputElement | HTMLTextAreaElement).value += key.key;
90+
dispatchFakeEvent(element, 'input');
91+
}
6692
}
6793
dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers);
6894
}
95+
96+
// Since we weren't dispatching `input` events while sending the keys, we have to do it now.
97+
if (!enterValueIncrementally) {
98+
dispatchFakeEvent(element, 'input');
99+
}
69100
}
70101

71102
/**

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,15 @@ export function crossEnvironmentSpecs(
395395
expect(await getActiveElementId()).toBe(await input.getAttribute('id'));
396396
});
397397

398+
it('should be able to type in values with a decimal', async () => {
399+
const input = await harness.numberInput();
400+
const value = await harness.numberInputValue();
401+
await input.sendKeys('123.456');
402+
403+
expect(await input.getProperty('value')).toBe('123.456');
404+
expect(await value.text()).toBe('Number value: 123.456');
405+
});
406+
398407
it('should be able to retrieve dimensions', async () => {
399408
const dimensions = await (await harness.title()).getDimensions();
400409
expect(dimensions).toEqual(jasmine.objectContaining({height: 100, width: 200}));

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export class MainComponentHarness extends ComponentHarness {
3535
readonly multiSelect = this.locatorFor('#multi-select');
3636
readonly multiSelectValue = this.locatorFor('#multi-select-value');
3737
readonly multiSelectChangeEventCounter = this.locatorFor('#multi-select-change-counter');
38+
readonly numberInput = this.locatorFor('#number-input');
39+
readonly numberInputValue = this.locatorFor('#number-input-value');
3840
readonly contextmenuTestResult = this.locatorFor('.contextmenu-test-result');
3941
// Allow null for element
4042
readonly nullItem = this.locatorForOptional('wrong locator');

src/cdk/testing/tests/test-components-module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
import {CommonModule} from '@angular/common';
1010
import {NgModule} from '@angular/core';
11-
import {FormsModule} from '@angular/forms';
11+
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
1212
import {TestMainComponent} from './test-main-component';
1313
import {TestShadowBoundary, TestSubShadowBoundary} from './test-shadow-boundary';
1414
import {TestSubComponent} from './test-sub-component';
1515

1616
@NgModule({
17-
imports: [CommonModule, FormsModule],
17+
imports: [CommonModule, FormsModule, ReactiveFormsModule],
1818
declarations: [TestMainComponent, TestSubComponent, TestShadowBoundary, TestSubShadowBoundary],
1919
exports: [TestMainComponent, TestSubComponent, TestShadowBoundary, TestSubShadowBoundary]
2020
})

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>
4545
<div id="multi-select-change-counter">Change events: {{multiSelectChangeEventCount}}</div>
4646
<div (myCustomEvent)="basicEvent = basicEvent + 1" id="custom-event-basic">Basic event: {{basicEvent}}</div>
4747
<div (myCustomEvent)="onCustomEvent($event)" id="custom-event-object">Event with object: {{customEventData}}</div>
48+
49+
<input id="number-input" type="number" aria-label="Enter a number" [formControl]="numberControl">
50+
<div id="number-input-value">Number value: {{numberControl.value}}</div>
4851
</div>
4952
<div class="subcomponents">
5053
<test-sub class="test-special" title="test tools" [items]="testTools"></test-sub>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {ENTER} from '@angular/cdk/keycodes';
1010
import {_supportsShadowDom} from '@angular/cdk/platform';
11+
import {FormControl} from '@angular/forms';
1112
import {
1213
ChangeDetectionStrategy,
1314
ChangeDetectorRef,
@@ -45,6 +46,7 @@ export class TestMainComponent implements OnDestroy {
4546
_shadowDomSupported = _supportsShadowDom();
4647
clickResult = {x: -1, y: -1};
4748
rightClickResult = {x: -1, y: -1, button: -1};
49+
numberControl = new FormControl();
4850

4951
@ViewChild('clickTestElement') clickTestElement: ElementRef<HTMLElement>;
5052
@ViewChild('taskStateResult') taskStateResultElement: ElementRef<HTMLElement>;

tools/public_api_guard/cdk/keycodes.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ export declare const PAGE_UP = 33;
176176

177177
export declare const PAUSE = 19;
178178

179+
export declare const PERIOD = 190;
180+
179181
export declare const PLUS_SIGN = 43;
180182

181183
export declare const PRINT_SCREEN = 44;

0 commit comments

Comments
 (0)