Fix focus management for text fields (#51009)

Fix focus management for text fields

This PR:

1. Refactors the DOM `focus` function to take [options][1]
2. Removes the timers sorrounding the `activeDomElement.focus()` so that the input elements get focused as immediate as possible.
3. Prevents the default on pointerdown in a Flutter View and schedules a `requestViewFocusChange` to claim focus in that view after some time. This gives `2` the opportunity to focus the right `<input />` or `<textarea />` element. This helps focus correctly transition from one input element to another (without jumping to a flutter view in between).
4. Deactivating a `TextField` doesn't blur the focused element anymore, it insteads schedules for later a call to move the focus to the flutter view if nothing inside it claimed focus first.
5. Prevents scroll in all the focus calls (this should help with the view jumping when focusing one text field after another).

## Sample apps

1. Full screen mode: https://tugorez.com/flutter_focus_web
2. Embedded mode: https://tugorez.com/flutter_focus_web?embedded

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart
index b11e9e9..622cf3f 100644
--- a/lib/web_ui/lib/src/engine/dom.dart
+++ b/lib/web_ui/lib/src/engine/dom.dart
@@ -658,7 +658,16 @@
   external JSNumber? get _tabIndex;
   double? get tabIndex => _tabIndex?.toDartDouble;
 
-  external JSVoid focus();
+  @JS('focus')
+  external JSVoid _focus(JSAny options);
+
+  void focus({bool? preventScroll, bool? focusVisible}) {
+    final Map<String, bool> options = <String, bool>{
+      if (preventScroll != null) 'preventScroll': preventScroll,
+      if (focusVisible != null) 'focusVisible': focusVisible,
+    };
+    _focus(options.toJSAnyDeep);
+  }
 
   @JS('scrollTop')
   external JSNumber get _scrollTop;
@@ -2249,9 +2258,11 @@
   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 85bea97..f70456e 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 @@
   num? get timeStamp => _event.timeStamp;
   bool get altKey => _event.altKey;
   bool get ctrlKey => _event.ctrlKey;
-  bool get shiftKey => _event.shiftKey;
+  bool get shiftKey => _event.shiftKey ?? false;
   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 3a10c4a..b334409 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 @@
   ///
   /// DO NOT rely on this bit as it will go away soon. You're warned :)!
   @visibleForTesting
-  static bool isEnabled = false;
+  static bool isEnabled = true;
 
   final FlutterViewManager _viewManager;
   final ui.ViewFocusChangeCallback _onViewFocusChange;
@@ -51,7 +51,7 @@
     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();
+        viewElement?.focus(preventScroll: true);
       }
     } else {
       viewElement?.blur();
@@ -70,7 +70,7 @@
 
   late final DomEventListener _handleKeyDown = createDomEventListener((DomEvent event) {
     event as DomKeyboardEvent;
-    if (event.shiftKey) {
+    if (event.shiftKey ?? false) {
       _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 f0b2b75..741761c 100644
--- a/lib/web_ui/lib/src/engine/pointer_binding.dart
+++ b/lib/web_ui/lib/src/engine/pointer_binding.dart
@@ -982,6 +982,22 @@
         );
       _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 da49e87..98b3400 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,7 +332,8 @@
 
     // In order to submit the form when Framework sends a `TextInput.commit`
     // message, we add a submit button to the form.
-    final DomHTMLInputElement submitButton = createDomHTMLInputElement();
+    // The -1 tab index value makes this element not reachable by keyboard.
+    final DomHTMLInputElement submitButton = createDomHTMLInputElement()..tabIndex = -1;
     _styleAutofillElements(submitButton, isOffScreen: true);
     submitButton.className = 'submitBtn';
     submitButton.type = 'submit';
@@ -1130,8 +1131,8 @@
       // 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();
-      activeDomElement.focus();
+      focusedFormElement!.focus(preventScroll: true);
+      moveFocusToActiveDomElement();
     }
   }
 }
@@ -1157,42 +1158,20 @@
   ///
   /// 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()`.
+  /// which in current state calls `domElement.focus(preventScroll: true)`.
   ///
   /// Making an extra `focus` request causes flickering in Safari.
   @override
   void placeElement() {
     geometry?.applyToDomElement(activeDomElement);
     if (hasAutofillGroup) {
-      // 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);
-      });
+      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);
     }
   }
 
