From c01b5caab93c17b33d39625aad76a437c29a902e Mon Sep 17 00:00:00 2001 From: nturgut Date: Tue, 5 Jan 2021 12:08:09 -0800 Subject: [PATCH] [web] fixing text editing for autofill with semantics (#23361) * fixing text editing for autofill with semantics * fix tests. add tests. unskip tests * removing new. adding issue to comment --- .../lib/src/engine/semantics/semantics.dart | 1 + .../lib/src/engine/semantics/text_field.dart | 47 ++++++- lib/web_ui/test/text_editing_test.dart | 125 ++++++++++++++---- 3 files changed, 146 insertions(+), 27 deletions(-) diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 9941f5fc67c7a..c9b8b28e945e8 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -927,6 +927,7 @@ class SemanticsObject { ..height = '${rect.height}px'; } } else { + // TODO: https://github.com/flutter/flutter/issues/73347 if (isDesktop) { element.style ..removeProperty('transform-origin') diff --git a/lib/web_ui/lib/src/engine/semantics/text_field.dart b/lib/web_ui/lib/src/engine/semantics/text_field.dart index 83f370745434c..c1e291b8d49c3 100644 --- a/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -15,12 +15,16 @@ part of engine; /// This class is still responsible for hooking up the DOM element with the /// [HybridTextEditing] instance so that changes are communicated to Flutter. class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { + /// The semantics object which this text editing element belongs to. + final SemanticsObject semanticsObject; + /// Creates a [SemanticsTextEditingStrategy] that eagerly instantiates /// [domElement] so the caller can insert it before calling /// [SemanticsTextEditingStrategy.enable]. - SemanticsTextEditingStrategy( + SemanticsTextEditingStrategy(SemanticsObject semanticsObject, HybridTextEditing owner, html.HtmlElement domElement) - : super(owner) { + : this.semanticsObject = semanticsObject, + super(owner) { // Make sure the DOM element is of a type that we support for text editing. // TODO(yjbanov): move into initializer list when https://github.com/dart-lang/sdk/issues/37881 is fixed. assert((domElement is html.InputElement) || @@ -44,6 +48,20 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { _subscriptions.clear(); _lastEditingState = null; + // If focused element is a part of a form, it needs to stay on the DOM + // until the autofill context of the form is finalized. + // More details on `TextInput.finishAutofillContext` call. + if (_appendedToForm && + _inputConfiguration.autofillGroup?.formElement != null) { + // We want to save the domElement with the form. However we still + // need to keep the text editing domElement attached to the semantics + // tree. In order to simplify the logic we will create a clone of the + // element. + final html.Node textFieldClone = domElement.clone(false); + domElement = textFieldClone as html.HtmlElement; + _inputConfiguration.autofillGroup?.storeForm(); + } + // If the text element still has focus, remove focus from the editable // element to cause the keyboard to hide. // Otherwise, the keyboard stays on screen even when the user navigates to @@ -87,6 +105,7 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { _inputConfiguration = inputConfig; _onChange = onChange; _onAction = onAction; + _applyConfiguration(inputConfig); } @override @@ -96,6 +115,25 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { // Refocus after setting editing state. domElement.focus(); } + + @override + void placeElement() { + // If this text editing element is a part of an autofill group. + if (hasAutofillGroup) { + placeForm(); + } + domElement.focus(); + } + + @override + void placeForm() { + // Switch domElement's parent from semantics object to form. + domElement.remove(); + _inputConfiguration.autofillGroup!.formElement.append(domElement); + semanticsObject.element + .append(_inputConfiguration.autofillGroup!.formElement); + _appendedToForm = true; + } } /// Manages semantics objects that represent editable text fields. @@ -114,6 +152,7 @@ class TextField extends RoleManager { ? html.TextAreaElement() : html.InputElement(); textEditingElement = SemanticsTextEditingStrategy( + semanticsObject, textEditing, editableDomElement, ); @@ -191,8 +230,8 @@ class TextField extends RoleManager { void _initializeForWebkit() { // Safari for desktop is also initialized as the other browsers. if (operatingSystem == OperatingSystem.macOs) { - _initializeForBlink(); - return; + _initializeForBlink(); + return; } num? lastTouchStartOffsetX; num? lastTouchStartOffsetY; diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index 184131a372a37..efcf24df89585 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -365,6 +365,13 @@ void testMain() { InputElement testInputElement; HybridTextEditing testTextEditing; + final PlatformMessagesSpy spy = PlatformMessagesSpy(); + + /// Emulates sending of a message by the framework to the engine. + void sendFrameworkMessage(dynamic message) { + textEditing.channel.handleTextInput(message, (ByteData data) {}); + } + setUp(() { testInputElement = InputElement(); testTextEditing = HybridTextEditing(); @@ -375,18 +382,77 @@ void testMain() { testInputElement = null; }); + test('autofill form lifecycle works', () async { + editingElement = SemanticsTextEditingStrategy( + SemanticsObject(5, null), testTextEditing, testInputElement); + // Create a configuration with an AutofillGroup of four text fields. + final Map flutterMultiAutofillElementConfig = + createFlutterConfig('text', + autofillHint: 'username', + autofillHintsForFields: [ + 'username', + 'email', + 'name', + 'telephoneNumber' + ]); + final MethodCall setClient = MethodCall('TextInput.setClient', + [123, flutterMultiAutofillElementConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState1 = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + + // The transform is changed. For example after a validation error, red + // line appeared under the input field. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + + // Form is added to DOM. + expect(document.getElementsByTagName('form'), isNotEmpty); + + const MethodCall clearClient = MethodCall('TextInput.clearClient'); + sendFrameworkMessage(codec.encodeMethodCall(clearClient)); + + // Confirm that [HybridTextEditing] didn't send any messages. + expect(spy.messages, isEmpty); + // Form stays on the DOM until autofill context is finalized. + expect(document.getElementsByTagName('form'), isNotEmpty); + expect(formsOnTheDom, hasLength(1)); + + const MethodCall finishAutofillContext = + MethodCall('TextInput.finishAutofillContext', false); + sendFrameworkMessage(codec.encodeMethodCall(finishAutofillContext)); + + // Form element is removed from DOM. + expect(document.getElementsByTagName('form'), hasLength(0)); + expect(formsOnTheDom, hasLength(0)); + }, + // TODO(nurhan): https://github.com/flutter/flutter/issues/50769 + skip: browserEngine == BrowserEngine.edge); + test('Does not accept dom elements of a wrong type', () { // A regular shouldn't be accepted. final HtmlElement span = SpanElement(); expect( - () => SemanticsTextEditingStrategy(HybridTextEditing(), span), + () => SemanticsTextEditingStrategy( + SemanticsObject(5, null), HybridTextEditing(), span), throwsAssertionError, ); }); test('Do not re-acquire focus', () { - editingElement = - SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement); + editingElement = SemanticsTextEditingStrategy( + SemanticsObject(5, null), HybridTextEditing(), testInputElement); expect(document.activeElement, document.body); @@ -408,8 +474,8 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('Does not dispose and recreate dom elements in persistent mode', () { - editingElement = - SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement); + editingElement = SemanticsTextEditingStrategy( + SemanticsObject(5, null), HybridTextEditing(), testInputElement); // The DOM element should've been eagerly created. expect(testInputElement, isNotNull); @@ -454,8 +520,8 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('Refocuses when setting editing state', () { - editingElement = - SemanticsTextEditingStrategy(HybridTextEditing(), testInputElement); + editingElement = SemanticsTextEditingStrategy( + SemanticsObject(5, null), HybridTextEditing(), testInputElement); document.body.append(testInputElement); editingElement.enable( @@ -474,8 +540,8 @@ void testMain() { test('Works in multi-line mode', () { final TextAreaElement textarea = TextAreaElement(); - editingElement = - SemanticsTextEditingStrategy(HybridTextEditing(), textarea); + editingElement = SemanticsTextEditingStrategy( + SemanticsObject(5, null), HybridTextEditing(), textarea); expect(editingElement.domElement, textarea); expect(document.activeElement, document.body); @@ -731,8 +797,8 @@ void testMain() { operatingSystem == OperatingSystem.iOs) { expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/textinput'); - expect(spy.messages[0].methodName, - 'TextInputClient.onConnectionClosed'); + expect( + spy.messages[0].methodName, 'TextInputClient.onConnectionClosed'); await Future.delayed(Duration.zero); // DOM element loses the focus. expect(document.activeElement, document.body); @@ -787,10 +853,8 @@ void testMain() { // Input element is removed from DOM. expect(document.getElementsByTagName('input'), hasLength(0)); }, - // TODO(nurhan): https://github.com/flutter/flutter/issues/50590 // TODO(nurhan): https://github.com/flutter/flutter/issues/50769 - skip: (browserEngine == BrowserEngine.webkit || - browserEngine == BrowserEngine.edge)); + skip: browserEngine == BrowserEngine.edge); test('finishAutofillContext removes form from DOM', () async { // Create a configuration with an AutofillGroup of four text fields. @@ -818,6 +882,13 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The transform is changed. For example after a validation error, red + // line appeared under the input field. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + // Form is added to DOM. expect(document.getElementsByTagName('form'), isNotEmpty); @@ -838,10 +909,8 @@ void testMain() { expect(document.getElementsByTagName('form'), hasLength(0)); expect(formsOnTheDom, hasLength(0)); }, - // TODO(nurhan): https://github.com/flutter/flutter/issues/50590 // TODO(nurhan): https://github.com/flutter/flutter/issues/50769 - skip: (browserEngine == BrowserEngine.webkit || - browserEngine == BrowserEngine.edge)); + skip: browserEngine == BrowserEngine.edge); test('finishAutofillContext with save submits forms', () async { // Create a configuration with an AutofillGroup of four text fields. @@ -869,6 +938,13 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The transform is changed. For example after a validation error, red + // line appeared under the input field. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + // Form is added to DOM. expect(document.getElementsByTagName('form'), isNotEmpty); FormElement formElement = document.getElementsByTagName('form')[0]; @@ -886,10 +962,8 @@ void testMain() { // `submit` action is called on form. await expectLater(await submittedForm.future, true); }, - // TODO(nurhan): https://github.com/flutter/flutter/issues/50590 // TODO(nurhan): https://github.com/flutter/flutter/issues/50769 - skip: (browserEngine == BrowserEngine.webkit || - browserEngine == BrowserEngine.edge)); + skip: browserEngine == BrowserEngine.edge); test('forms submits for focused input', () async { // Create a configuration with an AutofillGroup of four text fields. @@ -917,6 +991,13 @@ void testMain() { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + // The transform is changed. For example after a validation error, red + // line appeared under the input field. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + // Form is added to DOM. expect(document.getElementsByTagName('form'), isNotEmpty); FormElement formElement = document.getElementsByTagName('form')[0]; @@ -940,10 +1021,8 @@ void testMain() { expect(document.getElementsByTagName('form'), hasLength(0)); expect(formsOnTheDom, hasLength(0)); }, - // TODO(nurhan): https://github.com/flutter/flutter/issues/50590 // TODO(nurhan): https://github.com/flutter/flutter/issues/50769 - skip: (browserEngine == BrowserEngine.webkit || - browserEngine == BrowserEngine.edge)); + skip: browserEngine == BrowserEngine.edge); test('setClient, setEditingState, show, setClient', () { final MethodCall setClient = MethodCall(