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:

committed by
Miško Hevery

parent
97149f9424
commit
5efc86069f
@ -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(() => {
|
||||
|
Reference in New Issue
Block a user