@@ -1201,7 +1180,7 @@
     if (geometry != null) {
       placeElement();
     }
-    activeDomElement.focus();
+    moveFocusToActiveDomElement();
   }
 }
 
@@ -1248,6 +1227,12 @@
     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;
 
@@ -1285,7 +1270,8 @@
   }) {
     assert(!isEnabled);
 
-    domElement = inputConfig.inputType.createDomElement();
+    // The -1 tab index value makes this element not reachable by keyboard.
+    domElement = inputConfig.inputType.createDomElement()..tabIndex = -1;
     applyConfiguration(inputConfig);
 
     _setStaticStyleAttributes(activeDomElement);
@@ -1363,16 +1349,17 @@
     subscriptions.add(DomSubscription(domDocument, 'selectionchange',
             handleChange));
 
-    activeDomElement.addEventListener('beforeinput',
-        createDomEventListener(handleBeforeInput));
+    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));
+    }
 
     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();
   }
 
@@ -1422,13 +1409,12 @@
     // 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 {
-      activeDomElement.remove();
-    }
+      _moveFocusToFlutterView(activeDomElement, _activeDomElementView, removeElement: true);
+		}
     domElement = null;
   }
 
@@ -1442,7 +1428,7 @@
   }
 
   void placeElement() {
-    activeDomElement.focus();
+    moveFocusToActiveDomElement();
   }
 
   void placeForm() {
@@ -1508,6 +1494,15 @@
     }
   }
 
+  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;
@@ -1545,7 +1540,7 @@
     }
 
     // Re-focuses after setting editing state.
-    activeDomElement.focus();
+    moveFocusToActiveDomElement();
   }
 
   /// Prevent default behavior for mouse down, up and move.
@@ -1572,6 +1567,31 @@
       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 <input /> 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.
@@ -1605,17 +1625,6 @@
   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
@@ -1671,8 +1680,11 @@
     subscriptions.add(DomSubscription(domDocument, 'selectionchange',
             handleChange));
 
-    activeDomElement.addEventListener('beforeinput',
-        createDomEventListener(handleBeforeInput));
+    subscriptions.add(DomSubscription(activeDomElement, 'beforeinput',
+            handleBeforeInput));
+
+    subscriptions.add(DomSubscription(activeDomElement, 'blur',
+            handleBlur));
 
     addCompositionEventHandlers(activeDomElement);
 
@@ -1684,35 +1696,6 @@
             }));
 
     _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
@@ -1772,7 +1755,7 @@
 
   @override
   void placeElement() {
-    activeDomElement.focus();
+    moveFocusToActiveDomElement();
     geometry?.applyToDomElement(activeDomElement);
   }
 }
@@ -1824,31 +1807,20 @@
         DomSubscription(domDocument, 'selectionchange',
             handleChange));
 
-    activeDomElement.addEventListener('beforeinput',
-        createDomEventListener(handleBeforeInput));
+    subscriptions.add(DomSubscription(activeDomElement, 'beforeinput',
+        handleBeforeInput));
+
+    subscriptions.add(DomSubscription(activeDomElement, 'blur',
+            handleBlur));
 
     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() {
-    activeDomElement.focus();
+    moveFocusToActiveDomElement();
     geometry?.applyToDomElement(activeDomElement);
   }
 }
@@ -1888,8 +1860,9 @@
         DomSubscription(
             activeDomElement, 'keydown', maybeSendAction));
 
