Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 8ee3d53

Browse files
authored
[web] fix unexpected scrolling in semantics (#53922)
Always mark scrollable containers with `role="group"` to prevent the browser from merging all child elements into one giant string. Default to `preventScroll` to true across the web engine code, because it's a better default for Flutter than otherwise. Fixes flutter/flutter#130950
1 parent e5215e6 commit 8ee3d53

File tree

13 files changed

+59
-43
lines changed

13 files changed

+59
-43
lines changed

lib/web_ui/lib/src/engine/clipboard.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ class ExecCommandCopyStrategy implements CopyToClipboardStrategy {
184184
// See: https://developers.google.com/web/updates/2015/04/cut-and-copy-commands
185185
final DomHTMLTextAreaElement tempTextArea = _appendTemporaryTextArea();
186186
tempTextArea.value = text;
187-
tempTextArea.focus();
187+
tempTextArea.focusWithoutScroll();
188188
tempTextArea.select();
189189
bool result = false;
190190
try {

lib/web_ui/lib/src/engine/dom.dart

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -661,15 +661,26 @@ extension DomElementExtension on DomElement {
661661
external JSNumber? get _tabIndex;
662662
double? get tabIndex => _tabIndex?.toDartDouble;
663663

664+
/// Consider not exposing this method publicly. It defaults `preventScroll` to
665+
/// false, which is almost always wrong in Flutter. If you need to expose a
666+
/// method that focuses and scrolls to the element, give it a more specific
667+
/// and lengthy name, e.g. `focusAndScrollToElement`. See more details in
668+
/// [focusWithoutScroll].
664669
@JS('focus')
665670
external JSVoid _focus(JSAny options);
666671

667-
void focus({bool? preventScroll, bool? focusVisible}) {
668-
final Map<String, bool> options = <String, bool>{
669-
if (preventScroll != null) 'preventScroll': preventScroll,
670-
if (focusVisible != null) 'focusVisible': focusVisible,
671-
};
672-
_focus(options.toJSAnyDeep);
672+
static final JSAny _preventScrollOptions = <String, bool>{ 'preventScroll': true }.toJSAnyDeep;
673+
674+
/// Calls DOM `Element.focus` with `preventScroll` set to true.
675+
///
676+
/// This method exists because DOM `Element.focus` defaults to `preventScroll`
677+
/// set to false. This default browser behavior is almost always wrong in the
678+
/// Flutter context because the Flutter framework is in charge of scrolling
679+
/// all of the widget content. See, for example, this issue:
680+
///
681+
/// https://github.com/flutter/flutter/issues/130950
682+
void focusWithoutScroll() {
683+
_focus(_preventScrollOptions);
673684
}
674685

675686
@JS('scrollTop')

lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ final class ViewFocusBinding {
5151
if (state == ui.ViewFocusState.focused) {
5252
// Only move the focus to the flutter view if nothing inside it is focused already.
5353
if (viewId != _viewId(domDocument.activeElement)) {
54-
viewElement?.focus(preventScroll: true);
54+
viewElement?.focusWithoutScroll();
5555
}
5656
} else {
5757
viewElement?.blur();

lib/web_ui/lib/src/engine/semantics/focusable.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class Focusable extends RoleManager {
4949
/// decide whether to stop searching for a node that should take focus.
5050
bool focusAsRouteDefault() {
5151
_focusManager._lastEvent = AccessibilityFocusManagerEvent.requestedFocus;
52-
owner.element.focus();
52+
owner.element.focusWithoutScroll();
5353
return true;
5454
}
5555

@@ -279,7 +279,7 @@ class AccessibilityFocusManager {
279279
}
280280

281281
_lastEvent = AccessibilityFocusManagerEvent.requestedFocus;
282-
target.element.focus();
282+
target.element.focusWithoutScroll();
283283
});
284284
}
285285
}

lib/web_ui/lib/src/engine/semantics/incrementable.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class Incrementable extends PrimaryRoleManager {
6262

6363
@override
6464
bool focusAsRouteDefault() {
65-
_element.focus();
65+
_element.focusWithoutScroll();
6666
return true;
6767
}
6868

lib/web_ui/lib/src/engine/semantics/label_and_value.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ abstract final class LabelRepresentationBehavior {
9999
/// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
100100
void focusAsRouteDefault() {
101101
focusTarget.tabIndex = -1;
102-
focusTarget.focus();
102+
focusTarget.focusWithoutScroll();
103103
}
104104
}
105105

lib/web_ui/lib/src/engine/semantics/scrollable.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ class Scrollable extends PrimaryRoleManager {
2828
PrimaryRole.scrollable,
2929
semanticsObject,
3030
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
31-
);
31+
) {
32+
// Mark as group to prevent the browser from merging this element along with
33+
// all the children into one giant node. This is what happened with the
34+
// repro provided in https://github.com/flutter/flutter/issues/130950.
35+
setAriaRole('group');
36+
}
3237

3338
/// Disables browser-driven scrolling in the presence of pointer events.
3439
GestureModeCallback? _gestureModeListener;

lib/web_ui/lib/src/engine/semantics/text_field.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
162162
if (hasAutofillGroup) {
163163
placeForm();
164164
}
165-
activeDomElement.focus(preventScroll: true);
165+
activeDomElement.focusWithoutScroll();
166166
}
167167

168168
@override
@@ -219,7 +219,7 @@ class TextField extends PrimaryRoleManager {
219219

220220
@override
221221
bool focusAsRouteDefault() {
222-
editableElement.focus(preventScroll: true);
222+
editableElement.focusWithoutScroll();
223223
return true;
224224
}
225225

@@ -264,7 +264,7 @@ class TextField extends PrimaryRoleManager {
264264
semanticsObject.id, ui.SemanticsAction.focus, null);
265265
}));
266266
editableElement.addEventListener('click', createDomEventListener((DomEvent event) {
267-
editableElement.focus(preventScroll: true);
267+
editableElement.focusWithoutScroll();
268268
}));
269269
editableElement.addEventListener('blur', createDomEventListener((DomEvent event) {
270270
SemanticsTextEditingStrategy._instance?.deactivate(this);
@@ -283,7 +283,7 @@ class TextField extends PrimaryRoleManager {
283283
if (semanticsObject.hasFocus) {
284284
if (domDocument.activeElement != editableElement && semanticsObject.isEnabled) {
285285
semanticsObject.owner.addOneTimePostUpdateCallback(() {
286-
editableElement.focus(preventScroll: true);
286+
editableElement.focusWithoutScroll();
287287
});
288288
}
289289
SemanticsTextEditingStrategy._instance?.activate(this);

lib/web_ui/lib/src/engine/text_editing/text_editing.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,7 +1131,7 @@ class GloballyPositionedTextEditingStrategy extends DefaultTextEditingStrategy {
11311131
// only after placing it to the correct position. Hence autofill menu
11321132
// does not appear on top-left of the page.
11331133
// Refocus on the elements after applying the geometry.
1134-
focusedFormElement!.focus(preventScroll: true);
1134+
focusedFormElement!.focusWithoutScroll();
11351135
moveFocusToActiveDomElement();
11361136
}
11371137
}
@@ -1158,7 +1158,7 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy {
11581158
///
11591159
/// This method is similar to the [GloballyPositionedTextEditingStrategy].
11601160
/// The only part different: this method does not call `super.placeElement()`,
1161-
/// which in current state calls `domElement.focus(preventScroll: true)`.
1161+
/// which in current state calls `domElement.focusWithoutScroll()`.
11621162
///
11631163
/// Making an extra `focus` request causes flickering in Safari.
11641164
@override
@@ -1571,7 +1571,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements
15711571

15721572
/// Moves the focus to the [activeDomElement].
15731573
void moveFocusToActiveDomElement() {
1574-
activeDomElement.focus(preventScroll: true);
1574+
activeDomElement.focusWithoutScroll();
15751575
}
15761576

15771577
/// Move the focus to the given [EngineFlutterView] in the next timer event.
@@ -1589,7 +1589,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements
15891589
// move the focus, as it is likely that another widget already took the
15901590
// focus.
15911591
if (element == domDocument.activeElement) {
1592-
view?.dom.rootElement.focus(preventScroll: true);
1592+
view?.dom.rootElement.focusWithoutScroll();
15931593
}
15941594
if (removeElement) {
15951595
element.remove();

lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ void testMain() {
4141
test('The view is focusable but not reachable by keyboard when focused', () async {
4242
final EngineFlutterView view = createAndRegisterView(dispatcher);
4343

44-
view.dom.rootElement.focus();
44+
view.dom.rootElement.focusWithoutScroll();
4545

4646
// The root element should have a tabindex="-1" to make the flutter view
4747
// focusable but not reachable by the keyboard.
@@ -55,11 +55,11 @@ void testMain() {
5555
expect(view1.dom.rootElement.getAttribute('tabindex'), '0');
5656
expect(view2.dom.rootElement.getAttribute('tabindex'), '0');
5757

58-
view1.dom.rootElement.focus();
58+
view1.dom.rootElement.focusWithoutScroll();
5959
expect(view1.dom.rootElement.getAttribute('tabindex'), '-1');
6060
expect(view2.dom.rootElement.getAttribute('tabindex'), '0');
6161

62-
view2.dom.rootElement.focus();
62+
view2.dom.rootElement.focusWithoutScroll();
6363
expect(view1.dom.rootElement.getAttribute('tabindex'), '0');
6464
expect(view2.dom.rootElement.getAttribute('tabindex'), '-1');
6565

@@ -77,11 +77,11 @@ void testMain() {
7777
expect(view1.dom.rootElement.getAttribute('tabindex'), isNull);
7878
expect(view2.dom.rootElement.getAttribute('tabindex'), isNull);
7979

80-
view1.dom.rootElement.focus();
80+
view1.dom.rootElement.focusWithoutScroll();
8181
expect(view1.dom.rootElement.getAttribute('tabindex'), isNull);
8282
expect(view2.dom.rootElement.getAttribute('tabindex'), isNull);
8383

84-
view2.dom.rootElement.focus();
84+
view2.dom.rootElement.focusWithoutScroll();
8585
expect(view1.dom.rootElement.getAttribute('tabindex'), isNull);
8686
expect(view2.dom.rootElement.getAttribute('tabindex'), isNull);
8787

@@ -93,7 +93,7 @@ void testMain() {
9393
test('fires a focus event - a view was focused', () async {
9494
final EngineFlutterView view = createAndRegisterView(dispatcher);
9595

96-
view.dom.rootElement.focus();
96+
view.dom.rootElement.focusWithoutScroll();
9797

9898
expect(dispatchedViewFocusEvents, hasLength(1));
9999

@@ -105,7 +105,7 @@ void testMain() {
105105
test('fires a focus event - a view was unfocused', () async {
106106
final EngineFlutterView view = createAndRegisterView(dispatcher);
107107

108-
view.dom.rootElement.focus();
108+
view.dom.rootElement.focusWithoutScroll();
109109
view.dom.rootElement.blur();
110110

111111
expect(dispatchedViewFocusEvents, hasLength(2));
@@ -123,12 +123,12 @@ void testMain() {
123123
final EngineFlutterView view1 = createAndRegisterView(dispatcher);
124124
final EngineFlutterView view2 = createAndRegisterView(dispatcher);
125125

126-
view1.dom.rootElement.focus();
127-
view2.dom.rootElement.focus();
126+
view1.dom.rootElement.focusWithoutScroll();
127+
view2.dom.rootElement.focusWithoutScroll();
128128
// The statements simulate the user pressing shift + tab in the keyboard.
129129
// Synthetic keyboard events do not trigger focus changes.
130130
domDocument.body!.pressTabKey(shift: true);
131-
view1.dom.rootElement.focus();
131+
view1.dom.rootElement.focusWithoutScroll();
132132
domDocument.body!.releaseTabKey();
133133

134134
expect(dispatchedViewFocusEvents, hasLength(3));
@@ -150,8 +150,8 @@ void testMain() {
150150
final EngineFlutterView view1 = createAndRegisterView(dispatcher);
151151
final EngineFlutterView view2 = createAndRegisterView(dispatcher);
152152

153-
view1.dom.rootElement.focus();
154-
view2.dom.rootElement.focus();
153+
view1.dom.rootElement.focusWithoutScroll();
154+
view2.dom.rootElement.focusWithoutScroll();
155155
view2.dom.rootElement.blur();
156156

157157
expect(dispatchedViewFocusEvents, hasLength(3));
@@ -254,7 +254,7 @@ void testMain() {
254254
final EngineFlutterView view = createAndRegisterView(dispatcher);
255255

256256
view.dom.rootElement.append(input);
257-
input.focus();
257+
input.focusWithoutScroll();
258258

259259
dispatcher.requestViewFocusChange(
260260
viewId: view.viewId,

0 commit comments

Comments
 (0)