Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[web:a11y] support dialogs described by descendants #42108

Merged
merged 5 commits into from
May 19, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/checkable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class Checkable extends RoleManager {

@override
void dispose() {
super.dispose();
switch (_kind) {
case _CheckableKind.checkbox:
semanticsObject.setAriaRole('checkbox', false);
Expand Down
92 changes: 76 additions & 16 deletions lib/web_ui/lib/src/engine/semantics/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,86 @@ class Dialog extends RoleManager {
Dialog(SemanticsObject semanticsObject) : super(Role.dialog, semanticsObject);

@override
void dispose() {
semanticsObject.element.removeAttribute('aria-label');
semanticsObject.clearAriaRole();
void update() {
// If semantic object corresponding to the dialog also provides the label
// for itself it is applied as `aria-label`. See also [describeBy].
if (semanticsObject.namesRoute) {
final String? label = semanticsObject.label;
assert(() {
if (label == null || label.trim().isEmpty) {
printWarning(
'Semantic node ${semanticsObject.id} had both scopesRoute and '
'namesRoute set, indicating a self-labelled dialog, but it is '
'missing the label. A dialog should be labelled either by setting '
'namesRoute on itself and providing a label, or by containing a '
'child node with namesRoute that can describe it with its content.'
);
}
return true;
}());
semanticsObject.element.setAttribute('aria-label', label ?? '');
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't see where the aria-label is set for the case where namesRoute is a descendant of scopesRoute.

Does it happen through the LabelAndValue role manager? If yes, then why are we doing it in the Dialog role manager too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, we don't set aria-label when a descendant provides a description. We use aria-describedby="ID_OF_DESCENDANT" instead.

Copy link
Contributor

Choose a reason for hiding this comment

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

And then that descendant is supposed to set the aria-label, right? I don't see RouteName doing it, so I'm assuming that LabelAndValue is doing it. But LabelAndValue should be doing it for Dialog as well. Maybe I'm missing something 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.

Nope, the final state is like this:

<div role="dialog" aria-describedby="describer">
  <div id="describer">Dialog description</div>
</div>

No aria-label.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I see. I was confused by this test:

expectSemanticsTree('''
  <sem aria-describedby="flt-semantic-node-2" style="$rootSemanticStyle">
    <sem-c>
      <sem>
        <sem-c>
          <sem aria-label="$label"></sem>
        </sem-c>
      </sem>
    </sem-c>
  </sem>
''');

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, it's because the descendant can provide the description by applying aria-label to itself, but it can also be a group of elements that provide a description together.

semanticsObject.setAriaRole('dialog', true);
}
}

/// Sets the description of this dialog based on a [RouteName] descendant
/// node, unless the dialog provides its own label.
void describeBy(RouteName routeName) {
if (semanticsObject.namesRoute) {
// The dialog provides its own label, which takes precedence.
return;
}

semanticsObject.setAriaRole('dialog', true);
semanticsObject.element.setAttribute(
'aria-describedby',
routeName.semanticsObject.element.id,
);
}
}

/// Supplies a description for the nearest ancestor [Dialog].
class RouteName extends RoleManager {
RouteName(SemanticsObject semanticsObject) : super(Role.routeName, semanticsObject);

Dialog? _dialog;

@override
void update() {
final String? label = semanticsObject.label;
assert(() {
if (label == null || label.trim().isEmpty) {
printWarning(
'Semantic node ${semanticsObject.id} was assigned dialog role, but '
'is missing a label. A dialog should contain a label so that a '
'screen reader can communicate to the user that a dialog appeared '
'and a user action is requested.'
);
// NOTE(yjbanov): this does not handle the case when the node structure
// changes such that this RouteName is no longer attached to the same
// dialog. While this is technically expressible using the semantics API,
// after discussing this case with customers I decided that this case is not
// interesting enough to support. A tree restructure like this is likely to
// confuse screen readers, and it would add complexity to the engine's
// semantics code. Since reparenting can be done with no update to either
// the Dialog or RouteName we'd have to scan intermediate nodes for
// structural changes.
if (semanticsObject.isLabelDirty) {
final Dialog? dialog = _dialog;
if (dialog != null) {
// Already attached to a dialog, just update the description.
dialog.describeBy(this);
} else {
// Setting the label for the first time. Wait for the DOM tree to be
// established, then find the nearest dialog and update its label.
semanticsObject.owner.addOneTimePostUpdateCallback(() {
if (!isDisposed) {
_lookUpNearestAncestorDialog();
_dialog?.describeBy(this);
}
});
}
return true;
}());
semanticsObject.element.setAttribute('aria-label', label ?? '');
semanticsObject.setAriaRole('dialog', true);
}
}

void _lookUpNearestAncestorDialog() {
SemanticsObject? parent = semanticsObject.parent;
while (parent != null && !parent.hasRole(Role.dialog)) {
parent = parent.parent;
}
if (parent != null && parent.hasRole(Role.dialog)) {
_dialog = parent.getRole<Dialog>(Role.dialog);
}
}
}
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/focusable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Focusable extends RoleManager {

@override
void dispose() {
super.dispose();
_focusManager.stopManaging();
}
}
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class ImageRoleManager extends RoleManager {

@override
void dispose() {
super.dispose();
_cleanUpAuxiliaryElement();
_cleanupElement();
}
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/incrementable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class Incrementable extends RoleManager {
@override
void dispose() {
assert(_gestureModeListener != null);
super.dispose();
_focusManager.stopManaging();
semanticsObject.owner.removeGestureModeListener(_gestureModeListener);
_gestureModeListener = null;
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/label_and_value.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class LabelAndValue extends RoleManager {

@override
void dispose() {
super.dispose();
_cleanUpDom();
}
}
4 changes: 0 additions & 4 deletions lib/web_ui/lib/src/engine/semantics/live_region.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,4 @@ class LiveRegion extends RoleManager {
}
}
}

@override
void dispose() {
}
}
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/scrollable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ class Scrollable extends RoleManager {

@override
void dispose() {
super.dispose();
final DomCSSStyleDeclaration style = semanticsObject.element.style;
assert(_gestureModeListener != null);
style.removeProperty('overflowY');
Expand Down
129 changes: 108 additions & 21 deletions lib/web_ui/lib/src/engine/semantics/semantics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import 'dart:math' as math;
import 'dart:typed_data';

import 'package:meta/meta.dart';
import 'package:ui/ui.dart' as ui;

import '../../engine.dart' show registerHotRestartListener;
Expand Down Expand Up @@ -365,15 +366,35 @@ enum Role {

/// Adds the "dialog" ARIA role to the node.
///
/// This corresponds to a semantics node that has both `scopesRoute` and
/// `namesRoute` bits set. While in Flutter a named route is not necessarily a
/// dialog, this is the closest analog on the web.
/// This corresponds to a semantics node that has `scopesRoute` bit set. While
/// in Flutter a named route is not necessarily a dialog, this is the closest
/// analog on the web.
///
/// Why is `scopesRoute` alone not sufficient? Because Flutter can create
/// routes that are not logically dialogs and there's nothing interesting to
/// announce to the user. For example, a modal barrier has `scopesRoute` set
/// but marking it as a dialog would be wrong.
/// There are 3 possible situations:
///
/// * The node also has the `namesRoute` bit set. This means that the node's
/// `label` describes the dialog, which can be expressed by adding the
/// `aria-label` attribute.
/// * A descendant node has the `namesRoute` bit set. This means that the
/// child's content describes the dialog. The child may simply be labelled,
/// or it may be a subtree of nodes that describe the dialog together. The
/// nearest HTML equivalent is `aria-describedby`. The child acquires the
/// [routeName] role, which manages the relevant ARIA attributes.
/// * There is no `namesRoute` bit anywhere in the sub-tree rooted at the
/// current node. In this case it's likely not a dialog at all, and the node
/// should not get a label or the "dialog" role. It's just a group of
/// children. For example, a modal barrier has `scopesRoute` set but marking
/// it as a dialog would be wrong.
dialog,

/// Provides a description for an ancestor dialog.
///
/// This role is assigned to nodes that have `namesRoute` set but not
/// `scopesRoute`. When both flags are set the node only gets the dialog
/// role (see [dialog]).
///
/// If the ancestor dialog is missing, this role does nothing useful.
routeName,
}

/// A function that creates a [RoleManager] for a [SemanticsObject].
Expand All @@ -390,6 +411,7 @@ final Map<Role, RoleManagerFactory> _roleFactories = <Role, RoleManagerFactory>{
Role.image: (SemanticsObject object) => ImageRoleManager(object),
Role.liveRegion: (SemanticsObject object) => LiveRegion(object),
Role.dialog: (SemanticsObject object) => Dialog(object),
Role.routeName: (SemanticsObject object) => RouteName(object),
};

/// Provides the functionality associated with the role of the given
Expand All @@ -416,14 +438,21 @@ abstract class RoleManager {
/// minimum DOM updates.
void update();

/// Whether this role manager was disposed of.
bool get isDisposed => _isDisposed;
bool _isDisposed = false;

/// Called when [semanticsObject] is removed, or when it changes its role such
/// that this role is no longer relevant.
///
/// This method is expected to remove role-specific functionality from the
/// DOM. In particular, this method is the appropriate place to call
/// [EngineSemanticsOwner.removeGestureModeListener] if this role reponds to
/// gesture mode changes.
void dispose();
@mustCallSuper
void dispose() {
_isDisposed = true;
}
}

/// Instantiation of a framework-side semantics node in the DOM.
Expand Down Expand Up @@ -827,6 +856,15 @@ class SemanticsObject {
DomElement? _childContainerElement;

/// The parent of this semantics object.
///
/// This value is not final until the tree is finalized. It is not safe to
/// rely on this value in the middle of a semantics tree update. It is safe to
/// use this value in post-update callback (see [SemanticsUpdatePhase] and
/// [EngineSemanticsOwner.addOneTimePostUpdateCallback]).
SemanticsObject? get parent {
assert(owner.phase == SemanticsUpdatePhase.postUpdate);
return _parent;
}
SemanticsObject? _parent;

/// Whether this node currently has a given [SemanticsFlag].
Expand Down Expand Up @@ -881,14 +919,15 @@ class SemanticsObject {
!hasAction(ui.SemanticsAction.tap) &&
!hasFlag(ui.SemanticsFlag.isButton);

/// Whether this node should be treated as an ARIA dialog.
/// Whether this node defines a scope for a route.
///
/// See also [Role.dialog].
bool get isDialog {
final bool scopesRoute = hasFlag(ui.SemanticsFlag.scopesRoute);
final bool namesRoute = hasFlag(ui.SemanticsFlag.namesRoute);
return scopesRoute && namesRoute;
}
bool get scopesRoute => hasFlag(ui.SemanticsFlag.scopesRoute);

/// Whether this node describes a route.
///
/// See also [Role.dialog].
bool get namesRoute => hasFlag(ui.SemanticsFlag.namesRoute);

/// Whether this object carry enabled/disabled state (and if so whether it is
/// enabled).
Expand Down Expand Up @@ -1276,7 +1315,20 @@ class SemanticsObject {
/// spec:
///
/// > A map literal is ordered: iterating over the keys and/or values of the maps always happens in the order the keys appeared in the source code.
final Map<Role, RoleManager?> _roleManagers = <Role, RoleManager?>{};
final Map<Role, RoleManager> _roleManagers = <Role, RoleManager>{};

/// The mapping of roles to role managers.
///
/// This getter is only meant for testing.
Map<Role, RoleManager> get debugRoleManagers => _roleManagers;

/// Returns if this node has the given [role].
bool hasRole(Role role) => _roleManagers.containsKey(role);

/// Returns the role manager for the given [role] attached to this node.
///
/// If [hasRole] is false for the given [role], throws an error.
R getRole<R extends RoleManager>(Role role) => _roleManagers[role]! as R;

/// Returns the role manager for the given [role].
///
Expand All @@ -1287,10 +1339,11 @@ class SemanticsObject {
/// the lifecycles of [RoleManager] objects.
void _updateRoles() {
// Some role managers manage labels themselves for various role-specific reasons.
final bool managesOwnLabel = isTextField || isDialog || isVisualOnly;
final bool managesOwnLabel = isTextField || scopesRoute || isVisualOnly;
_updateRole(Role.labelAndValue, (hasLabel || hasValue || hasTooltip) && !managesOwnLabel);

_updateRole(Role.dialog, isDialog);
_updateRole(Role.dialog, scopesRoute);
_updateRole(Role.routeName, namesRoute && !scopesRoute);
_updateRole(Role.textField, isTextField);

// The generic `Focusable` role manager can be used for everything except
Expand Down Expand Up @@ -1500,6 +1553,30 @@ enum GestureMode {
browserGestures,
}

/// The current phase of the semantic update.
enum SemanticsUpdatePhase {
/// No update is in progress.
///
/// When the semantics owner receives an update, it enters the [updating]
/// phase from the idle phase.
idle,

/// Updating individual [SemanticsObject] nodes by calling
/// [RoleManager.update] and fixing parent-child relationships.
///
/// After this phase is done, the owner enters the [postUpdate] phase.
updating,

/// Post-update callbacks are being called.
///
/// At this point all nodes have been updated, the parent child hierarchy has
/// been established, the DOM tree is in sync with the semantics tree, and
/// [RoleManager.dispose] has been called on removed nodes.
///
/// After this phase is done, the owner switches back to [idle].
postUpdate,
}

/// The top-level service that manages everything semantics-related.
class EngineSemanticsOwner {
EngineSemanticsOwner._() {
Expand Down Expand Up @@ -1528,6 +1605,10 @@ class EngineSemanticsOwner {
_instance = null;
}

/// The current update phase of this semantics owner.
SemanticsUpdatePhase get phase => _phase;
SemanticsUpdatePhase _phase = SemanticsUpdatePhase.idle;

final Map<int, SemanticsObject> _semanticsTree = <int, SemanticsObject>{};

/// Map [SemanticsObject.id] to parent [SemanticsObject] it was attached to
Expand Down Expand Up @@ -1604,11 +1685,16 @@ class EngineSemanticsOwner {
_detachments = <SemanticsObject>[];
_attachments = <int, SemanticsObject>{};

if (_oneTimePostUpdateCallbacks.isNotEmpty) {
for (final ui.VoidCallback callback in _oneTimePostUpdateCallbacks) {
callback();
_phase = SemanticsUpdatePhase.postUpdate;
try {
if (_oneTimePostUpdateCallbacks.isNotEmpty) {
for (final ui.VoidCallback callback in _oneTimePostUpdateCallbacks) {
callback();
}
_oneTimePostUpdateCallbacks = <ui.VoidCallback>[];
}
_oneTimePostUpdateCallbacks = <ui.VoidCallback>[];
} finally {
_phase = SemanticsUpdatePhase.idle;
}
}

Expand Down Expand Up @@ -1876,6 +1962,7 @@ class EngineSemanticsOwner {
}
}

_phase = SemanticsUpdatePhase.updating;
final SemanticsUpdate update = uiUpdate as SemanticsUpdate;

// First, update each object's information about itself. This information is
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/tappable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class Tappable extends RoleManager {

@override
void dispose() {
super.dispose();
_stopListening();
semanticsObject.setAriaRole('button', false);
}
Expand Down