-    activeDomElement.addEventListener('beforeinput',
-        createDomEventListener(handleBeforeInput));
+    subscriptions.add(
+        DomSubscription(
+            activeDomElement, 'beforeinput', handleBeforeInput));
 
     addCompositionEventHandlers(activeDomElement);
 
@@ -1921,32 +1894,15 @@
         DomSubscription(
             activeDomElement, 'select', handleChange));
 
-    // Refocus on the activeDomElement after blur, so that user can keep editing the
-    // text field.
-    subscriptions.add(
-        DomSubscription(
-            activeDomElement,
-            'blur',
-            (_) {
-              _postponeFocus();
-            }));
+    subscriptions.add(DomSubscription(activeDomElement, 'blur',
+            handleBlur));
 
     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() {
-    activeDomElement.focus();
+    moveFocusToActiveDomElement();
     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 ac93b4c..49b7622 100644
--- a/lib/web_ui/test/engine/text_editing_test.dart
+++ b/lib/web_ui/test/engine/text_editing_test.dart
@@ -25,6 +25,9 @@
 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
 
@@ -67,13 +70,18 @@
     setUpTestViewDimensions: false
   );
 
-  tearDown(() {
+  setUp(() {
+    domDocument.activeElement?.blur();
+  });
+
+  tearDown(() async {
     lastEditingState = null;
     editingDeltaState = null;
     lastInputAction = null;
     cleanTextEditingStrategy();
     cleanTestFlags();
     clearBackUpDomElementIfExists();
+    await waitForTextStrategyStopPropagation();
   });
 
   group('$GloballyPositionedTextEditingStrategy', () {
@@ -86,13 +94,16 @@
       testTextEditing.configuration = singlelineConfig;
     });
 
-    test('Creates element when enabled and removes it when disabled', () {
+    test('Creates element when enabled and removes it when disabled', () async {
       expect(
         domDocument.getElementsByTagName('input'),
         hasLength(0),
       );
-      // The focus initially is on the body.
-      expect(domDocument.activeElement, domDocument.body);
+      expect(
+        domDocument.activeElement,
+        domDocument.body,
+        reason: 'The focus should initially be on the body',
+      );
       expect(defaultTextEditingRoot.ownerDocument?.activeElement,
           domDocument.body);
 
@@ -114,22 +125,24 @@
 
       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 body.
-      expect(domDocument.activeElement, domDocument.body);
+      // The focus is back to the flutter view.
+      expect(domDocument.activeElement, implicitViewRootElement);
       expect(defaultTextEditingRoot.ownerDocument?.activeElement,
-          domDocument.body);
+          implicitViewRootElement);
     });
 
-    test('inserts element in the correct view', () {
+    test('inserts element in the correct view', () async {
       final DomElement host = createDomElement('div');
       domDocument.body!.append(host);
       final EngineFlutterView view = EngineFlutterView(dispatcher, host);
@@ -152,6 +165,7 @@
 
       // Cleanup.
       editingStrategy!.disable();
+      await waitForTextStrategyStopPropagation();
       expect(textEditingHost.querySelectorAll('input'), hasLength(0));
       dispatcher.viewManager.unregisterView(view.viewId);
       view.dispose();
@@ -305,7 +319,7 @@
       expect(lastInputAction, isNull);
     });
 
-    test('Multi-line mode also works', () {
+    test('Multi-line mode also works', () async {
       // The textarea element is created lazily.
       expect(domDocument.getElementsByTagName('textarea'), hasLength(0));
       editingStrategy!.enable(
@@ -337,17 +351,23 @@
       checkTextAreaEditingState(textarea, 'bar\nbaz', 2, 7);
 
       editingStrategy!.disable();
+
+      await waitForTextStrategyStopPropagation();
+
       // The textarea should be cleaned up.
       expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(0));
-      // The focus is back to the body.
-      expect(defaultTextEditingRoot.ownerDocument?.activeElement,
-          domDocument.body);
+
+      expect(
+        defaultTextEditingRoot.ownerDocument?.activeElement,
+        implicitViewRootElement,
+        reason: 'The focus should be back to the body',
+      );
 
       // There should be no input action.
       expect(lastInputAction, isNull);
     });
 
-    test('Same instance can be re-enabled with different config', () {
+    test('Same instance can be re-enabled with different config', () async {
       // Make sure there's nothing in the DOM yet.
       expect(domDocument.getElementsByTagName('input'), hasLength(0));
       expect(domDocument.getElementsByTagName('textarea'), hasLength(0));
@@ -363,6 +383,7 @@
 
       // 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));
 
@@ -372,11 +393,13 @@
         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));
 
@@ -705,8 +728,6 @@
               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 =
@@ -723,8 +744,13 @@
       const MethodCall hide = MethodCall('TextInput.hide');
       sendFrameworkMessage(codec.encodeMethodCall(hide));
 
-      // Text editing should've stopped.
-      expect(domDocument.activeElement, domDocument.body);
+      await waitForTextStrategyStopPropagation();
+
+      expect(
+        domDocument.activeElement,
+        implicitViewRootElement,
+        reason: 'Text editing should have stopped',
+      );
 
       // Confirm that [HybridTextEditing] didn't send any messages.
       expect(spy.messages, isEmpty);
@@ -743,8 +769,11 @@
       });
       sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
 
-      // Editing shouldn't have started yet.
-      expect(domDocument.activeElement, domDocument.body);
+      expect(
+        domDocument.activeElement,
+        domDocument.body,
+        reason: 'Editing should not have started yet',
+      );
 
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
@@ -758,15 +787,19 @@
               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));
 
-      expect(domDocument.activeElement, domDocument.body);
+      await waitForTextStrategyStopPropagation();
+
+      expect(
+        domDocument.activeElement,
+        implicitViewRootElement,
+        reason: 'Text editing should have stopped',
+      );
 
       // Confirm that [HybridTextEditing] didn't send any messages.
       expect(spy.messages, isEmpty);
@@ -794,8 +827,6 @@
               Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
       sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
 
-      await waitForDesktopSafariFocus();
-
       const MethodCall setEditingState =
         MethodCall('TextInput.setEditingState', <String, dynamic>{
         'text': 'abcd',
@@ -863,9 +894,11 @@
       });
       sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
 
-      // Editing shouldn't have started yet.
-      expect(defaultTextEditingRoot.ownerDocument?.activeElement,
-          domDocument.body);
+      expect(
+        defaultTextEditingRoot.ownerDocument?.activeElement,
+        domDocument.body,
+        reason: 'Editing should not have started yet',
+      );
 
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
@@ -879,18 +912,14 @@
               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<void>.delayed(Duration.zero);
+
       // DOM element still keeps the focus.
       expect(defaultTextEditingRoot.ownerDocument?.activeElement,
           textEditing!.strategy.domElement);
@@ -977,8 +1006,6 @@
               Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
       sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
 
-      await waitForDesktopSafariFocus();
-
       checkInputEditingState(
           textEditing!.strategy.domElement, 'abcd', 2, 3);
 
@@ -1035,8 +1062,6 @@
               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);
 
@@ -1091,8 +1116,6 @@
               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 =
@@ -1146,8 +1169,6 @@
               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 =
@@ -1174,50 +1195,57 @@
       expect(formsOnTheDom, hasLength(0));
     });
 
-    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<String, dynamic> flutterMultiAutofillElementConfig =
-          createFlutterConfig('text',
-              autofillHint: 'username',
-              autofillHintsForFields: <String>[
-            'username',
-            'email',
-            'name',
-            'telephoneNumber'
-          ]);
-      final MethodCall setClient = MethodCall('TextInput.setClient',
-          <dynamic>[123, flutterMultiAutofillElementConfig]);
-      sendFrameworkMessage(codec.encodeMethodCall(setClient));
+    test('Moves the focus across input elements', () async {
+      final List<DomEvent> focusinEvents = <DomEvent>[];
+      final DomEventListener handleFocusIn = createDomEventListener(focusinEvents.add);
 
-      const MethodCall setEditingState1 =
+      final MethodCall setClient1 = MethodCall(
+        'TextInput.setClient',
+        <dynamic>[123, flutterSinglelineConfig],
+      );
+      final MethodCall setClient2 = MethodCall(
+        'TextInput.setClient',
+        <dynamic>[567, flutterSinglelineConfig],
+      );
+      const MethodCall setEditingState =
           MethodCall('TextInput.setEditingState', <String, dynamic>{
         'text': 'abcd',
         'selectionBase': 2,
         'selectionExtent': 3,
       });
-      sendFrameworkMessage(codec.encodeMethodCall(setEditingState1));
-
+      final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(
+        150,
+        50,
+        Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList(),
+      );
       const MethodCall show = MethodCall('TextInput.show');
-      sendFrameworkMessage(codec.encodeMethodCall(show));
+      const MethodCall clearClient = MethodCall('TextInput.clearClient');
 
-      final MethodCall setSizeAndTransform =
-          configureSetSizeAndTransformMethodCall(150, 50,
-              Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
+      domDocument.body!.addEventListener('focusin', handleFocusIn);
+      sendFrameworkMessage(codec.encodeMethodCall(setClient1));
+      sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
       sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
+      sendFrameworkMessage(codec.encodeMethodCall(show));
+      final DomElement firstInput = textEditing!.strategy.domElement!;
+      expect(domDocument.activeElement, firstInput);
 
-      // Prior to tick, form should not exist and no elements should be focused.
-      expect(defaultTextEditingRoot.querySelectorAll('form'), isEmpty);
-      expect(domDocument.activeElement, domDocument.body);
+      sendFrameworkMessage(codec.encodeMethodCall(setClient2));
+      sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
+      sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
+      sendFrameworkMessage(codec.encodeMethodCall(show));
+      final DomElement secondInput = textEditing!.strategy.domElement!;
+      expect(domDocument.activeElement, secondInput);
+      expect(firstInput, isNot(secondInput));
 
-      await waitForDesktopSafariFocus();
+      sendFrameworkMessage(codec.encodeMethodCall(clearClient));
+      await waitForTextStrategyStopPropagation();
+      domDocument.body!.removeEventListener('focusin', handleFocusIn);
 
-      // Form is added to DOM.
-      expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty);
-
-      final DomHTMLInputElement inputElement =
-          textEditing!.strategy.domElement! as DomHTMLInputElement;
-      expect(domDocument.activeElement, inputElement);
-    }, skip: !isSafari);
+      expect(focusinEvents, hasLength(3));
+      expect(focusinEvents[0].target, firstInput);
+      expect(focusinEvents[1].target, secondInput);
+      expect(focusinEvents[2].target, implicitViewRootElement);
+    });
 
     test('setClient, setEditingState, show, setClient', () async {
       final MethodCall setClient = MethodCall(
@@ -1232,8 +1260,11 @@
       });
       sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
 
-      // Editing shouldn't have started yet.
-      expect(domDocument.activeElement, domDocument.body);
+      expect(
+        domDocument.activeElement,
+        domDocument.body,
+        reason: 'Editing should not have started yet.',
+      );
 
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
@@ -1247,8 +1278,6 @@
               Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
       sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
 
-      await waitForDesktopSafariFocus();
-
       checkInputEditingState(
           textEditing!.strategy.domElement, 'abcd', 2, 3);
 
@@ -1256,9 +1285,13 @@
           'TextInput.setClient', <dynamic>[567, flutterSinglelineConfig]);
       sendFrameworkMessage(codec.encodeMethodCall(setClient2));
 
