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

Commit b5ba500

Browse files
authored
[web] autofocus in new routes (#47727)
Fixes flutter/flutter#138371 When a new route pops up the expectation is that the screen reader focuses on something in the new route. Since routes typically result in DOM nodes being replaced, the current effect is that the screen reader simply unfocuses from the page, causing the user to have to refocus on back on the page and look for elements to interact with, which is a poor user experience. The current workaround is to use `autofocus`, but that doesn't scale as it's easy to forget, and if the route in question is maintained by a different person you may not even have enough control over it to set `autofocus` on anything. For example, this is the case with Flutter's default date picker. All you have is `showDatePicker` and there's no way to control the focus. With this change the route (managed by the `Dialog` primary role) will check if a widget requested explicit focus (perhaps using `autofocus`), and if not, looks for the first descendant that a screen reader can focus on, and requests focus on it. The auto-focused element does not have to be literally focusable. For example, plain `Text` nodes do not have input focus (i.e. they are not `isFocusable`) but screen readers can still focus on them. If such an element is found, the web engine requests that the browser move focus to it programmatically (`element.focus()`), which causes the screen reader to move the a11y focus to it as well, but it sets `tabindex=-1` so the element is not focusable via keyboard or mouse.
1 parent 1b1b2a1 commit b5ba500

File tree

12 files changed

+525
-26
lines changed

12 files changed

+525
-26
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,7 @@ class Checkable extends PrimaryRoleManager {
102102
removeAttribute('aria-disabled');
103103
removeAttribute('disabled');
104104
}
105+
106+
@override
107+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
105108
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,39 @@ class Dialog extends PrimaryRoleManager {
1616
// names its own route an `aria-label` is used instead of `aria-describedby`.
1717
addFocusManagement();
1818
addLiveRegion();
19+
20+
// When a route/dialog shows up it is expected that the screen reader will
21+
// focus on something inside it. There could be two possibilities:
22+
//
23+
// 1. The framework explicitly marked a node inside the dialog as focused
24+
// via the `isFocusable` and `isFocused` flags. In this case, the node
25+
// will request focus directly and there's nothing to do on top of that.
26+
// 2. No node inside the route takes focus explicitly. In this case, the
27+
// expectation is to look through all nodes in traversal order and focus
28+
// on the first one.
29+
semanticsObject.owner.addOneTimePostUpdateCallback(() {
30+
if (semanticsObject.owner.hasNodeRequestingFocus) {
31+
// Case 1: a node requested explicit focus. Nothing extra to do.
32+
return;
33+
}
34+
35+
// Case 2: nothing requested explicit focus. Focus on the first descendant.
36+
_setDefaultFocus();
37+
});
38+
}
39+
40+
void _setDefaultFocus() {
41+
semanticsObject.visitDepthFirstInTraversalOrder((SemanticsObject node) {
42+
final PrimaryRoleManager? roleManager = node.primaryRole;
43+
if (roleManager == null) {
44+
return true;
45+
}
46+
47+
// If the node does not take focus (e.g. focusing on it does not make
48+
// sense at all). Despair not. Keep looking.
49+
final bool didTakeFocus = roleManager.focusAsRouteDefault();
50+
return !didTakeFocus;
51+
});
1952
}
2053

2154
@override
@@ -57,6 +90,13 @@ class Dialog extends PrimaryRoleManager {
5790
routeName.semanticsObject.element.id,
5891
);
5992
}
93+
94+
@override
95+
bool focusAsRouteDefault() {
96+
// Dialogs are the ones that look inside themselves to find elements to
97+
// focus on. It doesn't make sense to focus on the dialog itself.
98+
return false;
99+
}
60100
}
61101

