diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index b11e9e97a7458..622cf3f2a2dd1 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -658,7 +658,16 @@ extension DomElementExtension on DomElement { 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 @@ 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 85bea97039e0a..f70456e42239c 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; + 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 3a10c4ab723c9..b3344099f7bd4 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 = false; + static bool isEnabled = true; 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(); + viewElement?.focus(preventScroll: true); } } else { viewElement?.blur(); @@ -70,7 +70,7 @@ final class ViewFocusBinding { 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 f0b2b75c8e521..741761c434515 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 @@ 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 da49e877b2d7c..98b3400c5032d 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 @@ class EngineAutofillForm { // 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 @@ 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(); - activeDomElement.focus(); + focusedFormElement!.focus(preventScroll: true); + moveFocusToActiveDomElement(); } } } @@ -1157,42 +1158,20 @@ 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()`. + /// 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 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy { if (geometry != null) { placeElement(); } - activeDomElement.focus(); + moveFocusToActiveDomElement(); } } @@ -1248,6 +1227,12 @@ 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; @@ -1285,7 +1270,8 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements }) { 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,15 +1349,16 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements subscriptions.add(DomSubscription(domDocument, 'selectionchange', handleChange)); - activeDomElement.addEventListener('beforeinput', - createDomEventListener(handleBeforeInput)); + subscriptions.add(DomSubscription(activeDomElement, 'beforeinput', + handleBeforeInput)); - addCompositionEventHandlers(activeDomElement); + 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)); + } - // Refocus on the activeDomElement after blur, so that user can keep editing the - // text field. - subscriptions.add(DomSubscription(activeDomElement, 'blur', - (_) { activeDomElement.focus(); })); + addCompositionEventHandlers(activeDomElement); preventDefaultForMouseEvents(); } @@ -1422,13 +1409,12 @@ 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 { - activeDomElement.remove(); - } + _moveFocusToFlutterView(activeDomElement, _activeDomElementView, removeElement: true); + } domElement = null; } @@ -1442,7 +1428,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements } void placeElement() { - activeDomElement.focus(); + moveFocusToActiveDomElement(); } void placeForm() { @@ -1508,6 +1494,15 @@ 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; @@ -1545,7 +1540,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements } // Re-focuses after setting editing state. - activeDomElement.focus(); + moveFocusToActiveDomElement(); } /// Prevent default behavior for mouse down, up and move. @@ -1572,6 +1567,31 @@ 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 <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 @@ 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 @@ -1671,8 +1680,11 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { 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 @@ 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 @@ -1772,7 +1755,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { @override void placeElement() { - activeDomElement.focus(); + moveFocusToActiveDomElement(); geometry?.applyToDomElement(activeDomElement); } } @@ -1824,31 +1807,20 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy { DomSubscription(domDocument, 'selectionchange', handleChange)); - activeDomElement.addEventListener('beforeinput', - createDomEventListener(handleBeforeInput)); + subscriptions.add(DomSubscription(activeDomElement, 'beforeinput', + handleBeforeInput)); - addCompositionEventHandlers(activeDomElement); + subscriptions.add(DomSubscription(activeDomElement, 'blur', + handleBlur)); - 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(); - } - })); + addCompositionEventHandlers(activeDomElement); preventDefaultForMouseEvents(); } @override void placeElement() { - activeDomElement.focus(); + moveFocusToActiveDomElement(); geometry?.applyToDomElement(activeDomElement); } } @@ -1888,8 +1860,9 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy { DomSubscription( activeDomElement, 'keydown', maybeSendAction)); - activeDomElement.addEventListener('beforeinput', - createDomEventListener(handleBeforeInput)); + subscriptions.add( + DomSubscription( + activeDomElement, 'beforeinput', handleBeforeInput)); addCompositionEventHandlers(activeDomElement); @@ -1921,32 +1894,15 @@ class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy { 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 ac93b4c0c0657..49b76226a6c87 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 @@ 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 @@ -67,13 +70,18 @@ Future<void> testMain() async { 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 @@ Future<void> testMain() async { 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 @@ Future<void> 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 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 @@ Future<void> testMain() async { // Cleanup. editingStrategy!.disable(); + await waitForTextStrategyStopPropagation(); expect(textEditingHost.querySelectorAll('input'), hasLength(0)); dispatcher.viewManager.unregisterView(view.viewId); view.dispose(); @@ -305,7 +319,7 @@ Future<void> testMain() async { 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 @@ Future<void> testMain() async { 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 @@ Future<void> 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)); @@ -372,11 +393,13 @@ Future<void> 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)); @@ -705,8 +728,6 @@ Future<void> 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 = @@ -723,8 +744,13 @@ Future<void> testMain() async { 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 @@ Future<void> testMain() async { }); 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 @@ Future<void> 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)); - 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 @@ Future<void> testMain() async { 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 @@ Future<void> testMain() async { }); 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 @@ Future<void> 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<void>.delayed(Duration.zero); + // DOM element still keeps the focus. expect(defaultTextEditingRoot.ownerDocument?.activeElement, textEditing!.strategy.domElement); @@ -977,8 +1006,6 @@ Future<void> 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); @@ -1035,8 +1062,6 @@ Future<void> 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); @@ -1091,8 +1116,6 @@ Future<void> 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 = @@ -1146,8 +1169,6 @@ Future<void> 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 = @@ -1174,50 +1195,57 @@ Future<void> testMain() async { 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); - - await waitForDesktopSafariFocus(); + 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)); - // Form is added to DOM. - expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); + sendFrameworkMessage(codec.encodeMethodCall(clearClient)); + await waitForTextStrategyStopPropagation(); + domDocument.body!.removeEventListener('focusin', handleFocusIn); - 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 @@ Future<void> testMain() async { }); 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 @@ Future<void> 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); @@ -1256,9 +1285,13 @@ Future<void> testMain() async { '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 @@ Future<void> testMain() async { }); 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 @@ Future<void> 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); @@ -1412,7 +1443,6 @@ Future<void> 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); @@ -1468,8 +1498,6 @@ Future<void> 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); @@ -1780,8 +1808,6 @@ Future<void> 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); @@ -1955,8 +1981,6 @@ Future<void> 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; @@ -2030,8 +2054,6 @@ Future<void> 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; @@ -2116,7 +2138,6 @@ Future<void> 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); @@ -2168,9 +2189,11 @@ Future<void> testMain() async { '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 @@ Future<void> 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); @@ -2253,8 +2274,13 @@ Future<void> testMain() async { 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 @@ Future<void> 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'); }); @@ -2300,8 +2324,6 @@ Future<void> 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'); }); @@ -2538,11 +2560,8 @@ Future<void> 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); @@ -2622,8 +2641,6 @@ Future<void> 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; @@ -2667,8 +2684,6 @@ Future<void> 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; @@ -2839,6 +2854,7 @@ Future<void> 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'); @@ -3494,6 +3510,8 @@ Future<void> 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. @@ -3503,7 +3521,7 @@ Future<void> testMain() async { } 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 @@ void clearForms() { 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 {