Skip to content

Commit 3ee7f14

Browse files
authored
[web] Fix unresponsive input above SelectionArea in Safari and Firefox. (#167275)
Fixes flutter/flutter#157579 ### Description When the `TextField` is placed above the `HtmlElementView`, it becomes unresponsive on Safari and Firefox. After the investigation, I found that this happens because the underlying `input`/`textarea` loses focus, leading to not listening to the keyboard input. After some investigation, I found out that calling `preventDefault` on `mousedown` events on SelectionArea's `div` element prevents the `input/textarea` from losing focus. This PR focuses on `SelectionArea`, but there is the same issue happening in the `pointer_interceptor ` package flutter/flutter#157920. If this solution is accepted, then I could file a separate PR for `pointer_interceptor` package with the same fix. | Before | After | | :---: | :---: | | https://input-above-selection-area-bug.web.app | https://input-above-selection-area-fix.web.app | | <video src="https://github.com/user-attachments/assets/be73a5e9-84e4-44f9-96b3-f8d24f44e0b8" /> | <video src="https://github.com/user-attachments/assets/87746058-df6e-4caf-8f85-c240de32c630" /> | <details> <summary>Old description</summary> The fix I am proposing is to delay the `moveFocusToActiveDomElement` by using `Timer`. I am not sure whether this is a proper fix, as it looks like the issues may be in the way pointer events are handled. I tried adding `event.preventDefault()` after `_callback(event, pointerData)` in `pointer_binding.dart` and the issue was fixed, but then text selection in `SelectionRegion` became broken. https://github.com/flutter/flutter/blob/aef4718b39569939ec0052c44819cd01176234ca/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart#L942-L974 The application with the bug reproduction is hosted at: https://input-above-element-view-bug.web.app The application with the fix is hosted at: https://input-above-element-view-fix.web.app <details> <summary>Application Source Code</summary> ```dart import 'package:flutter/material.dart'; import 'package:web/web.dart' as web; void main() async { runApp( MaterialApp( home: Screen(), ), ); } class Screen extends StatelessWidget { const Screen({super.key}); @OverRide Widget build(BuildContext context) { return Scaffold( body: Column( children: [ Expanded( child: ColoredBox( color: Colors.green.withAlpha(50), child: SelectionArea( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('SelectionArea below'), OneLineTextField(), MultilineTextField(), ], ), ), ), ), ), Builder( builder: (context) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: OneLineTextField(), ), ElevatedButton( child: const Text('Show dialog'), onPressed: () { showDialog( context: context, builder: (BuildContext context) { return SimpleDialog( children: <Widget>[ Column( mainAxisSize: MainAxisSize.min, children: [ OneLineTextField(), OneLineTextField(), OneLineTextField(), ], ), ], ); }, ); }, ), ], ); }, ), Expanded( child: ColoredBox( color: Colors.orange.withAlpha(50), child: SimpleDiv( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Simple div below'), OneLineTextField(), MultilineTextField(), ], ), ), ), ), ), ], ), ); } } class OneLineTextField extends StatelessWidget { const OneLineTextField({super.key}); @OverRide Widget build(BuildContext context) { return TextField( decoration: InputDecoration( labelText: 'One-line', floatingLabelBehavior: FloatingLabelBehavior.always, ), ); } } class MultilineTextField extends StatelessWidget { const MultilineTextField({super.key}); @OverRide Widget build(BuildContext context) { return TextField( decoration: InputDecoration( labelText: 'Multiline', floatingLabelBehavior: FloatingLabelBehavior.always, ), minLines: 1, maxLines: null, ); } } class SimpleDiv extends StatelessWidget { const SimpleDiv({ required this.child, super.key, }); final Widget child; @OverRide Widget build(BuildContext context) { return Stack( children: <Widget>[ Positioned.fill( child: HtmlElementView.fromTagName( tagName: 'div', isVisible: false, onElementCreated: (element) { (element as web.HTMLElement) ..style.width = '100%' ..style.height = '100%'; }, ), ), child, ], ); } } ``` </details> #### Firefox On `TextField` tap, the focus moves to the `input`, and then back to the `flutter-view`. You can take a look at the "Before" recording. | Before | After | | :---: | :---: | | `flutter-view -> input -> flutter-view` | `flutter-view -> input -> flutter-view -> input` | | <video src="https://github.com/user-attachments/assets/dbbbbca7-500c-4682-a2e8-b49751a27b5c" /> | <video src="https://github.com/user-attachments/assets/3886592f-c4e0-4c92-9e24-9cc2cb5a4763" /> | #### Safari Like in Firefox, on `TextField` tap, the focus moves to the `input`, and then back to the `flutter-view`. You can take a look at the "Before" recording. | Before | After | | :---: | :---: | | `flutter-view -> input -> flutter-view` | `flutter-view -> input -> flutter-view -> input` | | <video src="https://github.com/user-attachments/assets/10c3b7e5-cf64-4858-8874-98c1e1aae74f" /> | <video src="https://github.com/user-attachments/assets/5c74ea51-494a-410c-ae02-be0dccf1b344" /> | #### Chrome The issue is not happening on Chrome. If you take a look at the recording, you will notice that on a `TextField` tap, the focus moves the following way: `flutter-view -> input -> input`. The reason why it doesn't move to `flutter-view` is that we have a `moveFocusToActiveDomElement` call in the `handleBlur` function. As far as I understand, it prevents `input` from losing focus. However, the same call in Firefox doesn't prevent input focus loss. In Safari, it fixes the issue, but listening to `blur` events is not a way to go, according to the following comment https://github.com/flutter/flutter/blob/master/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart#L1385-L1391 <video src="https://github.com/user-attachments/assets/6168effd-49ff-4064-9876-50ab3bfae9ac" /> </details> ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 9737016 commit 3ee7f14

3 files changed

Lines changed: 29 additions & 0 deletions

File tree

packages/flutter/lib/src/web.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ extension type MouseEvent._(JSObject _) implements JSObject {
107107
external num get offsetX;
108108
external num get offsetY;
109109
external int get button;
110+
external void preventDefault();
110111
}
111112

112113
extension type Navigator._(JSObject _) implements JSObject {

packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget {
129129
'mousedown',
130130
(web.Event event) {
131131
final web.MouseEvent mouseEvent = event as web.MouseEvent;
132+
mouseEvent.preventDefault();
132133
if (mouseEvent.button != _kRightClickButton) {
133134
return;
134135
}

packages/flutter/test/widgets/selectable_region_context_menu_test.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,33 @@ void main() {
134134
expect((selectWordEvent!.globalPosition.dx - 200).abs() < precisionErrorTolerance, isTrue);
135135
expect((selectWordEvent.globalPosition.dy - 300).abs() < precisionErrorTolerance, isTrue);
136136
});
137+
138+
// Regression test for https://github.com/flutter/flutter/issues/157579
139+
testWidgets('prevents default action of mousedown events', (WidgetTester tester) async {
140+
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
141+
142+
await tester.pumpWidget(
143+
MaterialApp(
144+
home: SelectableRegion(
145+
selectionControls: emptyTextSelectionControls,
146+
child: const SizedBox.shrink(),
147+
),
148+
),
149+
);
150+
151+
final web.HTMLElement element =
152+
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
153+
expect(element, isNotNull);
154+
155+
for (int i = 0; i <= 4; i++) {
156+
final web.MouseEvent event = web.MouseEvent(
157+
'mousedown',
158+
web.MouseEventInit(button: i, clientX: 200, clientY: 300, cancelable: true),
159+
);
160+
element.dispatchEvent(event);
161+
expect(event.defaultPrevented, isTrue);
162+
}
163+
});
137164
}
138165

139166
void removeAllStyleElements() {

0 commit comments

Comments
 (0)