Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/checkable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,7 @@ class Checkable extends PrimaryRoleManager {
removeAttribute('aria-disabled');
removeAttribute('disabled');
}

@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
40 changes: 40 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,39 @@ class Dialog extends PrimaryRoleManager {
// names its own route an `aria-label` is used instead of `aria-describedby`.
addFocusManagement();
addLiveRegion();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this class be named Route instead? It took me a while to understand a dialog is corresponding to scopesRoute = true

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PrimaryRoleManager is called Dialog because it sets the ARIA role="dialog", which is the unique role identifier. I can rename it, but I'd prefer doing it in a separate PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If every route is a role="dialog", wouldn't the screen reader announce every MaterialPageRoute a dialog when focused? Moving this to a separate pr sgtm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently "dialog" is the only available ARIA role. A route expressed as a transition to a different screen is expressed on the web by changing the URL (which we already do via UrlStrategy) and updating the title of the page (which is expressed via the Title widget). So I think for routes-that-are-not-dialogs we could improve the situation if the framework communicated the difference somehow. Happy to collaborate on improving this.

// When a route/dialog shows up it is expected that the screen reader will
// focus on something inside it. There could be two possibilities:
//
// 1. The framework explicitly marked a node inside the dialog as focused
// via the `isFocusable` and `isFocused` flags. In this case, the node
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFocusable/isFocused are refering to keyboard focus. For a sighted user it is ok if some element in the middle of the page request keyboard focus and the system bring focus to there. For a visually impaired user, this will make them confused.

Should we always do (2) if screen reader is turned on.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It really depends on the type of dialog. For example for a role="alertdialog", it's very common to move focus into the Ok button since when the focus first moves into the alert dialog, the screen reader vocalizes the entire contents of the dialog. Of course, the choice to focus on the "Ok" dialog would ideally by done via an explicit autofocus rather than implicit focus. So maybe you may decide that that consideration might be less relevant for fallback behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the web we do not know if the screen reader is turned on, so we have to pick one option. I'm not sure if focusing on the first keyboard-focusable will produce good result, as there's no guarantee that the widget will be correct one, and landing in the middle of the dialog, the user will have to traverse backwards then forwards to find the widget they are interested in.

I think keeping the logic simple (i.e. what it is in the PR right now) while leaving the option to use autofocus on the framework should be flexible enough.

// will request focus directly and there's nothing to do on top of that.
// 2. No node inside the route takes focus explicitly. In this case, the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case this route is reveal when the previous top-most routes are popped, you can listen to the send focus event if we start sending event for web in framework.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. I've been getting updates from @Hangyujin about it. I'm planning to implementing for web, if/when it's ready on the framework side.

Copy link
Member

@hannah-hyj hannah-hyj Dec 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it has been implemented on ios and android now.
related PR: ios: #48252
framework: flutter/flutter#135771

// expectation is to look through all nodes in traversal order and focus
// on the first one.
semanticsObject.owner.addOneTimePostUpdateCallback(() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does the debouncing work if there are multiple dialogs created in a update

Copy link
Contributor Author

@yjbanov yjbanov Dec 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not considered it. I hope apps do not create multiple dialogs in a single update. It seems like a messy UX implementation. I think the framework removes semantics from all background routes anyway, so the engine would only observe the top-most one, no?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there are nested navigators or a pop up route like dropdownButtons that somehow open simultaneous , the former is probably more common than the latter.

Another case is that I do know customer money use scopeRoute + namesRoute combo when they are animating page change effect within one page, but they are targeting mobile.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Looking at the code, what will happen right now is that the last dialog to show up will "win" the focus because addOneTimePostUpdateCallback callbacks are processed in FIFO order. So whatever order the framework communicates dialogs, the last one to be initialized will win. So I think it should work, but there could be corner-cases. I'm willing to look into this as a follow-up. The current implementation covers all use-cases that we have in the a11y_assessments app.

if (semanticsObject.owner.hasNodeRequestingFocus) {
// Case 1: a node requested explicit focus. Nothing extra to do.
return;
}

// Case 2: nothing requested explicit focus. Focus on the first descendant.
_setDefaultFocus();
Comment on lines +35 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need to add a 3rd case:

  1. Nothing was focused before the semantics update, and nothing requested focus during this update, so leave everything as is.

This 3rd case will be common when Flutter is embedded inside another app, where the screen reader might be focusing somewhere else outside of the Flutter app. We don't want to steal that focus, do we?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If something in the embedded flutter view triggered a route change inside, we should bring the focus here I think.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other platform we send screen change accessibilityEvent and let OS decide whether to move the accessibility focus. so we are in an uncharted territory here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we have focusability at the FlutterView level, one possible middle ground could be:

/// Case 3: the route popped up inside a FlutterView that's not currently
            focused. Do not auto-focus inside the route because that could
            "steal" focus from an element outside the current FlutterView.

In the meantime, I agree with @chunhtai that if a route change happened, it is likely because the user took some action to push that route, and the most common outcome should be that a widget inside the new route takes focus. We can revisit when we have FlutterView focusability (currently worked on by @tugorez).

});
}

void _setDefaultFocus() {
semanticsObject.visitDepthFirstInTraversalOrder((SemanticsObject node) {
final PrimaryRoleManager? roleManager = node.primaryRole;
if (roleManager == null) {
return true;
}

// If the node does not take focus (e.g. focusing on it does not make
// sense at all). Despair not. Keep looking.
final bool didTakeFocus = roleManager.focusAsRouteDefault();
return !didTakeFocus;
});
}

