fix(forms): make composition event buffering configurable (#15256)

This commit fixes a regression where `ngModel` no longer syncs
letter by letter on Android devices, and instead syncs at the
end of every word. This broke when we introduced buffering of
IME events so IMEs like Pinyin keyboards or Katakana keyboards
wouldn't display composition strings. Unfortunately, iOS devices
and Android devices have opposite event behavior. Whereas iOS
devices fire composition events for IME keyboards only, Android
fires composition events for Latin-language keyboards. For
this reason, languages like English don't work as expected on
Android if we always buffer. So to support both platforms,
composition string buffering will only be turned on by default
for non-Android devices.

However, we have also added a `COMPOSITION_BUFFER_MODE` token
to make this configurable by the application. In some cases, apps
might might still want to receive intermediate values. For example,
some inputs begin searching based on Latin letters before a
character selection is made.

As a provider, this is fairly flexible. If you want to turn
composition buffering off, simply provide the token at the top
level:

```ts
providers: [
   {provide: COMPOSITION_BUFFER_MODE, useValue: false}
]
```

Or, if you want to change the mode  based on locale or platform,
you can use a factory:

```ts
import {shouldUseBuffering} from 'my/lib';

....
providers: [
   {provide: COMPOSITION_BUFFER_MODE, useFactory: shouldUseBuffering}
]
```

Closes #15079.

PR Close #15256
This commit is contained in:
Kara Erickson 2017-03-20 17:38:33 -07:00 committed by Miško Hevery
parent 97149f9424
commit 5efc86069f
12 changed files with 237 additions and 61 deletions

View File

@ -12,9 +12,9 @@ PACKAGES=(core
compiler
common
animations
forms
platform-browser
platform-browser-dynamic
forms
http
platform-server
platform-webworker

View File

@ -10,7 +10,8 @@
"license": "MIT",
"peerDependencies": {
"@angular/core": "0.0.0-PLACEHOLDER",
"@angular/common": "0.0.0-PLACEHOLDER"
"@angular/common": "0.0.0-PLACEHOLDER",
"@angular/platform-browser": "0.0.0-PLACEHOLDER"
},
"repository": {
"type": "git",

View File

@ -16,6 +16,7 @@ export default {
'@angular/core': 'ng.core',
'@angular/common': 'ng.common',
'@angular/compiler': 'ng.compiler',
'@angular/platform-browser': 'ng.platformBrowser',
'rxjs/Observable': 'Rx',
'rxjs/Subject': 'Rx',
'rxjs/observable/fromPromise': 'Rx.Observable',

View File

@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Directive, ElementRef, Renderer, forwardRef} from '@angular/core';
import {Directive, ElementRef, Inject, InjectionToken, Optional, Renderer, forwardRef} from '@angular/core';
import {ɵgetDOM as getDOM} from '@angular/platform-browser';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from './control_value_accessor';
export const DEFAULT_VALUE_ACCESSOR: any = {
@ -16,6 +16,21 @@ export const DEFAULT_VALUE_ACCESSOR: any = {
multi: true
};
/**
* We must check whether the agent is Android because composition events
* behave differently between iOS and Android.
*/
function _isAndroid(): boolean {
const userAgent = getDOM() ? getDOM().getUserAgent() : '';
return /android (\d+)/.test(userAgent.toLowerCase());
}
/**
* Turn this mode on if you want form directives to buffer IME input until compositionend
* @experimental
*/
export const COMPOSITION_BUFFER_MODE = new InjectionToken<boolean>('CompositionEventMode');
/**
* The default accessor for writing a value and listening to changes that is used by the
* {@link NgModel}, {@link FormControlDirective}, and {@link FormControlName} directives.
@ -32,15 +47,29 @@ export const DEFAULT_VALUE_ACCESSOR: any = {
'input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]',
// TODO: vsavkin replace the above selector with the one below it once
// https://github.com/angular/angular/issues/3011 is implemented
// selector: '[ngControl],[ngModel],[ngFormControl]',
host: {'(input)': 'onChange($event.target.value)', '(blur)': 'onTouched()'},
// selector: '[ngModel],[formControl],[formControlName]',
host: {
'(input)': '_handleInput($event.target.value)',
'(blur)': 'onTouched()',
'(compositionstart)': '_compositionStart()',
'(compositionend)': '_compositionEnd($event.target.value)'
},
providers: [DEFAULT_VALUE_ACCESSOR]
})
export class DefaultValueAccessor implements ControlValueAccessor {
onChange = (_: any) => {};
onTouched = () => {};
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
/** Whether the user is creating a composition string (IME events). */
private _composing = false;
constructor(
private _renderer: Renderer, private _elementRef: ElementRef,
@Optional() @Inject(COMPOSITION_BUFFER_MODE) private _compositionMode: boolean) {
if (this._compositionMode == null) {
this._compositionMode = !_isAndroid();
}
}
writeValue(value: any): void {
const normalizedValue = value == null ? '' : value;
@ -53,4 +82,17 @@ export class DefaultValueAccessor implements ControlValueAccessor {
setDisabledState(isDisabled: boolean): void {
this._renderer.setElementProperty(this._elementRef.nativeElement, 'disabled', isDisabled);
}
_handleInput(value: any): void {
if (!this._compositionMode || (this._compositionMode && !this._composing)) {
this.onChange(value);
}
}
_compositionStart(): void { this._composing = true; }
_compositionEnd(value: any): void {
this._composing = false;
this._compositionMode && this.onChange(value);
}
}

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Directive, EventEmitter, Host, HostListener, Inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges, forwardRef} from '@angular/core';
import {Directive, EventEmitter, Host, Inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges, forwardRef} from '@angular/core';
import {FormControl} from '../model';
import {NG_ASYNC_VALIDATORS, NG_VALIDATORS} from '../validators';
@ -114,7 +114,6 @@ export class NgModel extends NgControl implements OnChanges,
_control = new FormControl();
/** @internal */
_registered = false;
private _composing = false;
viewModel: any;
@Input() name: string;
@ -124,15 +123,6 @@ export class NgModel extends NgControl implements OnChanges,
@Output('ngModelChange') update = new EventEmitter();
@HostListener('compositionstart')
compositionStart(): void { this._composing = true; }
@HostListener('compositionend')
compositionEnd(): void {
this._composing = false;
this.update.emit(this.viewModel);
}
constructor(@Optional() @Host() parent: ControlContainer,
@Optional() @Self() @Inject(NG_VALIDATORS) validators: Array<Validator|ValidatorFn>,
@Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: Array<AsyncValidator|AsyncValidatorFn>,
@ -176,7 +166,7 @@ export class NgModel extends NgControl implements OnChanges,
viewToModelUpdate(newValue: any): void {
this.viewModel = newValue;
!this._composing && this.update.emit(newValue);
this.update.emit(newValue);
}
private _setUpControl(): void {

View File

@ -23,7 +23,7 @@ export {AbstractFormGroupDirective} from './directives/abstract_form_group_direc
export {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor';
export {ControlContainer} from './directives/control_container';
export {ControlValueAccessor, NG_VALUE_ACCESSOR} from './directives/control_value_accessor';
export {DefaultValueAccessor} from './directives/default_value_accessor';
export {COMPOSITION_BUFFER_MODE, DefaultValueAccessor} from './directives/default_value_accessor';
export {Form} from './directives/form_interface';
export {NgControl} from './directives/ng_control';
export {NgControlStatus, NgControlStatusGroup} from './directives/ng_control_status';

View File

@ -44,7 +44,7 @@ export function main() {
describe('Form Directives', () => {
let defaultAccessor: DefaultValueAccessor;
beforeEach(() => { defaultAccessor = new DefaultValueAccessor(null, null); });
beforeEach(() => { defaultAccessor = new DefaultValueAccessor(null, null, null); });
describe('shared', () => {
describe('selectValueAccessor', () => {

View File

@ -8,7 +8,7 @@
import {Component, Directive, EventEmitter, Input, Output, Type, forwardRef} from '@angular/core';
import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing';
import {AbstractControl, AsyncValidator, AsyncValidatorFn, ControlValueAccessor, FormArray, FormControl, FormGroup, FormGroupDirective, FormsModule, NG_ASYNC_VALIDATORS, NG_VALIDATORS, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule, Validators} from '@angular/forms';
import {AbstractControl, AsyncValidator, AsyncValidatorFn, COMPOSITION_BUFFER_MODE, ControlValueAccessor, FormArray, FormControl, FormGroup, FormGroupDirective, FormsModule, NG_ASYNC_VALIDATORS, NG_VALIDATORS, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule, Validators} from '@angular/forms';
import {By} from '@angular/platform-browser/src/dom/debug/by';
import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
import {dispatchEvent} from '@angular/platform-browser/testing/src/browser_util';
@ -1908,6 +1908,87 @@ export function main() {
});
});
describe('IME events', () => {
it('should determine IME event handling depending on platform by default', () => {
const fixture = initTest(FormControlComp);
fixture.componentInstance.control = new FormControl('oldValue');
fixture.detectChanges();
const inputEl = fixture.debugElement.query(By.css('input'));
const inputNativeEl = inputEl.nativeElement;
expect(inputNativeEl.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionstart', null);
inputNativeEl.value = 'updatedValue';
dispatchEvent(inputNativeEl, 'input');
const isAndroid = /android (\d+)/.test(getDOM().getUserAgent().toLowerCase());
if (isAndroid) {
// On Android, values should update immediately
expect(fixture.componentInstance.control.value).toEqual('updatedValue');
} else {
// On other platforms, values should wait for compositionend
expect(fixture.componentInstance.control.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionend', {target: {value: 'updatedValue'}});
fixture.detectChanges();
expect(fixture.componentInstance.control.value).toEqual('updatedValue');
}
});
it('should hold IME events until compositionend if composition mode', () => {
TestBed.overrideComponent(
FormControlComp,
{set: {providers: [{provide: COMPOSITION_BUFFER_MODE, useValue: true}]}});
const fixture = initTest(FormControlComp);
fixture.componentInstance.control = new FormControl('oldValue');
fixture.detectChanges();
const inputEl = fixture.debugElement.query(By.css('input'));
const inputNativeEl = inputEl.nativeElement;
expect(inputNativeEl.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionstart', null);
inputNativeEl.value = 'updatedValue';
dispatchEvent(inputNativeEl, 'input');
// should not update when compositionstart
expect(fixture.componentInstance.control.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionend', {target: {value: 'updatedValue'}});
fixture.detectChanges();
// should update when compositionend
expect(fixture.componentInstance.control.value).toEqual('updatedValue');
});
it('should work normally with composition events if composition mode is off', () => {
TestBed.overrideComponent(
FormControlComp,
{set: {providers: [{provide: COMPOSITION_BUFFER_MODE, useValue: false}]}});
const fixture = initTest(FormControlComp);
fixture.componentInstance.control = new FormControl('oldValue');
fixture.detectChanges();
const inputEl = fixture.debugElement.query(By.css('input'));
const inputNativeEl = inputEl.nativeElement;
expect(inputNativeEl.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionstart', null);
inputNativeEl.value = 'updatedValue';
dispatchEvent(inputNativeEl, 'input');
fixture.detectChanges();
// formControl should update normally
expect(fixture.componentInstance.control.value).toEqual('updatedValue');
});
});
});
}

View File

@ -8,7 +8,7 @@
import {Component, Directive, Input, Type, forwardRef} from '@angular/core';
import {ComponentFixture, TestBed, async, fakeAsync, tick} from '@angular/core/testing';
import {AbstractControl, AsyncValidator, ControlValueAccessor, FormsModule, NG_ASYNC_VALIDATORS, NG_VALUE_ACCESSOR, NgForm} from '@angular/forms';
import {AbstractControl, AsyncValidator, COMPOSITION_BUFFER_MODE, ControlValueAccessor, FormsModule, NG_ASYNC_VALIDATORS, NG_VALUE_ACCESSOR, NgForm} from '@angular/forms';
import {By} from '@angular/platform-browser/src/dom/debug/by';
import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
import {dispatchEvent} from '@angular/platform-browser/testing/src/browser_util';
@ -42,39 +42,6 @@ export function main() {
expect(fixture.componentInstance.name).toEqual('updatedValue');
}));
it('should ngModel hold ime events until compositionend', fakeAsync(() => {
const fixture = initTest(StandaloneNgModel);
// model -> view
const inputEl = fixture.debugElement.query(By.css('input'));
const inputNativeEl = inputEl.nativeElement;
fixture.componentInstance.name = 'oldValue';
fixture.detectChanges();
tick();
expect(inputNativeEl.value).toEqual('oldValue');
// view -> model
inputEl.triggerEventHandler('compositionstart', null);
inputNativeEl.value = 'updatedValue';
dispatchEvent(inputNativeEl, 'input');
tick();
// should ngModel not update when compositionstart
expect(fixture.componentInstance.name).toEqual('oldValue');
inputEl.triggerEventHandler('compositionend', null);
fixture.detectChanges();
tick();
// should ngModel update when compositionend
expect(fixture.componentInstance.name).toEqual('updatedValue');
}));
it('should support ngModel registration with a parent form', fakeAsync(() => {
const fixture = initTest(NgModelForm);
fixture.componentInstance.name = 'Nancy';
@ -1166,6 +1133,95 @@ export function main() {
});
describe('IME events', () => {
it('should determine IME event handling depending on platform by default', fakeAsync(() => {
const fixture = initTest(StandaloneNgModel);
const inputEl = fixture.debugElement.query(By.css('input'));
const inputNativeEl = inputEl.nativeElement;
fixture.componentInstance.name = 'oldValue';
fixture.detectChanges();
tick();
expect(inputNativeEl.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionstart', null);
inputNativeEl.value = 'updatedValue';
dispatchEvent(inputNativeEl, 'input');
tick();
const isAndroid = /android (\d+)/.test(getDOM().getUserAgent().toLowerCase());
if (isAndroid) {
// On Android, values should update immediately
expect(fixture.componentInstance.name).toEqual('updatedValue');
} else {
// On other platforms, values should wait until compositionend
expect(fixture.componentInstance.name).toEqual('oldValue');
inputEl.triggerEventHandler('compositionend', {target: {value: 'updatedValue'}});
fixture.detectChanges();
tick();
expect(fixture.componentInstance.name).toEqual('updatedValue');
}
}));
it('should hold IME events until compositionend if composition mode', fakeAsync(() => {
TestBed.overrideComponent(
StandaloneNgModel,
{set: {providers: [{provide: COMPOSITION_BUFFER_MODE, useValue: true}]}});
const fixture = initTest(StandaloneNgModel);
const inputEl = fixture.debugElement.query(By.css('input'));
const inputNativeEl = inputEl.nativeElement;
fixture.componentInstance.name = 'oldValue';
fixture.detectChanges();
tick();
expect(inputNativeEl.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionstart', null);
inputNativeEl.value = 'updatedValue';
dispatchEvent(inputNativeEl, 'input');
tick();
// ngModel should not update when compositionstart
expect(fixture.componentInstance.name).toEqual('oldValue');
inputEl.triggerEventHandler('compositionend', {target: {value: 'updatedValue'}});
fixture.detectChanges();
tick();
// ngModel should update when compositionend
expect(fixture.componentInstance.name).toEqual('updatedValue');
}));
it('should work normally with composition events if composition mode is off',
fakeAsync(() => {
TestBed.overrideComponent(
StandaloneNgModel,
{set: {providers: [{provide: COMPOSITION_BUFFER_MODE, useValue: false}]}});
const fixture = initTest(StandaloneNgModel);
const inputEl = fixture.debugElement.query(By.css('input'));
const inputNativeEl = inputEl.nativeElement;
fixture.componentInstance.name = 'oldValue';
fixture.detectChanges();
tick();
expect(inputNativeEl.value).toEqual('oldValue');
inputEl.triggerEventHandler('compositionstart', null);
inputNativeEl.value = 'updatedValue';
dispatchEvent(inputNativeEl, 'input');
tick();
// ngModel should update normally
expect(fixture.componentInstance.name).toEqual('updatedValue');
}));
});
describe('ngModel corner cases', () => {
it('should update the view when the model is set back to what used to be in the view',
fakeAsync(() => {

View File

@ -13,7 +13,8 @@
"@angular/common": ["../../dist/packages/common"],
"@angular/common/testing": ["../../dist/packages/common/testing"],
"@angular/compiler": ["../../dist/packages/compiler"],
"@angular/compiler/testing": ["../../dist/packages/compiler/testing"]
"@angular/compiler/testing": ["../../dist/packages/compiler/testing"],
"@angular/platform-browser": ["../../dist/packages/platform-browser"]
},
"rootDir": ".",
"sourceMap": true,

View File

@ -150,7 +150,7 @@ export class WorkerDomAdapter extends DomAdapter {
getLocation(): Location { throw 'not implemented'; }
getBaseHref(doc: Document): string { throw 'not implemented'; }
resetBaseElement(): void { throw 'not implemented'; }
getUserAgent(): string { throw 'not implemented'; }
getUserAgent(): string { return 'Fake user agent'; }
setData(element: any, name: string, value: string) { throw 'not implemented'; }
getComputedStyle(element: any): any { throw 'not implemented'; }
getData(element: any, name: string): string { throw 'not implemented'; }

View File

@ -121,6 +121,9 @@ export declare class CheckboxRequiredValidator extends RequiredValidator {
validate(c: AbstractControl): ValidationErrors | null;
}
/** @experimental */
export declare const COMPOSITION_BUFFER_MODE: InjectionToken<boolean>;
/** @stable */
export declare class ControlContainer extends AbstractControlDirective {
readonly formDirective: Form;
@ -140,7 +143,10 @@ export interface ControlValueAccessor {
export declare class DefaultValueAccessor implements ControlValueAccessor {
onChange: (_: any) => void;
onTouched: () => void;
constructor(_renderer: Renderer, _elementRef: ElementRef);
constructor(_renderer: Renderer, _elementRef: ElementRef, _compositionMode: boolean);
_compositionEnd(value: any): void;
_compositionStart(): void;
_handleInput(value: any): void;
registerOnChange(fn: (_: any) => void): void;
registerOnTouched(fn: () => void): void;
setDisabledState(isDisabled: boolean): void;
@ -426,8 +432,6 @@ export declare class NgModel extends NgControl implements OnChanges, OnDestroy {
readonly validator: ValidatorFn;
viewModel: any;
constructor(parent: ControlContainer, validators: Array<Validator | ValidatorFn>, asyncValidators: Array<AsyncValidator | AsyncValidatorFn>, valueAccessors: ControlValueAccessor[]);
compositionEnd(): void;
compositionStart(): void;
ngOnChanges(changes: SimpleChanges): void;
ngOnDestroy(): void;
viewToModelUpdate(newValue: any): void;