-      // Receiving another client via setClient should stop editing, hence
-      // should remove the previous active element.
-      expect(domDocument.activeElement, domDocument.body);
+      await waitForTextStrategyStopPropagation();
+
+      expect(
+        domDocument.activeElement,
+        implicitViewRootElement,
+        reason: 'Receiving another client via setClient should stop editing, hence should remove the previous active element.',
+      );
 
       // Confirm that [HybridTextEditing] didn't send any messages.
       expect(spy.messages, isEmpty);
@@ -1299,7 +1332,6 @@
       });
       sendFrameworkMessage(codec.encodeMethodCall(setEditingState2));
 
-      await waitForDesktopSafariFocus();
       // The second [setEditingState] should override the first one.
       checkInputEditingState(
           textEditing!.strategy.domElement, 'xyz', 0, 2);
@@ -1341,7 +1373,6 @@
               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);
@@ -1412,7 +1443,6 @@
               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);
@@ -1468,8 +1498,6 @@
               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);
@@ -1780,8 +1808,6 @@
               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);
@@ -1955,8 +1981,6 @@
               Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
       sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
 
-      await waitForDesktopSafariFocus();
-
       final DomHTMLInputElement input = textEditing!.strategy.domElement! as
           DomHTMLInputElement;
 
@@ -2030,8 +2054,6 @@
               Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
       sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
 