@override
Expand Down Expand Up @@ -57,6 +90,13 @@ class Dialog extends PrimaryRoleManager {
routeName.semanticsObject.element.id,
);
}

@override
bool focusAsRouteDefault() {
// Dialogs are the ones that look inside themselves to find elements to
// focus on. It doesn't make sense to focus on the dialog itself.
return false;
}
}

/// Supplies a description for the nearest ancestor [Dialog].
Expand Down
68 changes: 58 additions & 10 deletions lib/web_ui/lib/src/engine/semantics/focusable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ class Focusable extends RoleManager {

final AccessibilityFocusManager _focusManager;

/// Requests focus as a result of a route (e.g. dialog) deciding that the node
/// managed by this class should be focused by default when nothing requests
/// focus explicitly.
///
/// This method of taking focus is different from the regular method of using
/// the [SemanticsObject.hasFocus] flag, as in this case the framework did not
/// explicitly request focus. Instead, the DOM element is being focus directly
/// programmatically, simulating the screen reader choosing a default element
/// to focus on.
Comment on lines +44 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we inform the framework about the element we chose here? That way, when the user hits tab, the framework would continue the traversal correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do inform the framework via a didGainAccessibilityFocus event, which happen by relevant nodes receiving the DOM focus event.

///
/// Returns `true` if the role manager took the focus. Returns `false` if
/// this role manager did not take the focus. The return value can be used to
/// decide whether to stop searching for a node that should take focus.
bool focusAsRouteDefault() {
owner.element.focus();
return true;
}

@override
void update() {
if (semanticsObject.isFocusable) {
Expand Down Expand Up @@ -84,6 +102,14 @@ class AccessibilityFocusManager {

_FocusTarget? _target;

// The last focus value set by this focus manager, used to prevent requesting
// focus on the same element repeatedly. Requesting focus on DOM elements is
// not an idempotent operation. If the element is already focused and focus is
// requested the browser will scroll to that element. However, scrolling is
// not this class' concern and so this class should avoid doing anything that
// would affect scrolling.
bool? _lastSetValue;

/// Whether this focus manager is managing a focusable target.
bool get isManaging => _target != null;

Expand Down Expand Up @@ -136,6 +162,7 @@ class AccessibilityFocusManager {
void stopManaging() {
final _FocusTarget? target = _target;
_target = null;
_lastSetValue = null;

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

target.element.removeEventListener('focus', target.domFocusListener);
target.element.removeEventListener('blur', target.domBlurListener);

// Blur the element after removing listeners. If this method is being called
// it indicates that the framework already knows that this node should not
// have focus, and there's no need to notify it.
target.element.blur();
}

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

if (target == null) {
// If this branch is being executed, there's a bug somewhere already, but
// it doesn't hurt to clean up old values anyway.
_lastSetValue = null;

// Nothing is being managed right now.
assert(() {
printWarning(
Expand All @@ -185,6 +211,32 @@ class AccessibilityFocusManager {
return;
}

if (value == _lastSetValue) {
// The focus is being changed to a value that's already been requested in
// the past. Do nothing.
return;
}
_lastSetValue = value;

if (value) {
_owner.willRequestFocus();
} else {
// Do not blur elements. Instead let the element be blurred by requesting
// focus elsewhere. Blurring elements is a very error-prone thing to do,
// as it is subject to non-local effects. Let's say the framework decides
// that a semantics node is currently not focused. That would lead to
// changeFocus(false) to be called. However, what if this node is inside
// a dialog, and nothing else in the dialog is focused. The Flutter
// framework expects that the screen reader will focus on the first (in
// traversal order) focusable element inside the dialog and send a
// didGainAccessibilityFocus action. Screen readers on the web do not do
// that, and so the web engine has to implement this behavior directly. So
// the dialog will look for a focusable element and request focus on it,
// but now there may be a race between this method unsetting the focus and
// the dialog requesting focus on the same element.
return;
}

// Delay the focus request until the final DOM structure is established
// because the element may not yet be attached to the DOM, or it may be
// reparented and lose focus again.
Expand All @@ -197,11 +249,7 @@ class AccessibilityFocusManager {
return;
}

if (value) {
target.element.focus();
} else {
target.element.blur();
}
target.element.focus();
});
}
}
3 changes: 3 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ class ImageRoleManager extends PrimaryRoleManager {
addTappable();
}

@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;

/// The element with role="img" and aria-label could block access to all
/// children elements, therefore create an auxiliary element and describe the
/// image in that if the semantic object have child nodes.
Expand Down
6 changes: 6 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/incrementable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ class Incrementable extends PrimaryRoleManager {
_focusManager.manage(semanticsObject.id, _element);
}

@override
bool focusAsRouteDefault() {
_element.focus();
return true;
}

/// The HTML element used to render semantics to the browser.
final DomHTMLInputElement _element = createDomHTMLInputElement();

Expand Down
3 changes: 3 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/link.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ class Link extends PrimaryRoleManager {
element.style.display = 'block';
return element;
}

@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
9 changes: 9 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/platform_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,13 @@ class PlatformViewRoleManager extends PrimaryRoleManager {
removeAttribute('aria-owns');
}
}

@override
bool focusAsRouteDefault() {
// It's unclear how it's possible to auto-focus on something inside a
// platform view without knowing what's in it. If the framework adds API for
// focusing on platform view internals, this method will be able to do more,
// but for now there's nothing to focus on.
return false;
}
}
3 changes: 3 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/scrollable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,7 @@ class Scrollable extends PrimaryRoleManager {
_gestureModeListener = null;
}
}

@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
Loading