diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index b8fc960b719..61345ca0431 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -188,20 +188,16 @@ void _insertEditingElementInView(DomElement element, int viewId) { class EngineAutofillForm { EngineAutofillForm({ required this.viewId, - required this.formElement, - this.elements, - this.items, - this.formIdentifier = '', - this.insertionReferenceNode, + required this.items, + required this.formIdentifier, + required this.focusedElementId, }); - final DomHTMLFormElement formElement; + DomHTMLFormElement? formElement; - final Map? elements; + final elements = {}; - final Map? items; - - final DomHTMLElement? insertionReferenceNode; + final Map items; /// Identifier for the form. /// @@ -209,13 +205,19 @@ class EngineAutofillForm { /// form. /// /// It is used for storing the form until submission. - /// See [formsOnTheDom]. + /// See [dormantForms]. final String formIdentifier; /// The ID of the view that this form is rendered into. final int viewId; - /// Creates an [EngineAutofillFrom] from the JSON representation of a Flutter + final String focusedElementId; + + bool get _isSafariStrategy => + textEditing.strategy is SafariDesktopTextEditingStrategy || + textEditing.strategy is IOSTextEditingStrategy; + + /// Creates an [EngineAutofillForm] from the JSON representation of a Flutter /// framework `TextInputConfiguration` object. /// /// The `focusedElementAutofill` argument corresponds to the "autofill" field @@ -237,15 +239,130 @@ class EngineAutofillForm { return null; } + final items = {}; + // If there is only one text field in the autofill model, `fields` will be // null. `focusedElementAutofill` contains the information about the one // text field. - final elements = {}; - final items = {}; - final DomHTMLFormElement formElement = createDomHTMLFormElement(); - final isSafariDesktopStrategy = textEditing.strategy is SafariDesktopTextEditingStrategy; - DomHTMLElement? insertionReferenceNode; + if (fields != null) { + for (final Map field in fields.cast>()) { + final autofillInfo = AutofillInfo.fromFrameworkMessage( + field.readJson('autofill'), + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + field.readString('textCapitalization'), + ), + ); + final EngineInputType inputType = EngineInputType.fromName( + field.readJson('inputType').readString('name'), + ); + + items[autofillInfo.uniqueIdentifier] = FieldItem( + inputType: inputType, + autofillInfo: autofillInfo, + ); + } + } else { + final autofillInfo = AutofillInfo.fromFrameworkMessage( + focusedElementAutofill, + textCapitalization: TextCapitalizationConfig.fromInputConfiguration( + focusedElementAutofill.readString('textCapitalization'), + ), + ); + + // Any `inputType` is okay here since this will not be used to create the focused element + // here. The focused element is a special case that is created outside of the + // `EngineAutofillForm`. + const EngineInputType inputType = EngineInputType.none; + + items[autofillInfo.uniqueIdentifier] = FieldItem( + inputType: inputType, + autofillInfo: autofillInfo, + ); + } + + return EngineAutofillForm( + viewId: viewId, + items: items, + formIdentifier: _getFormIdentifier(items), + focusedElementId: focusedElementAutofill.readString('uniqueIdentifier'), + ); + } + + static String _getFormIdentifier(Map items) { + final ids = []; + for (final FieldItem item in items.values) { + ids.add(item.autofillInfo.uniqueIdentifier); + } + + ids.sort(); + return ids.join('*'); + } + + /// Wakes up the form with the given focused element. + /// + /// The [focusedElement] is inserted into the form, replacing the old focused element. + void wakeUp(DomHTMLElement focusedElement, AutofillInfo focusedAutofill) { + // Since we're disabling pointer events on the form to fix Safari autofill, + // we need to explicitly set pointer events on the active input element in + // order to calculate the correct pointer event offsets. + // See: https://github.com/flutter/flutter/issues/136006 + if (_isSafariStrategy) { + focusedElement.style.pointerEvents = 'all'; + } + + final EngineAutofillForm? existingForm = dormantForms[formIdentifier]; + + if (!identical(this, existingForm)) { + assert(formElement == null); + assert(elements.isEmpty); + + if (existingForm != null) { + // If the form already has a dormant DOM element, let's use it instead of creating a new one. + formElement = existingForm.formElement; + elements.addAll(existingForm.elements); + + // There's a new focused element that needs to be inserted into the existing form. + // + // Do not cause DOM disturbance unless necessary. Doing superfluous DOM operations may seem + // harmless, but it actually causes focus changes that could break things. + if (!formElement!.contains(focusedElement)) { + // Find the matching element and replace it with the new focused element. + final DomElement oldFocusedElement = elements[focusedAutofill.uniqueIdentifier]!; + elements[focusedAutofill.uniqueIdentifier] = focusedElement; + oldFocusedElement.replaceWith(focusedElement); + } + } else { + formElement = _createFormElementAndFields(focusedElement, focusedAutofill); + _insertEditingElementInView(formElement!, viewId); + } + } + + _updateFieldValues(); + } + + /// Makes the form dormant. + /// + /// A dormant form stays in the DOM and does not interact with the framework until it's woken up. + /// + /// The form is kept in the DOM to: + /// 1. Allow the browser to autofill it. + /// 2. Allow submitting the form later. + void goDormant() { + assert(formElement != null); + + dormantForms[formIdentifier] = this; + _styleAutofillElements(formElement!, isOffScreen: true); + } + + DomHTMLFormElement _createFormElementAndFields( + DomHTMLElement focusedElement, + AutofillInfo focusedAutofill, + ) { + assert(this.formElement == null); + assert(elements.isEmpty); + + final DomHTMLFormElement formElement = createDomHTMLFormElement(); // Validation is in the framework side. formElement.noValidate = true; formElement.method = 'post'; @@ -255,88 +372,34 @@ class EngineAutofillForm { // We need to explicitly disable pointer events on the form in Safari Desktop, // so that we don't have pointer event collisions if users hover over or click // into the invisible autofill elements within the form. - _styleAutofillElements(formElement, shouldDisablePointerEvents: isSafariDesktopStrategy); + _styleAutofillElements(formElement, shouldDisablePointerEvents: _isSafariStrategy); - // We keep the ids in a list then sort them later, in case the text fields' - // locations are re-ordered on the framework side. - final ids = List.empty(growable: true); + for (final FieldItem field in items.values) { + final DomHTMLElement htmlElement; + if (field.autofillInfo.uniqueIdentifier == focusedAutofill.uniqueIdentifier) { + // Do not create the focused element here since it is created already. Use the provided one. + htmlElement = focusedElement; + } else { + htmlElement = field.inputType.createDomElement(); + field.autofillInfo.applyToDomElement(htmlElement); - // The focused text editing element will not be created here. - final focusedElement = AutofillInfo.fromFrameworkMessage(focusedElementAutofill); - - if (fields != null) { - var fieldIsFocusedElement = false; - for (final Map field in fields.cast>()) { - final Map autofillInfo = field.readJson('autofill'); - final autofill = AutofillInfo.fromFrameworkMessage( - autofillInfo, - textCapitalization: TextCapitalizationConfig.fromInputConfiguration( - field.readString('textCapitalization'), - ), + // Safari does not respect elements that are invisible (or + // have no size) and that leads to issues with autofill only partially + // working (ref: https://github.com/flutter/flutter/issues/71275). + // Thus, we have to make sure that the elements remain invisible to users, + // but not to Safari for autofill to work. Since these elements are + // sized and placed on the DOM, we also have to disable pointer events. + _styleAutofillElements( + htmlElement, + shouldHideElement: !_isSafariStrategy, + shouldDisablePointerEvents: _isSafariStrategy, ); - - ids.add(autofill.uniqueIdentifier); - - if (autofill.uniqueIdentifier != focusedElement.uniqueIdentifier) { - final EngineInputType engineInputType = EngineInputType.fromName( - field.readJson('inputType').readString('name'), - ); - - final DomHTMLElement htmlElement = engineInputType.createDomElement(); - autofill.editingState.applyToDomElement(htmlElement); - autofill.applyToDomElement(htmlElement); - - // Safari Desktop does not respect elements that are invisible (or - // have no size) and that leads to issues with autofill only partially - // working (ref: https://github.com/flutter/flutter/issues/71275). - // Thus, we have to make sure that the elements remain invisible to users, - // but not to Safari for autofill to work. Since these elements are - // sized and placed on the DOM, we also have to disable pointer events. - _styleAutofillElements( - htmlElement, - shouldHideElement: !isSafariDesktopStrategy, - shouldDisablePointerEvents: isSafariDesktopStrategy, - ); - - items[autofill.uniqueIdentifier] = autofill; - elements[autofill.uniqueIdentifier] = htmlElement; - formElement.append(htmlElement); - - // We want to track the node in the position directly after our focused - // element, so we can later insert that element in the correct position - // right before this node. - if (fieldIsFocusedElement) { - insertionReferenceNode = htmlElement; - fieldIsFocusedElement = false; - } - } else { - // current field is the focused element that we create elsewhere - fieldIsFocusedElement = true; - } } - } else { - // There is one input element in the form. - ids.add(focusedElement.uniqueIdentifier); + + elements[field.autofillInfo.uniqueIdentifier] = htmlElement; + formElement.append(htmlElement); } - ids.sort(); - final idBuffer = StringBuffer(); - - // Add a separator between element identifiers. - for (final id in ids) { - if (idBuffer.length > 0) { - idBuffer.write('*'); - } - idBuffer.write(id); - } - - final formIdentifier = idBuffer.toString(); - - // If a form with the same Autofill elements is already on the dom, remove - // it from DOM. - final DomHTMLFormElement? form = formsOnTheDom[formIdentifier]; - form?.remove(); - // In order to submit the form when Framework sends a `TextInput.commit` // message, we add a submit button to the form. // The -1 tab index value makes this element not reachable by keyboard. @@ -344,43 +407,19 @@ class EngineAutofillForm { _styleAutofillElements(submitButton, isOffScreen: true); submitButton.className = 'submitBtn'; submitButton.type = 'submit'; - formElement.append(submitButton); - // If the focused node is at the end of the form, we'll default to inserting - // it before the submit field. - insertionReferenceNode ??= submitButton; - - return EngineAutofillForm( - viewId: viewId, - formElement: formElement, - elements: elements, - items: items, - formIdentifier: formIdentifier, - insertionReferenceNode: insertionReferenceNode, - ); + return formElement; } - void placeForm(DomHTMLElement mainTextEditingElement) { - // Since we're disabling pointer events on the form to fix Safari autofill, - // we need to explicitly set pointer events on the active input element in - // order to calculate the correct pointer event offsets. - // See: https://github.com/flutter/flutter/issues/136006 - if (textEditing.strategy is SafariDesktopTextEditingStrategy) { - mainTextEditingElement.style.pointerEvents = 'all'; + /// Updates the field values in this form. + void _updateFieldValues() { + for (final String key in elements.keys) { + final DomHTMLElement element = elements[key]!; + final AutofillInfo autofill = items[key]!.autofillInfo; + // Maybe only apply text but not selection? + autofill.editingState.applyToDomElement(element); } - - // Do not cause DOM disturbance unless necessary. Doing superfluous DOM operations may seem - // harmless, but it actually causes focus changes that could break things. - if (!formElement.contains(mainTextEditingElement)) { - formElement.insertBefore(mainTextEditingElement, insertionReferenceNode); - } - _insertEditingElementInView(formElement, viewId); - } - - void storeForm() { - formsOnTheDom[formIdentifier] = formElement; - _styleAutofillElements(formElement, isOffScreen: true); } /// Listens to `onInput` event on the form fields. @@ -394,21 +433,22 @@ class EngineAutofillForm { /// [TextEditingStrategy.addEventHandlers] method call and all /// listeners are removed during [TextEditingStrategy.disable] method call. List addInputEventListeners() { - final Iterable keys = elements!.keys; + final Iterable keys = elements.keys; final subscriptions = []; void addSubscriptionForKey(String key) { - final DomElement element = elements![key]!; + final DomHTMLElement element = elements[key]!; subscriptions.add( DomSubscription( element, 'input', createDomEventListener((DomEvent e) { - if (items![key] == null) { + if (items[key] == null) { throw StateError('AutofillInfo must have a valid uniqueIdentifier.'); - } else { - final AutofillInfo autofillInfo = items![key]!; - handleChange(element, autofillInfo); + } else if (key != focusedElementId) { + // `input` events on the focused element are handled elsewhere. + final AutofillInfo autofillInfo = items[key]!.autofillInfo; + _handleChange(element, autofillInfo); } }), ), @@ -419,20 +459,19 @@ class EngineAutofillForm { return subscriptions; } - void handleChange(DomElement domElement, AutofillInfo autofillInfo) { - final newEditingState = EditingState.fromDomElement(domElement as DomHTMLElement); - + void _handleChange(DomHTMLElement domElement, AutofillInfo autofillInfo) { + final newEditingState = EditingState.fromDomElement(domElement); _sendAutofillEditingState(autofillInfo.uniqueIdentifier, newEditingState); } /// Sends the 'TextInputClient.updateEditingStateWithTag' message to the framework. - void _sendAutofillEditingState(String? tag, EditingState editingState) { + void _sendAutofillEditingState(String tag, EditingState editingState) { EnginePlatformDispatcher.instance.invokeOnPlatformMessage( 'flutter/textinput', const JSONMethodCodec().encodeMethodCall( MethodCall('TextInputClient.updateEditingStateWithTag', [ 0, - {tag: editingState.toFlutter()}, + {tag: editingState.toFlutter()}, ]), ), _emptyCallback, @@ -440,6 +479,13 @@ class EngineAutofillForm { } } +class FieldItem { + FieldItem({required this.inputType, required this.autofillInfo}); + + final EngineInputType inputType; + final AutofillInfo autofillInfo; +} + /// Autofill related values. /// /// These values are to be used when a text field have autofill enabled. @@ -969,10 +1015,6 @@ class EditingState { /// This should only be used by focused elements only, because only focused /// elements can have their text selection range set. Attempting to set /// selection range on a non-focused element will cause it to request focus. - /// - /// See also: - /// - /// * [applyTextToDomElement], which is used for non-focused elements. void applyToDomElement(DomHTMLElement? domElement) { if (domElement != null && domElement.isA()) { final element = domElement as DomHTMLInputElement; @@ -988,25 +1030,6 @@ class EditingState { ); } } - - /// Applies the [text] to the [domElement]. - /// - /// This is used by non-focused elements. - /// - /// See also: - /// - /// * [applyToDomElement], which is used for focused elements. - void applyTextToDomElement(DomHTMLElement? domElement) { - if (domElement != null && domElement.isA()) { - final element = domElement as DomHTMLInputElement; - element.value = text; - } else if (domElement != null && domElement.isA()) { - final element = domElement as DomHTMLTextAreaElement; - element.value = text; - } else { - throw UnsupportedError('Unsupported DOM element type'); - } - } } /// Controls the appearance of the input control being edited. @@ -1085,6 +1108,7 @@ class InputConfiguration { final bool enableDeltaModel; + /// Autofill information for the focused text field. final AutofillInfo? autofill; final EngineAutofillForm? autofillGroup; @@ -1482,7 +1506,7 @@ abstract class DefaultTextEditingStrategy // More details on `TextInput.finishAutofillContext` call. if (_appendedToForm && inputConfiguration.autofillGroup?.formElement != null) { _styleAutofillElements(activeDomElement, isOffScreen: true); - inputConfiguration.autofillGroup?.storeForm(); + inputConfiguration.autofillGroup?.goDormant(); EnginePlatformDispatcher.instance.viewManager.safeBlur(activeDomElement); } else { EnginePlatformDispatcher.instance.viewManager.safeRemove(activeDomElement); @@ -1504,7 +1528,11 @@ abstract class DefaultTextEditingStrategy } void placeForm() { - inputConfiguration.autofillGroup!.placeForm(activeDomElement); + inputConfiguration.autofillGroup!.wakeUp(activeDomElement, inputConfiguration.autofill!); + // When the form woke up, it should've placed the correct editing state on all fields, including + // the focused one. We update `lastEditingState` to reflect that. + lastEditingState = EditingState.fromDomElement(activeDomElement); + _appendedToForm = true; } @@ -2261,8 +2289,8 @@ class TextInputFinishAutofillContext extends TextInputCommand { /// Called when the form is finalized with save option `true`. /// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277 void saveForms() { - formsOnTheDom.forEach((String identifier, DomHTMLFormElement form) { - final submitBtn = form.getElementsByClassName('submitBtn').first as DomHTMLInputElement; + dormantForms.forEach((String identifier, EngineAutofillForm form) { + final submitBtn = form.formElement!.getElementsByClassName('submitBtn').first as DomElement; submitBtn.click(); }); } @@ -2271,10 +2299,10 @@ void saveForms() { /// /// Called when the form is finalized. void cleanForms() { - for (final DomHTMLFormElement form in formsOnTheDom.values) { - form.remove(); + for (final EngineAutofillForm form in dormantForms.values) { + form.formElement?.remove(); } - formsOnTheDom.clear(); + dormantForms.clear(); } /// Translates the message-based communication between the framework and the @@ -2302,7 +2330,7 @@ class TextEditingChannel { case 'TextInput.updateConfig': // Set configuration eagerly because it contains data about the text - // field used to flush the command queue. However, delaye applying the + // field used to flush the command queue. However, delay applying the // configuration because the strategy may not be available yet. implementation.configuration = InputConfiguration.fromFrameworkMessage( call.arguments as Map, @@ -2415,6 +2443,17 @@ class TextEditingChannel { _emptyCallback, ); } + + /// Sends the 'TextInput.refocus' message to the framework. + void refocus(int? clientId) { + EnginePlatformDispatcher.instance.invokeOnPlatformMessage( + 'flutter/textinput', + const JSONMethodCodec().encodeMethodCall( + MethodCall('TextInput.refocus', [clientId]), + ), + _emptyCallback, + ); + } } /// Text editing singleton. @@ -2426,7 +2465,7 @@ final HybridTextEditing textEditing = HybridTextEditing(); /// save or cancel them. /// /// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277 -final Map formsOnTheDom = {}; +final dormantForms = {}; /// Should be used as a singleton to provide support for text editing in /// Flutter Web. @@ -2441,7 +2480,17 @@ class HybridTextEditing { /// /// The constructor also decides which text editing strategy to use depending /// on the operating system and browser engine. - HybridTextEditing(); + HybridTextEditing() { + if (ui_web.browser.operatingSystem == ui_web.OperatingSystem.iOs) { + // In iOS 26, the text field is blurred right before autofill occurs. We need to keep listening + // for focus events in order to re-establish the connection with the framework when the text + // field is focused again for autofill. + for (final EngineFlutterView view in EnginePlatformDispatcher.instance.views) { + _addRefocusListenerToView(view.viewId); + } + EnginePlatformDispatcher.instance.viewManager.onViewCreated.listen(_addRefocusListenerToView); + } + } late final TextEditingChannel channel = TextEditingChannel(this); @@ -2505,6 +2554,24 @@ class HybridTextEditing { channel.onConnectionClosed(_clientId); } } + + void _addRefocusListenerToView(int viewId) { + final EngineFlutterView? view = EnginePlatformDispatcher.instance.viewManager[viewId]; + view!.dom.textEditingHost.addEventListener('focusin', createDomEventListener(_handleRefocus)); + } + + void _handleRefocus(DomEvent event) { + if (isEditing) { + return; + } + final target = event.target as DomElement?; + if (target == null) { + return; + } + if (target.classList.contains(HybridTextEditing.textEditingClass)) { + channel.refocus(_clientId); + } + } } /// Information on the font and alignment of a text editing element. diff --git a/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart b/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart index fe87f6006db..c0c70aa02be 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart @@ -1007,7 +1007,7 @@ Future testMain() async { // Form elements { final DomHTMLFormElement formElement = - textEditing!.configuration!.autofillGroup!.formElement; + textEditing!.configuration!.autofillGroup!.formElement!; expect(formElement.style.alignContent, isEmpty); // Should contain one and one @@ -1220,14 +1220,14 @@ Future testMain() async { expect(spy.messages, isEmpty); // Form stays on the DOM until autofill context is finalized. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); - expect(formsOnTheDom, hasLength(1)); + expect(dormantForms, hasLength(1)); const finishAutofillContext = MethodCall('TextInput.finishAutofillContext', false); sendFrameworkMessage(codec.encodeMethodCall(finishAutofillContext)); // Form element is removed from DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), isEmpty); - expect(formsOnTheDom, hasLength(0)); + expect(dormantForms, hasLength(0)); }); test('finishAutofillContext with save submits forms', () async { @@ -1339,7 +1339,7 @@ Future testMain() async { await expectLater(await submittedForm.future, isTrue); // Form element is removed from DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), hasLength(0)); - expect(formsOnTheDom, hasLength(0)); + expect(dormantForms, hasLength(0)); }); test('Moves the focus across input elements', () async { @@ -1540,7 +1540,7 @@ Future testMain() async { expect(spy.messages, isEmpty); // Form stays on the DOM until autofill context is finalized. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); - expect(formsOnTheDom, hasLength(1)); + expect(dormantForms, hasLength(1)); }); test('singleTextField Autofill setEditableSizeAndTransform preserves' @@ -1613,7 +1613,7 @@ Future testMain() async { expect(spy.messages, isEmpty); // Form stays on the DOM until autofill context is finalized. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); - expect(formsOnTheDom, hasLength(1)); + expect(dormantForms, hasLength(1)); }); test('multiTextField Autofill: setClient, setEditingState, show, ' @@ -1667,7 +1667,7 @@ Future testMain() async { expect(spy.messages, isEmpty); // Form stays on the DOM until autofill context is finalized. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); - expect(formsOnTheDom, hasLength(1)); + expect(dormantForms, hasLength(1)); }); test('No capitalization: setClient, setEditingState, show', () { @@ -2846,7 +2846,7 @@ Future testMain() async { sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); final DomElement input = textEditing!.strategy.domElement!; - final DomElement form = textEditing!.configuration!.autofillGroup!.formElement; + final DomElement form = textEditing!.configuration!.autofillGroup!.formElement!; // Input and form are appended to the right view. expect(view.dom.textEditingHost.contains(input), isTrue); @@ -2886,7 +2886,7 @@ Future testMain() async { sendFrameworkMessage(codec.encodeMethodCall(show)); final DomElement input = textEditing!.strategy.domElement!; - final DomElement form = textEditing!.configuration!.autofillGroup!.formElement; + final DomElement form = textEditing!.configuration!.autofillGroup!.formElement!; // Input and form are appended to view1. expect(view1.dom.textEditingHost.contains(input), isTrue); @@ -3009,13 +3009,14 @@ Future testMain() async { // Number of elements if number of fields sent to the constructor minus // one (for the focused text element). - expect(autofillForm.elements, hasLength(2)); - expect(autofillForm.items, hasLength(2)); + expect(autofillForm.elements, hasLength(3)); + expect(autofillForm.items, hasLength(3)); expect(autofillForm.formElement, isNotNull); expect(autofillForm.formIdentifier, 'field1*field2*field3'); - final DomHTMLFormElement form = autofillForm.formElement; + // TODO(mdebbar): Things are different now. DOM elements aren't created immediately like they used to. + final DomHTMLFormElement form = autofillForm.formElement!; // Note that we also add a submit button. Therefore the form element has // 3 child nodes. expect(form.childNodes, hasLength(3)); @@ -3074,27 +3075,30 @@ Future testMain() async { ['username', 'password', 'newPassword'], ['field1', 'fields2', 'field3'], ); + final Map focusedAutofillMap = createAutofillInfo('username', 'field1'); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( kImplicitViewId, - createAutofillInfo('username', 'field1'), + focusedAutofillMap, fields, )!; final DomHTMLInputElement testInputElement = createDomHTMLInputElement(); - autofillForm.placeForm(testInputElement); + final focusedAutofill = AutofillInfo.fromFrameworkMessage(focusedAutofillMap); + autofillForm.wakeUp(testInputElement, focusedAutofill); // The focused element is appended to the form, form also has the button // so in total it shoould have 4 elements. - final DomHTMLFormElement form = autofillForm.formElement; + final DomHTMLFormElement form = autofillForm.formElement!; expect(form.childNodes, hasLength(4)); final formOnDom = defaultTextEditingRoot.querySelector('form')! as DomHTMLFormElement; // Form is attached to the DOM. expect(form, equals(formOnDom)); - autofillForm.storeForm(); + autofillForm.goDormant(); expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); - expect(formsOnTheDom, hasLength(1)); + expect(dormantForms, hasLength(1)); + expect(dormantForms, {autofillForm.formIdentifier: autofillForm}); }); test('Validate single element form', () { @@ -3105,16 +3109,20 @@ Future testMain() async { fields, )!; - // The focused element is the only field. Form should be empty after - // the initialization (focus element is appended later). - expect(autofillForm.elements, isEmpty); - expect(autofillForm.items, isEmpty); + // The focused element is the only field. Form should have a single element after + // the initialization. + expect(autofillForm.elements, hasLength(1)); + expect(autofillForm.elements, contains('field1')); + expect(autofillForm.items, hasLength(1)); + expect(autofillForm.items, contains('field1')); + expect(autofillForm.items['field1']!.autofillInfo.autofillHint, 'username'); expect(autofillForm.formElement, isNotNull); - final DomHTMLFormElement form = autofillForm.formElement; + // TODO(mdebbar): Things are different now. DOM elements aren't created immediately like they used to. + final DomHTMLFormElement form = autofillForm.formElement!; // Submit button is added to the form. expect(form.childNodes, isNotEmpty); - final inputElement = form.childNodes.toList()[0] as DomHTMLInputElement; + final inputElement = form.childNodes.toList().last as DomHTMLInputElement; expect(inputElement.type, 'submit'); expect(inputElement.tabIndex, -1, reason: 'The input should not be reachable by keyboard'); @@ -3134,37 +3142,45 @@ Future testMain() async { }); test('placeForm() should place element in correct position', () { + final Map focusedAutofillMap = createAutofillInfo('email', 'field1'); final List fields = createFieldValues( ['email', 'username', 'password'], ['field1', 'field2', 'field3'], ); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( kImplicitViewId, - createAutofillInfo('email', 'field1'), + focusedAutofillMap, fields, )!; expect(autofillForm.elements, hasLength(2)); + // TODO(mdebbar): Things are different now. DOM elements aren't created immediately like they used to. var formChildNodes = - autofillForm.formElement.childNodes.toList() as List; + autofillForm.formElement!.childNodes.toList() as List; // Only username, password, submit nodes are created expect(formChildNodes, hasLength(3)); expect(formChildNodes[0].name, 'username'); expect(formChildNodes[1].name, 'current-password'); expect(formChildNodes[2].type, 'submit'); - // insertion point for email should be before username - expect(autofillForm.insertionReferenceNode, formChildNodes[0]); final DomHTMLInputElement testInputElement = createDomHTMLInputElement(); testInputElement.name = 'email'; - autofillForm.placeForm(testInputElement); + final focusedAutofill = AutofillInfo.fromFrameworkMessage(focusedAutofillMap); + autofillForm.wakeUp(testInputElement, focusedAutofill); + + expect(autofillForm.elements['field1'], testInputElement); + expect(autofillForm.items['field1'], focusedAutofill); + + formChildNodes = autofillForm.formElement!.childNodes.toList() as List; - formChildNodes = autofillForm.formElement.childNodes.toList() as List; // email node should be placed before username expect(formChildNodes, hasLength(4)); + expect(formChildNodes[0].name, 'email'); + expect(formChildNodes[0], testInputElement); + expect(formChildNodes[1].name, 'username'); expect(formChildNodes[2].name, 'current-password'); expect(formChildNodes[3].type, 'submit'); @@ -3182,8 +3198,9 @@ Future testMain() async { createAutofillInfo('email', 'field1'), fields, )!; + // TODO(mdebbar): Things are different now. DOM elements aren't created immediately like they used to. final formChildNodes = - autofillForm.formElement.childNodes.toList() as List; + autofillForm.formElement!.childNodes.toList() as List; final DomHTMLInputElement username = formChildNodes[0]; final DomHTMLInputElement password = formChildNodes[1]; @@ -3195,7 +3212,7 @@ Future testMain() async { expect(password.style.width, '0px'); expect(password.style.height, '0px'); expect(password.style.pointerEvents, isNot('none')); - expect(autofillForm.formElement.style.pointerEvents, isNot('none')); + expect(autofillForm.formElement!.style.pointerEvents, isNot('none')); }, skip: isSafari, ); @@ -3210,8 +3227,9 @@ Future testMain() async { createAutofillInfo('email', 'field1'), fields, )!; + // TODO(mdebbar): Things are different now. DOM elements aren't created immediately like they used to. final formChildNodes = - autofillForm.formElement.childNodes.toList() as List; + autofillForm.formElement!.childNodes.toList() as List; final DomHTMLInputElement username = formChildNodes[0]; final DomHTMLInputElement password = formChildNodes[1]; expect(username.name, 'username'); @@ -3222,28 +3240,30 @@ Future testMain() async { expect(password.style.width, isNot('0px')); expect(password.style.height, isNot('0px')); expect(password.style.pointerEvents, 'none'); - expect(autofillForm.formElement.style.pointerEvents, 'none'); + expect(autofillForm.formElement!.style.pointerEvents, 'none'); }, skip: !isSafari); test( 'the focused element within a form should explicitly set pointer events on Safari', () { + final Map focusedAutofillMap = createAutofillInfo('email', 'field1'); final List fields = createFieldValues( ['email', 'username', 'password'], ['field1', 'field2', 'field3'], ); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( kImplicitViewId, - createAutofillInfo('email', 'field1'), + focusedAutofillMap, fields, )!; final DomHTMLInputElement testInputElement = createDomHTMLInputElement(); testInputElement.name = 'email'; - autofillForm.placeForm(testInputElement); + final focusedAutofill = AutofillInfo.fromFrameworkMessage(focusedAutofillMap); + autofillForm.wakeUp(testInputElement, focusedAutofill); final formChildNodes = - autofillForm.formElement.childNodes.toList() as List; + autofillForm.formElement!.childNodes.toList() as List; final DomHTMLInputElement email = formChildNodes[0]; final DomHTMLInputElement username = formChildNodes[1]; final DomHTMLInputElement password = formChildNodes[2]; @@ -3253,7 +3273,7 @@ Future testMain() async { expect(password.name, 'current-password'); // pointer events are none on the form and all non-focused elements - expect(autofillForm.formElement.style.pointerEvents, 'none'); + expect(autofillForm.formElement!.style.pointerEvents, 'none'); expect(username.style.pointerEvents, 'none'); expect(password.style.pointerEvents, 'none'); @@ -4026,7 +4046,7 @@ void clearForms() { while (defaultTextEditingRoot.querySelectorAll('form').isNotEmpty) { defaultTextEditingRoot.querySelectorAll('form').last.remove(); } - formsOnTheDom.clear(); + dormantForms.clear(); } /// Waits until the text strategy closes and moves the focus accordingly. diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index cf77b4c6dfb..a926a73c9c9 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1369,6 +1369,9 @@ mixin TextInputClient { /// This method will only be called on iOS. void showAutocorrectionPromptRect(int start, int end); + /// Requests that this client refocus the text input control. + void refocus(); + /// Platform notified framework of closed connection. /// /// [TextInputClient] should cleanup its connection and finalize editing. @@ -2050,6 +2053,7 @@ class TextInput { assert(_debugEnsureInputActionWorksOnPlatform(configuration.inputAction)); _currentConnection = connection; _currentConfiguration = configuration; + _lastConnection = connection; _setClient(connection._client, configuration); } @@ -2079,6 +2083,7 @@ class TextInput { TextInputConnection? _currentConnection; late TextInputConfiguration _currentConfiguration; + TextInputConnection? _lastConnection; final Map _scribbleClients = {}; bool _scribbleInProgress = false; @@ -2150,6 +2155,15 @@ class TextInput { case 'TextInputClient.scribbleInteractionFinished': _scribbleInProgress = false; return; + case 'TextInput.refocus': + final args = methodCall.arguments as List; + final clientId = args[0] as int; + if (_currentConnection == null && + _lastConnection != null && + _lastConnection!._id == clientId) { + _lastConnection!._client.refocus(); + } + return; } if (_currentConnection == null) { return; diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 6a9849c0215..2a76fd9204c 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4063,6 +4063,13 @@ class EditableTextState extends State } } + @override + void refocus() { + if (mounted && !_hasFocus) { + widget.focusNode.requestFocus(); + } + } + @override void connectionClosed() { if (_hasInputConnection) {