diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index 622cf3f2a2dd1..b11e9e97a7458 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -658,16 +658,7 @@ extension DomElementExtension on DomElement { external JSNumber? get _tabIndex; double? get tabIndex => _tabIndex?.toDartDouble; - @JS('focus') - external JSVoid _focus(JSAny options); - - void focus({bool? preventScroll, bool? focusVisible}) { - final Map options = { - if (preventScroll != null) 'preventScroll': preventScroll, - if (focusVisible != null) 'focusVisible': focusVisible, - }; - _focus(options.toJSAnyDeep); - } + external JSVoid focus(); @JS('scrollTop') external JSNumber get _scrollTop; @@ -2258,11 +2249,9 @@ extension DomKeyboardEventExtension on DomKeyboardEvent { external JSBoolean? get _repeat; bool? get repeat => _repeat?.toDart; - // Safari injects synthetic keyboard events after auto-complete that don't - // have a `shiftKey` attribute, so this property must be nullable. @JS('shiftKey') - external JSBoolean? get _shiftKey; - bool? get shiftKey => _shiftKey?.toDart; + external JSBoolean get _shiftKey; + bool get shiftKey => _shiftKey.toDart; @JS('isComposing') external JSBoolean get _isComposing; diff --git a/lib/web_ui/lib/src/engine/keyboard_binding.dart b/lib/web_ui/lib/src/engine/keyboard_binding.dart index f70456e42239c..85bea97039e0a 100644 --- a/lib/web_ui/lib/src/engine/keyboard_binding.dart +++ b/lib/web_ui/lib/src/engine/keyboard_binding.dart @@ -207,7 +207,7 @@ class FlutterHtmlKeyboardEvent { num? get timeStamp => _event.timeStamp; bool get altKey => _event.altKey; bool get ctrlKey => _event.ctrlKey; - bool get shiftKey => _event.shiftKey ?? false; + bool get shiftKey => _event.shiftKey; bool get metaKey => _event.metaKey; bool get isComposing => _event.isComposing; diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart b/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart index b3344099f7bd4..3a10c4ab723c9 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart @@ -16,7 +16,7 @@ final class ViewFocusBinding { /// /// DO NOT rely on this bit as it will go away soon. You're warned :)! @visibleForTesting - static bool isEnabled = true; + static bool isEnabled = false; final FlutterViewManager _viewManager; final ui.ViewFocusChangeCallback _onViewFocusChange; @@ -51,7 +51,7 @@ final class ViewFocusBinding { if (state == ui.ViewFocusState.focused) { // Only move the focus to the flutter view if nothing inside it is focused already. if (viewId != _viewId(domDocument.activeElement)) { - viewElement?.focus(preventScroll: true); + viewElement?.focus(); } } else { viewElement?.blur(); @@ -70,7 +70,7 @@ final class ViewFocusBinding { late final DomEventListener _handleKeyDown = createDomEventListener((DomEvent event) { event as DomKeyboardEvent; - if (event.shiftKey ?? false) { + if (event.shiftKey) { _viewFocusDirection = ui.ViewFocusDirection.backward; } }); diff --git a/lib/web_ui/lib/src/engine/pointer_binding.dart b/lib/web_ui/lib/src/engine/pointer_binding.dart index 741761c434515..f0b2b75c8e521 100644 --- a/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -982,22 +982,6 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { ); _convertEventsToPointerData(data: pointerData, event: event, details: down); _callback(event, pointerData); - - if (event.target == _viewTarget) { - // Ensure smooth focus transitions between text fields within the Flutter view. - // Without preventing the default and this delay, the engine may not have fully - // rendered the next input element, leading to the focus incorrectly returning to - // the main Flutter view instead. - // A zero-length timer is sufficient in all tested browsers to achieve this. - event.preventDefault(); - Timer(Duration.zero, () { - EnginePlatformDispatcher.instance.requestViewFocusChange( - viewId: _view.viewId, - state: ui.ViewFocusState.focused, - direction: ui.ViewFocusDirection.undefined, - ); - }); - } }); // Why `domWindow` you ask? See this fiddle: https://jsfiddle.net/ditman/7towxaqp diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index f1c45b4b8fc58..0a7a8de1c5514 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -332,8 +332,7 @@ class EngineAutofillForm { // 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. - final DomHTMLInputElement submitButton = createDomHTMLInputElement()..tabIndex = -1; + final DomHTMLInputElement submitButton = createDomHTMLInputElement(); _styleAutofillElements(submitButton, isOffScreen: true); submitButton.className = 'submitBtn'; submitButton.type = 'submit'; @@ -1131,8 +1130,8 @@ class GloballyPositionedTextEditingStrategy extends DefaultTextEditingStrategy { // only after placing it to the correct position. Hence autofill menu // does not appear on top-left of the page. // Refocus on the elements after applying the geometry. - focusedFormElement!.focus(preventScroll: true); - moveFocusToActiveDomElement(); + focusedFormElement!.focus(); + activeDomElement.focus(); } } } @@ -1158,20 +1157,42 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy { /// /// This method is similar to the [GloballyPositionedTextEditingStrategy]. /// The only part different: this method does not call `super.placeElement()`, - /// which in current state calls `domElement.focus(preventScroll: true)`. + /// which in current state calls `domElement.focus()`. /// /// Making an extra `focus` request causes flickering in Safari. @override void placeElement() { geometry?.applyToDomElement(activeDomElement); if (hasAutofillGroup) { - placeForm(); - // Set the last editing state if it exists, this is critical for a - // users ongoing work to continue uninterrupted when there is an update to - // the transform. - // If domElement is not focused cursor location will not be correct. - moveFocusToActiveDomElement(); - lastEditingState?.applyToDomElement(activeDomElement); + // We listen to pointerdown events on the Flutter View element and programatically + // focus our inputs. However, these inputs are focused before the pointerdown + // events conclude. Thus, the browser triggers a blur event immediately after + // focusing these inputs. This causes issues with Safari Desktop's autofill + // dialog (ref: https://github.com/flutter/flutter/issues/127960). + // In order to guarantee that we only focus after the pointerdown event concludes, + // we wrap the form autofill placement and focus logic in a zero-duration Timer. + // This ensures that our input doesn't have instantaneous focus/blur events + // occur on it and fixes the autofill dialog bug as a result. + Timer(Duration.zero, () { + placeForm(); + // On Safari Desktop, when a form is focused, it opens an autofill menu + // immediately. + // Flutter framework sends `setEditableSizeAndTransform` for informing + // the engine about the location of the text field. This call may arrive + // after the first `show` call, depending on the text input widget's + // implementation. Therefore form is placed, when + // `setEditableSizeAndTransform` method is called and focus called on the + // form only after placing it to the correct position and only once after + // that. Calling focus multiple times causes flickering. + focusedFormElement!.focus(); + + // Set the last editing state if it exists, this is critical for a + // users ongoing work to continue uninterrupted when there is an update to + // the transform. + // If domElement is not focused cursor location will not be correct. + activeDomElement.focus(); + lastEditingState?.applyToDomElement(activeDomElement); + }); } } @@ -1180,7 +1201,7 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy { if (geometry != null) { placeElement(); } - moveFocusToActiveDomElement(); + activeDomElement.focus(); } } @@ -1227,12 +1248,6 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements return domElement!; } - /// The [FlutterView] in which [activeDomElement] is contained. - EngineFlutterView? get _activeDomElementView => _viewForElement(activeDomElement); - - EngineFlutterView? _viewForElement(DomElement element) => - EnginePlatformDispatcher.instance.viewManager.findViewForElement(element); - late InputConfiguration inputConfiguration; EditingState? lastEditingState; @@ -1270,8 +1285,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements }) { assert(!isEnabled); - // The -1 tab index value makes this element not reachable by keyboard. - domElement = inputConfig.inputType.createDomElement()..tabIndex = -1; + domElement = inputConfig.inputType.createDomElement(); applyConfiguration(inputConfig); _setStaticStyleAttributes(activeDomElement); @@ -1349,17 +1363,16 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements subscriptions.add(DomSubscription(domDocument, 'selectionchange', handleChange)); - subscriptions.add(DomSubscription(activeDomElement, 'beforeinput', - handleBeforeInput)); - - if (this is! SafariDesktopTextEditingStrategy) { - // handleBlur causes Safari to reopen autofill dialogs after autofill, - // so we don't attach the listener there. - subscriptions.add(DomSubscription(activeDomElement, 'blur', handleBlur)); - } + activeDomElement.addEventListener('beforeinput', + createDomEventListener(handleBeforeInput)); addCompositionEventHandlers(activeDomElement); + // Refocus on the activeDomElement after blur, so that user can keep editing the + // text field. + subscriptions.add(DomSubscription(activeDomElement, 'blur', + (_) { activeDomElement.focus(); })); + preventDefaultForMouseEvents(); } @@ -1409,12 +1422,13 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements // More details on `TextInput.finishAutofillContext` call. if (_appendedToForm && inputConfiguration.autofillGroup?.formElement != null) { + // Subscriptions are removed, listeners won't be triggered. + activeDomElement.blur(); _styleAutofillElements(activeDomElement, isOffScreen: true); inputConfiguration.autofillGroup?.storeForm(); - _moveFocusToFlutterView(activeDomElement, _activeDomElementView); } else { - _moveFocusToFlutterView(activeDomElement, _activeDomElementView, removeElement: true); - } + activeDomElement.remove(); + } domElement = null; } @@ -1428,7 +1442,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements } void placeElement() { - moveFocusToActiveDomElement(); + activeDomElement.focus(); } void placeForm() { @@ -1494,15 +1508,6 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements } } - void handleBlur(DomEvent event) { - event as DomFocusEvent; - - final DomElement? willGainFocusElement = event.relatedTarget as DomElement?; - if (willGainFocusElement == null || _viewForElement(willGainFocusElement) == _activeDomElementView) { - moveFocusToActiveDomElement(); - } - } - void maybeSendAction(DomEvent e) { if (domInstanceOfString(e, 'KeyboardEvent')) { final DomKeyboardEvent event = e as DomKeyboardEvent; @@ -1541,7 +1546,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements } // Re-focuses after setting editing state. - moveFocusToActiveDomElement(); + activeDomElement.focus(); } /// Prevent default behavior for mouse down, up and move. @@ -1568,31 +1573,6 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements event.preventDefault(); })); } - - /// Moves the focus to the [activeDomElement]. - void moveFocusToActiveDomElement() { - activeDomElement.focus(preventScroll: true); - } - - /// Moves the focus to the [EngineFlutterView]. - /// - /// The delay gives the engine the opportunity to focus another element. - /// The delay should help prevent the keyboard from jumping when the focus goes from - /// one text field to another. - static void _moveFocusToFlutterView( - DomElement element, - EngineFlutterView? view, { - bool removeElement = false, - }) { - Timer(Duration.zero, () { - if (element == domDocument.activeElement) { - view?.dom.rootElement.focus(preventScroll: true); - } - if (removeElement) { - element.remove(); - } - }); - } } /// IOS/Safari behaviour for text editing. @@ -1626,6 +1606,17 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { Timer? _positionInputElementTimer; static const Duration _delayBeforePlacement = Duration(milliseconds: 100); + /// This interval between the blur subscription and callback is considered to + /// be fast. + /// + /// This is only used for iOS. The blur callback may trigger as soon as the + /// creation of the subscription. Occasionally in this case, the virtual + /// keyboard will quickly show and hide again. + /// + /// Less than this interval allows the virtual keyboard to keep showing up + /// instead of hiding rapidly. + static const Duration _blurFastCallbackInterval = Duration(milliseconds: 200); + /// Whether or not the input element can be positioned at this point in time. /// /// This is currently only used in iOS. It's set to false before focusing the @@ -1681,11 +1672,8 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { subscriptions.add(DomSubscription(domDocument, 'selectionchange', handleChange)); - subscriptions.add(DomSubscription(activeDomElement, 'beforeinput', - handleBeforeInput)); - - subscriptions.add(DomSubscription(activeDomElement, 'blur', - handleBlur)); + activeDomElement.addEventListener('beforeinput', + createDomEventListener(handleBeforeInput)); addCompositionEventHandlers(activeDomElement); @@ -1697,6 +1685,35 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { })); _addTapListener(); + + // Record start time of blur subscription. + final Stopwatch blurWatch = Stopwatch()..start(); + + // On iOS, blur is trigerred in the following cases: + // + // 1. The browser app is sent to the background (or the tab is changed). In + // this case, the window loses focus (see [windowHasFocus]), + // so we close the input connection with the framework. + // 2. The user taps on another focusable element. In this case, we refocus + // the input field and wait for the framework to manage the focus change. + // 3. The virtual keyboard is closed by tapping "done". We can't detect this + // programmatically, so we end up refocusing the input field. This is + // okay because the virtual keyboard will hide, and as soon as the user + // taps the text field again, the virtual keyboard will come up. + // 4. Safari sometimes sends a blur event immediately after activating the + // input field. In this case, we want to keep the focus on the input field. + // In order to detect this, we measure how much time has passed since the + // input field was activated. If the time is too short, we re-focus the + // input element. + subscriptions.add(DomSubscription(activeDomElement, 'blur', + (_) { + final bool isFastCallback = blurWatch.elapsed < _blurFastCallbackInterval; + if (windowHasFocus && isFastCallback) { + activeDomElement.focus(); + } else { + owner.sendTextConnectionClosedToFrameworkIfAny(); + } + })); } @override @@ -1756,7 +1773,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { @override void placeElement() { - moveFocusToActiveDomElement(); + activeDomElement.focus(); geometry?.applyToDomElement(activeDomElement); } } @@ -1808,20 +1825,31 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy { DomSubscription(domDocument, 'selectionchange', handleChange)); - subscriptions.add(DomSubscription(activeDomElement, 'beforeinput', - handleBeforeInput)); - - subscriptions.add(DomSubscription(activeDomElement, 'blur', - handleBlur)); + activeDomElement.addEventListener('beforeinput', + createDomEventListener(handleBeforeInput)); addCompositionEventHandlers(activeDomElement); + subscriptions.add( + DomSubscription(activeDomElement, 'blur', + (_) { + if (windowHasFocus) { + // Chrome on Android will hide the onscreen keyboard when you tap outside + // the text box. Instead, we want the framework to tell us to hide the + // keyboard via `TextInput.clearClient` or `TextInput.hide`. Therefore + // refocus as long as [windowHasFocus] is true. + activeDomElement.focus(); + } else { + owner.sendTextConnectionClosedToFrameworkIfAny(); + } + })); + preventDefaultForMouseEvents(); } @override void placeElement() { - moveFocusToActiveDomElement(); + activeDomElement.focus(); geometry?.applyToDomElement(activeDomElement); } } @@ -1861,9 +1889,8 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy { DomSubscription( activeDomElement, 'keydown', maybeSendAction)); - subscriptions.add( - DomSubscription( - activeDomElement, 'beforeinput', handleBeforeInput)); + activeDomElement.addEventListener('beforeinput', + createDomEventListener(handleBeforeInput)); addCompositionEventHandlers(activeDomElement); @@ -1895,15 +1922,32 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy { DomSubscription( activeDomElement, 'select', handleChange)); - subscriptions.add(DomSubscription(activeDomElement, 'blur', - handleBlur)); + // Refocus on the activeDomElement after blur, so that user can keep editing the + // text field. + subscriptions.add( + DomSubscription( + activeDomElement, + 'blur', + (_) { + _postponeFocus(); + })); preventDefaultForMouseEvents(); } + void _postponeFocus() { + // Firefox does not focus on the editing element if we call the focus + // inside the blur event, therefore we postpone the focus. + // Calling focus inside a Timer for `0` milliseconds guarantee that it is + // called after blur event propagation is completed. + Timer(Duration.zero, () { + activeDomElement.focus(); + }); + } + @override void placeElement() { - moveFocusToActiveDomElement(); + activeDomElement.focus(); geometry?.applyToDomElement(activeDomElement); // Set the last editing state if it exists, this is critical for a // users ongoing work to continue uninterrupted when there is an update to diff --git a/lib/web_ui/test/engine/text_editing_test.dart b/lib/web_ui/test/engine/text_editing_test.dart index bea5f02bf0d66..eed507d8e786d 100644 --- a/lib/web_ui/test/engine/text_editing_test.dart +++ b/lib/web_ui/test/engine/text_editing_test.dart @@ -25,9 +25,6 @@ EnginePlatformDispatcher get dispatcher => EnginePlatformDispatcher.instance; DomElement get defaultTextEditingRoot => dispatcher.implicitView!.dom.textEditingHost; -DomElement get implicitViewRootElement => - dispatcher.implicitView!.dom.rootElement; - /// Add unit tests for [FirefoxTextEditingStrategy]. // TODO(mdebbar): https://github.com/flutter/flutter/issues/46891 @@ -70,18 +67,13 @@ Future testMain() async { setUpTestViewDimensions: false ); - setUp(() { - domDocument.activeElement?.blur(); - }); - - tearDown(() async { + tearDown(() { lastEditingState = null; editingDeltaState = null; lastInputAction = null; cleanTextEditingStrategy(); cleanTestFlags(); clearBackUpDomElementIfExists(); - await waitForTextStrategyStopPropagation(); }); group('$GloballyPositionedTextEditingStrategy', () { @@ -94,16 +86,13 @@ Future testMain() async { testTextEditing.configuration = singlelineConfig; }); - test('Creates element when enabled and removes it when disabled', () async { + test('Creates element when enabled and removes it when disabled', () { expect( domDocument.getElementsByTagName('input'), hasLength(0), ); - expect( - domDocument.activeElement, - domDocument.body, - reason: 'The focus should initially be on the body', - ); + // The focus initially is on the body. + expect(domDocument.activeElement, domDocument.body); expect(defaultTextEditingRoot.ownerDocument?.activeElement, domDocument.body); @@ -125,24 +114,22 @@ Future testMain() async { expect(editingStrategy!.domElement, input); expect(input.getAttribute('type'), null); - expect(input.tabIndex, -1, reason: 'The input should not be reachable by keyboard'); // Input is appended to the right point of the DOM. expect(defaultTextEditingRoot.contains(editingStrategy!.domElement), isTrue); editingStrategy!.disable(); - await waitForTextStrategyStopPropagation(); expect( defaultTextEditingRoot.querySelectorAll('input'), hasLength(0), ); - // The focus is back to the flutter view. - expect(domDocument.activeElement, implicitViewRootElement); + // The focus is back to the body. + expect(domDocument.activeElement, domDocument.body); expect(defaultTextEditingRoot.ownerDocument?.activeElement, - implicitViewRootElement); + domDocument.body); }); - test('inserts element in the correct view', () async { + test('inserts element in the correct view', () { final DomElement host = createDomElement('div'); domDocument.body!.append(host); final EngineFlutterView view = EngineFlutterView(dispatcher, host); @@ -165,7 +152,6 @@ Future testMain() async { // Cleanup. editingStrategy!.disable(); - await waitForTextStrategyStopPropagation(); expect(textEditingHost.querySelectorAll('input'), hasLength(0)); dispatcher.viewManager.unregisterView(view.viewId); view.dispose(); @@ -319,7 +305,7 @@ Future testMain() async { expect(lastInputAction, isNull); }); - test('Multi-line mode also works', () async { + test('Multi-line mode also works', () { // The textarea element is created lazily. expect(domDocument.getElementsByTagName('textarea'), hasLength(0)); editingStrategy!.enable( @@ -351,23 +337,17 @@ Future testMain() async { checkTextAreaEditingState(textarea, 'bar\nbaz', 2, 7); editingStrategy!.disable(); - - await waitForTextStrategyStopPropagation(); - // The textarea should be cleaned up. expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(0)); - - expect( - defaultTextEditingRoot.ownerDocument?.activeElement, - implicitViewRootElement, - reason: 'The focus should be back to the body', - ); + // The focus is back to the body. + expect(defaultTextEditingRoot.ownerDocument?.activeElement, + domDocument.body); // There should be no input action. expect(lastInputAction, isNull); }); - test('Same instance can be re-enabled with different config', () async { + test('Same instance can be re-enabled with different config', () { // Make sure there's nothing in the DOM yet. expect(domDocument.getElementsByTagName('input'), hasLength(0)); expect(domDocument.getElementsByTagName('textarea'), hasLength(0)); @@ -383,7 +363,6 @@ Future testMain() async { // Disable and check that all DOM elements were removed. editingStrategy!.disable(); - await waitForTextStrategyStopPropagation(); expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(0)); expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(0)); @@ -393,13 +372,11 @@ Future testMain() async { onChange: trackEditingState, onAction: trackInputAction, ); - await waitForTextStrategyStopPropagation(); expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(0)); expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(1)); // Disable again and check that all DOM elements were removed. editingStrategy!.disable(); - await waitForTextStrategyStopPropagation(); expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(0)); expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(0)); @@ -758,6 +735,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState(textEditing!.strategy.domElement, '', 0, 0); const MethodCall setEditingState = @@ -774,13 +753,8 @@ Future testMain() async { const MethodCall hide = MethodCall('TextInput.hide'); sendFrameworkMessage(codec.encodeMethodCall(hide)); - await waitForTextStrategyStopPropagation(); - - expect( - domDocument.activeElement, - implicitViewRootElement, - reason: 'Text editing should have stopped', - ); + // Text editing should've stopped. + expect(domDocument.activeElement, domDocument.body); // Confirm that [HybridTextEditing] didn't send any messages. expect(spy.messages, isEmpty); @@ -799,11 +773,8 @@ Future testMain() async { }); sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); - expect( - domDocument.activeElement, - domDocument.body, - reason: 'Editing should not have started yet', - ); + // Editing shouldn't have started yet. + expect(domDocument.activeElement, domDocument.body); const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); @@ -817,19 +788,15 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); const MethodCall clearClient = MethodCall('TextInput.clearClient'); sendFrameworkMessage(codec.encodeMethodCall(clearClient)); - await waitForTextStrategyStopPropagation(); - - expect( - domDocument.activeElement, - implicitViewRootElement, - reason: 'Text editing should have stopped', - ); + expect(domDocument.activeElement, domDocument.body); // Confirm that [HybridTextEditing] didn't send any messages. expect(spy.messages, isEmpty); @@ -857,6 +824,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + const MethodCall setEditingState = MethodCall('TextInput.setEditingState', { 'text': 'abcd', @@ -924,11 +893,9 @@ Future testMain() async { }); sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); - expect( - defaultTextEditingRoot.ownerDocument?.activeElement, - domDocument.body, - reason: 'Editing should not have started yet', - ); + // Editing shouldn't have started yet. + expect(defaultTextEditingRoot.ownerDocument?.activeElement, + domDocument.body); const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); @@ -942,14 +909,18 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); expect(textEditing!.isEditing, isTrue); + // DOM element is blurred. + textEditing!.strategy.domElement!.blur(); + // No connection close message sent. expect(spy.messages, hasLength(0)); await Future.delayed(Duration.zero); - // DOM element still keeps the focus. expect(defaultTextEditingRoot.ownerDocument?.activeElement, textEditing!.strategy.domElement); @@ -1036,6 +1007,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1092,6 +1065,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Form is added to DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); @@ -1146,6 +1121,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Form is added to DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); final DomHTMLFormElement formElement = @@ -1199,6 +1176,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Form is added to DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); final DomHTMLFormElement formElement = @@ -1225,57 +1204,50 @@ Future testMain() async { expect(formsOnTheDom, hasLength(0)); }); - test('Moves the focus across input elements', () async { - final List focusinEvents = []; - final DomEventListener handleFocusIn = createDomEventListener(focusinEvents.add); + test('form is not placed and input is not focused until after tick on Desktop Safari', () async { + // 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)); - final MethodCall setClient1 = MethodCall( - 'TextInput.setClient', - [123, flutterSinglelineConfig], - ); - final MethodCall setClient2 = MethodCall( - 'TextInput.setClient', - [567, flutterSinglelineConfig], - ); - const MethodCall setEditingState = + const MethodCall setEditingState1 = MethodCall('TextInput.setEditingState', { 'text': 'abcd', 'selectionBase': 2, 'selectionExtent': 3, }); - final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall( - 150, - 50, - Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList(), - ); - const MethodCall show = MethodCall('TextInput.show'); - const MethodCall clearClient = MethodCall('TextInput.clearClient'); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); - domDocument.body!.addEventListener('focusin', handleFocusIn); - sendFrameworkMessage(codec.encodeMethodCall(setClient1)); - sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); - sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); - final DomElement firstInput = textEditing!.strategy.domElement!; - expect(domDocument.activeElement, firstInput); - sendFrameworkMessage(codec.encodeMethodCall(setClient2)); - sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); - sendFrameworkMessage(codec.encodeMethodCall(show)); - final DomElement secondInput = textEditing!.strategy.domElement!; - expect(domDocument.activeElement, secondInput); - expect(firstInput, isNot(secondInput)); - sendFrameworkMessage(codec.encodeMethodCall(clearClient)); - await waitForTextStrategyStopPropagation(); - domDocument.body!.removeEventListener('focusin', handleFocusIn); + // Prior to tick, form should not exist and no elements should be focused. + expect(defaultTextEditingRoot.querySelectorAll('form'), isEmpty); + expect(domDocument.activeElement, domDocument.body); - expect(focusinEvents, hasLength(3)); - expect(focusinEvents[0].target, firstInput); - expect(focusinEvents[1].target, secondInput); - expect(focusinEvents[2].target, implicitViewRootElement); - }); + await waitForDesktopSafariFocus(); + + // Form is added to DOM. + expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); + + final DomHTMLInputElement inputElement = + textEditing!.strategy.domElement! as DomHTMLInputElement; + expect(domDocument.activeElement, inputElement); + }, skip: !isSafari); test('setClient, setEditingState, show, setClient', () async { final MethodCall setClient = MethodCall( @@ -1290,11 +1262,8 @@ Future testMain() async { }); sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); - expect( - domDocument.activeElement, - domDocument.body, - reason: 'Editing should not have started yet.', - ); + // Editing shouldn't have started yet. + expect(domDocument.activeElement, domDocument.body); const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); @@ -1308,6 +1277,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1315,13 +1286,9 @@ Future testMain() async { 'TextInput.setClient', [567, flutterSinglelineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient2)); - await waitForTextStrategyStopPropagation(); - - expect( - domDocument.activeElement, - implicitViewRootElement, - reason: 'Receiving another client via setClient should stop editing, hence should remove the previous active element.', - ); + // Receiving another client via setClient should stop editing, hence + // should remove the previous active element. + expect(domDocument.activeElement, domDocument.body); // Confirm that [HybridTextEditing] didn't send any messages. expect(spy.messages, isEmpty); @@ -1362,6 +1329,7 @@ Future testMain() async { }); sendFrameworkMessage(codec.encodeMethodCall(setEditingState2)); + await waitForDesktopSafariFocus(); // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'xyz', 0, 2); @@ -1403,6 +1371,7 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1473,6 +1442,7 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(updateSizeAndTransform)); + await waitForDesktopSafariFocus(); // Check the element still has focus. User can keep editing. expect(defaultTextEditingRoot.ownerDocument?.activeElement, textEditing!.strategy.domElement); @@ -1528,6 +1498,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1838,6 +1810,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Check if the selection range is correct. checkInputEditingState( textEditing!.strategy.domElement, 'xyz', 1, 2); @@ -2011,6 +1985,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + final DomHTMLInputElement input = textEditing!.strategy.domElement! as DomHTMLInputElement; @@ -2084,6 +2060,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + final DomHTMLInputElement input = textEditing!.strategy.domElement! as DomHTMLInputElement; @@ -2168,6 +2146,7 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -2219,11 +2198,9 @@ Future testMain() async { 'TextInput.setClient', [123, flutterMultilineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); - expect( - defaultTextEditingRoot.ownerDocument?.activeElement, - domDocument.body, - reason: 'Editing should have not started yet', - ); + // Editing shouldn't have started yet. + expect(defaultTextEditingRoot.ownerDocument?.activeElement, + domDocument.body); const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); @@ -2237,6 +2214,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + final DomHTMLTextAreaElement textarea = textEditing!.strategy.domElement! as DomHTMLTextAreaElement; checkTextAreaEditingState(textarea, '', 0, 0); @@ -2304,13 +2283,8 @@ Future testMain() async { const MethodCall hide = MethodCall('TextInput.hide'); sendFrameworkMessage(codec.encodeMethodCall(hide)); - await waitForTextStrategyStopPropagation(); - - expect( - domDocument.activeElement, - implicitViewRootElement, - reason: 'Text editing should have stopped', - ); + // Text editing should've stopped. + expect(domDocument.activeElement, domDocument.body); // Confirm that [HybridTextEditing] didn't send any more messages. expect(spy.messages, isEmpty); @@ -2333,6 +2307,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + expect(textEditing!.strategy.domElement!.tagName, 'INPUT'); expect(getEditingInputMode(), 'none'); }); @@ -2354,6 +2330,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + expect(textEditing!.strategy.domElement!.tagName, 'TEXTAREA'); expect(getEditingInputMode(), 'none'); }); @@ -2592,8 +2570,11 @@ Future testMain() async { final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(10, 10, transform); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + final DomElement input = textEditing!.strategy.domElement!; + // Input is appended to the right view. expect(view.dom.textEditingHost.contains(input), isTrue); @@ -2673,6 +2654,8 @@ Future testMain() async { final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(10, 10, transform); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + final DomElement input = textEditing!.strategy.domElement!; final DomElement form = textEditing!.configuration!.autofillGroup!.formElement; @@ -2716,6 +2699,8 @@ Future testMain() async { const MethodCall show = MethodCall('TextInput.show'); sendFrameworkMessage(codec.encodeMethodCall(show)); + await waitForDesktopSafariFocus(); + final DomElement input = textEditing!.strategy.domElement!; final DomElement form = textEditing!.configuration!.autofillGroup!.formElement; @@ -2886,7 +2871,6 @@ Future testMain() async { final DomHTMLInputElement inputElement = form.childNodes.toList()[0] as DomHTMLInputElement; expect(inputElement.type, 'submit'); - expect(inputElement.tabIndex, -1, reason: 'The input should not be reachable by keyboard'); // The submit button should have class `submitBtn`. expect(inputElement.className, 'submitBtn'); @@ -3542,8 +3526,6 @@ Future testMain() async { ); final DomHTMLElement input = editingStrategy!.activeDomElement; - expect(domDocument.activeElement, input, reason: 'the input element should be focused'); - expect(input.style.color, contains('transparent')); if (isSafari) { // macOS 13 returns different values than macOS 12. @@ -3553,7 +3535,7 @@ Future testMain() async { } else { expect(input.style.background, contains('transparent')); expect(input.style.outline, contains('none')); - expect(input.style.border, anyOf(contains('none'), contains('medium'))); + expect(input.style.border, contains('none')); } expect(input.style.backgroundColor, contains('transparent')); expect(input.style.caretColor, contains('transparent')); @@ -3757,9 +3739,13 @@ void clearForms() { formsOnTheDom.clear(); } -/// Waits until the text strategy closes and moves the focus accordingly. -Future waitForTextStrategyStopPropagation() async { - await Future.delayed(Duration.zero); +/// On Desktop Safari, the editing element is focused after a zero-duration timer +/// to prevent autofill popup flickering. We must wait a tick for this placement +/// before referencing these elements. +Future waitForDesktopSafariFocus() async { + if (textEditing.strategy is SafariDesktopTextEditingStrategy) { + await Future.delayed(Duration.zero); + } } class GlobalTextEditingStrategySpy extends GloballyPositionedTextEditingStrategy {