62102
/// Supplies a description for the nearest ancestor [Dialog].

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

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ class Focusable extends RoleManager {
3434

3535
final AccessibilityFocusManager _focusManager;
3636

37+
/// Requests focus as a result of a route (e.g. dialog) deciding that the node
38+
/// managed by this class should be focused by default when nothing requests
39+
/// focus explicitly.
40+
///
41+
/// This method of taking focus is different from the regular method of using
42+
/// the [SemanticsObject.hasFocus] flag, as in this case the framework did not
43+
/// explicitly request focus. Instead, the DOM element is being focus directly
44+
/// programmatically, simulating the screen reader choosing a default element
45+
/// to focus on.
46+
///
47+
/// Returns `true` if the role manager took the focus. Returns `false` if
48+
/// this role manager did not take the focus. The return value can be used to
49+
/// decide whether to stop searching for a node that should take focus.
50+
bool focusAsRouteDefault() {
51+
owner.element.focus();
52+
return true;
53+
}
54+
3755
@override
3856
void update() {
3957
if (semanticsObject.isFocusable) {
@@ -84,6 +102,14 @@ class AccessibilityFocusManager {
84102

85103
_FocusTarget? _target;
86104

105+
// The last focus value set by this focus manager, used to prevent requesting
106+
// focus on the same element repeatedly. Requesting focus on DOM elements is
107+
// not an idempotent operation. If the element is already focused and focus is
108+
// requested the browser will scroll to that element. However, scrolling is
109+
// not this class' concern and so this class should avoid doing anything that
110+
// would affect scrolling.
111+
bool? _lastSetValue;
112+
87113
/// Whether this focus manager is managing a focusable target.
88114
bool get isManaging => _target != null;
89115

@@ -136,6 +162,7 @@ class AccessibilityFocusManager {
136162
void stopManaging() {
137163
final _FocusTarget? target = _target;
138164
_target = null;
165+
_lastSetValue = null;
139166

140167
if (target == null) {
141168
/// Nothing is being managed. Just return.
@@ -144,11 +171,6 @@ class AccessibilityFocusManager {
144171

145172
target.element.removeEventListener('focus', target.domFocusListener);
146173
target.element.removeEventListener('blur', target.domBlurListener);
147-
148-
// Blur the element after removing listeners. If this method is being called
149-
// it indicates that the framework already knows that this node should not
150-
// have focus, and there's no need to notify it.
151-
target.element.blur();
152174
}
153175

154176
void _setFocusFromDom(bool acquireFocus) {
@@ -174,6 +196,10 @@ class AccessibilityFocusManager {
174196
final _FocusTarget? target = _target;
175197

176198
if (target == null) {
199+
// If this branch is being executed, there's a bug somewhere already, but
200+
// it doesn't hurt to clean up old values anyway.
201+
_lastSetValue = null;
202+
177203
// Nothing is being managed right now.
178204
assert(() {
179205
printWarning(
@@ -185,6 +211,32 @@ class AccessibilityFocusManager {
185211
return;
186212
}
187213

214+
if (value == _lastSetValue) {
215+
// The focus is being changed to a value that's already been requested in
216+
// the past. Do nothing.
217+
return;
218+
}
219+
_lastSetValue = value;
220+
221+
if (value) {
222+
_owner.willRequestFocus();
223+
} else {
224+
// Do not blur elements. Instead let the element be blurred by requesting
225+
// focus elsewhere. Blurring elements is a very error-prone thing to do,
226+
// as it is subject to non-local effects. Let's say the framework decides
227+
// that a semantics node is currently not focused. That would lead to
228+
// changeFocus(false) to be called. However, what if this node is inside
229+
// a dialog, and nothing else in the dialog is focused. The Flutter
230+
// framework expects that the screen reader will focus on the first (in
231+
// traversal order) focusable element inside the dialog and send a
232+
// didGainAccessibilityFocus action. Screen readers on the web do not do
233+
// that, and so the web engine has to implement this behavior directly. So
234+
// the dialog will look for a focusable element and request focus on it,
235+
// but now there may be a race between this method unsetting the focus and
236+
// the dialog requesting focus on the same element.
237+
return;
238+
}
239+
188240
// Delay the focus request until the final DOM structure is established
189241
// because the element may not yet be attached to the DOM, or it may be
190242
// reparented and lose focus again.
@@ -197,11 +249,7 @@ class AccessibilityFocusManager {
197249
return;
198250
}
199251

200-
if (value) {
201-
target.element.focus();
202-
} else {
203-
target.element.blur();
204-
}
252+
target.element.focus();
205253
});
206254
}
207255
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ class ImageRoleManager extends PrimaryRoleManager {
2323
addTappable();
2424
}
2525

26+
@override
27+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
28+
2629
/// The element with role="img" and aria-label could block access to all
2730
/// children elements, therefore create an auxiliary element and describe the
2831
/// image in that if the semantic object have child nodes.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ class Incrementable extends PrimaryRoleManager {
5959
_focusManager.manage(semanticsObject.id, _element);
6060
}
6161

62+
@override
63+
bool focusAsRouteDefault() {
64+
_element.focus();
65+
return true;
66+
}
67+
6268
/// The HTML element used to render semantics to the browser.
6369
final DomHTMLInputElement _element = createDomHTMLInputElement();
6470

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,7 @@ class Link extends PrimaryRoleManager {
1818
element.style.display = 'block';
1919
return element;
2020
}
21+
22+
@override
23+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
2124
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,13 @@ class PlatformViewRoleManager extends PrimaryRoleManager {
3838
removeAttribute('aria-owns');
3939
}
4040
}
41+
42+
@override
43+
bool focusAsRouteDefault() {
44+
// It's unclear how it's possible to auto-focus on something inside a
45+
// platform view without knowing what's in it. If the framework adds API for
46+
// focusing on platform view internals, this method will be able to do more,
47+
// but for now there's nothing to focus on.
48+
return false;
49+
}
4150
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,7 @@ class Scrollable extends PrimaryRoleManager {
239239
_gestureModeListener = null;
240240
}
241241
}
242+
243+
@override
244+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
242245
}

0 commit comments

Comments
 (0)