-      await waitForDesktopSafariFocus();
-
       final DomHTMLInputElement input = textEditing!.strategy.domElement! as
           DomHTMLInputElement;
 
@@ -2116,7 +2138,6 @@
               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);
@@ -2168,9 +2189,11 @@
           'TextInput.setClient', <dynamic>[123, flutterMultilineConfig]);
       sendFrameworkMessage(codec.encodeMethodCall(setClient));
 
-      // Editing shouldn't have started yet.
-      expect(defaultTextEditingRoot.ownerDocument?.activeElement,
-          domDocument.body);
+      expect(
+        defaultTextEditingRoot.ownerDocument?.activeElement,
+        domDocument.body,
+        reason: 'Editing should have not started yet',
+      );
 
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
@@ -2184,8 +2207,6 @@
               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);
@@ -2253,8 +2274,13 @@
       const MethodCall hide = MethodCall('TextInput.hide');
       sendFrameworkMessage(codec.encodeMethodCall(hide));
 
-      // Text editing should've stopped.
-      expect(domDocument.activeElement, domDocument.body);
+      await waitForTextStrategyStopPropagation();
+
+      expect(
+        domDocument.activeElement,
+        implicitViewRootElement,
+        reason: 'Text editing should have stopped',
+      );
 
       // Confirm that [HybridTextEditing] didn't send any more messages.
       expect(spy.messages, isEmpty);
@@ -2277,8 +2303,6 @@
               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');
     });
@@ -2300,8 +2324,6 @@
               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');
     });
@@ -2538,11 +2560,8 @@
       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);
 
@@ -2622,8 +2641,6 @@
       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;
 
@@ -2667,8 +2684,6 @@
       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;
 
@@ -2839,6 +2854,7 @@
       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');
@@ -3494,6 +3510,8 @@
       );
 
       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.
@@ -3503,7 +3521,7 @@
       } else {
         expect(input.style.background, contains('transparent'));
         expect(input.style.outline, contains('none'));
-        expect(input.style.border, contains('none'));
+        expect(input.style.border, anyOf(contains('none'), contains('medium')));
       }
       expect(input.style.backgroundColor, contains('transparent'));
       expect(input.style.caretColor, contains('transparent'));
@@ -3707,13 +3725,9 @@
   formsOnTheDom.clear();
 }
 
-/// 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<void> waitForDesktopSafariFocus() async {
-  if (textEditing.strategy is SafariDesktopTextEditingStrategy) {
-    await Future<void>.delayed(Duration.zero);
-  }
+/// Waits until the text strategy closes and moves the focus accordingly.
+Future<void> waitForTextStrategyStopPropagation() async {
+  await Future<void>.delayed(Duration.zero);
 }
 
 class GlobalTextEditingStrategySpy extends GloballyPositionedTextEditingStrategy {