diff --git a/lib/web_ui/lib/src/engine/semantics/checkable.dart b/lib/web_ui/lib/src/engine/semantics/checkable.dart index 8704c771b2805..e6f5b2efcc223 100644 --- a/lib/web_ui/lib/src/engine/semantics/checkable.dart +++ b/lib/web_ui/lib/src/engine/semantics/checkable.dart @@ -49,23 +49,25 @@ _CheckableKind _checkableKindFromSemanticsFlag( /// See also [ui.SemanticsFlag.hasCheckedState], [ui.SemanticsFlag.isChecked], /// [ui.SemanticsFlag.isInMutuallyExclusiveGroup], [ui.SemanticsFlag.isToggled], /// [ui.SemanticsFlag.hasToggledState] -class Checkable extends RoleManager { +class Checkable extends PrimaryRoleManager { Checkable(SemanticsObject semanticsObject) : _kind = _checkableKindFromSemanticsFlag(semanticsObject), - super(Role.checkable, semanticsObject); + super.withBasics(PrimaryRole.checkable, semanticsObject); final _CheckableKind _kind; @override void update() { + super.update(); + if (semanticsObject.isFlagsDirty) { switch (_kind) { case _CheckableKind.checkbox: - semanticsObject.setAriaRole('checkbox', true); + semanticsObject.setAriaRole('checkbox'); case _CheckableKind.radio: - semanticsObject.setAriaRole('radio', true); + semanticsObject.setAriaRole('radio'); case _CheckableKind.toggle: - semanticsObject.setAriaRole('switch', true); + semanticsObject.setAriaRole('switch'); } /// Adding disabled and aria-disabled attribute to notify the assistive @@ -85,14 +87,6 @@ class Checkable extends RoleManager { @override void dispose() { super.dispose(); - switch (_kind) { - case _CheckableKind.checkbox: - semanticsObject.setAriaRole('checkbox', false); - case _CheckableKind.radio: - semanticsObject.setAriaRole('radio', false); - case _CheckableKind.toggle: - semanticsObject.setAriaRole('switch', false); - } _removeDisabledAttribute(); } diff --git a/lib/web_ui/lib/src/engine/semantics/dialog.dart b/lib/web_ui/lib/src/engine/semantics/dialog.dart index df2c743d4ad3f..7e8f58ec6c73d 100644 --- a/lib/web_ui/lib/src/engine/semantics/dialog.dart +++ b/lib/web_ui/lib/src/engine/semantics/dialog.dart @@ -9,11 +9,19 @@ import '../util.dart'; /// Provides accessibility for dialogs. /// /// See also [Role.dialog]. -class Dialog extends RoleManager { - Dialog(SemanticsObject semanticsObject) : super(Role.dialog, semanticsObject); +class Dialog extends PrimaryRoleManager { + Dialog(SemanticsObject semanticsObject) : super.blank(PrimaryRole.dialog, semanticsObject) { + // The following secondary roles can coexist with dialog. Generic `RouteName` + // and `LabelAndValue` are not used by this role because when the dialog + // names its own route an `aria-label` is used instead of `aria-describedby`. + addFocusManagement(); + addLiveRegion(); + } @override void update() { + super.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) { @@ -31,7 +39,7 @@ class Dialog extends RoleManager { return true; }()); semanticsObject.element.setAttribute('aria-label', label ?? ''); - semanticsObject.setAriaRole('dialog', true); + semanticsObject.setAriaRole('dialog'); } } @@ -43,7 +51,7 @@ class Dialog extends RoleManager { return; } - semanticsObject.setAriaRole('dialog', true); + semanticsObject.setAriaRole('dialog'); semanticsObject.element.setAttribute( 'aria-describedby', routeName.semanticsObject.element.id, @@ -88,11 +96,11 @@ class RouteName extends RoleManager { void _lookUpNearestAncestorDialog() { SemanticsObject? parent = semanticsObject.parent; - while (parent != null && !parent.hasRole(Role.dialog)) { + while (parent != null && parent.primaryRole?.role != PrimaryRole.dialog) { parent = parent.parent; } - if (parent != null && parent.hasRole(Role.dialog)) { - _dialog = parent.getRole(Role.dialog); + if (parent != null && parent.primaryRole?.role == PrimaryRole.dialog) { + _dialog = parent.primaryRole! as Dialog; } } } diff --git a/lib/web_ui/lib/src/engine/semantics/image.dart b/lib/web_ui/lib/src/engine/semantics/image.dart index e867544439d0a..0f0eb298b0ad3 100644 --- a/lib/web_ui/lib/src/engine/semantics/image.dart +++ b/lib/web_ui/lib/src/engine/semantics/image.dart @@ -10,9 +10,18 @@ import 'semantics.dart'; /// Uses aria img role to convey this semantic information to the element. /// /// Screen-readers takes advantage of "aria-label" to describe the visual. -class ImageRoleManager extends RoleManager { +class ImageRoleManager extends PrimaryRoleManager { ImageRoleManager(SemanticsObject semanticsObject) - : super(Role.image, semanticsObject); + : super.blank(PrimaryRole.image, semanticsObject) { + // The following secondary roles can coexist with images. `LabelAndValue` is + // not used because this role manager uses special auxiliary elements to + // supply ARIA labels. + // TODO(yjbanov): reevaluate usage of aux elements, https://github.com/flutter/flutter/issues/129317 + addFocusManagement(); + addLiveRegion(); + addRouteName(); + addTappable(); + } /// The element with role="img" and aria-label could block access to all /// children elements, therefore create an auxiliary element and describe the @@ -21,6 +30,8 @@ class ImageRoleManager extends RoleManager { @override void update() { + super.update(); + if (semanticsObject.isVisualOnly && semanticsObject.hasChildren) { if (_auxiliaryImageElement == null) { _auxiliaryImageElement = domDocument.createElement('flt-semantics-img'); @@ -44,7 +55,7 @@ class ImageRoleManager extends RoleManager { _auxiliaryImageElement!.setAttribute('role', 'img'); _setLabel(_auxiliaryImageElement); } else if (semanticsObject.isVisualOnly) { - semanticsObject.setAriaRole('img', true); + semanticsObject.setAriaRole('img'); _setLabel(semanticsObject.element); _cleanUpAuxiliaryElement(); } else { @@ -67,7 +78,6 @@ class ImageRoleManager extends RoleManager { } void _cleanupElement() { - semanticsObject.setAriaRole('img', false); semanticsObject.element.removeAttribute('aria-label'); } diff --git a/lib/web_ui/lib/src/engine/semantics/incrementable.dart b/lib/web_ui/lib/src/engine/semantics/incrementable.dart index f58e2a8eddc25..f1de98d026982 100644 --- a/lib/web_ui/lib/src/engine/semantics/incrementable.dart +++ b/lib/web_ui/lib/src/engine/semantics/incrementable.dart @@ -18,10 +18,17 @@ import 'semantics.dart'; /// The input element is disabled whenever the gesture mode switches to pointer /// events. This is to prevent the browser from taking over drag gestures. Drag /// gestures must be interpreted by the Flutter framework. -class Incrementable extends RoleManager { +class Incrementable extends PrimaryRoleManager { Incrementable(SemanticsObject semanticsObject) : _focusManager = AccessibilityFocusManager(semanticsObject.owner), - super(Role.incrementable, semanticsObject) { + super.blank(PrimaryRole.incrementable, semanticsObject) { + // The following generic roles can coexist with incrementables. Generic focus + // management is not used by this role because the root DOM element is not + // the one being focused on, but the internal `` element. + addLiveRegion(); + addRouteName(); + addLabelAndValue(); + semanticsObject.element.append(_element); _element.type = 'range'; _element.setAttribute('role', 'slider'); @@ -80,6 +87,8 @@ class Incrementable extends RoleManager { @override void update() { + super.update(); + switch (semanticsObject.owner.gestureMode) { case GestureMode.browserGestures: _enableBrowserGestureHandling(); diff --git a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart index 087b30dc8394f..643f305fc7287 100644 --- a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart +++ b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:ui/ui.dart' as ui; - import '../dom.dart'; import 'semantics.dart'; @@ -66,33 +64,6 @@ class LabelAndValue extends RoleManager { semanticsObject.element .setAttribute('aria-label', combinedValue.toString()); - - // Assign one of three roles to the element: heading, group, text. - // - // - "group" is used when the node has children, irrespective of whether the - // node is marked as a header or not. This is because marking a group - // as a "heading" will prevent the AT from reaching its children. - // - "heading" is used when the framework explicitly marks the node as a - // heading and the node does not have children. - // - "text" is used by default. - // - // As of October 24, 2022, "text" only has effect on Safari. Other browsers - // ignore it. Setting role="text" prevents Safari from treating the element - // as a "group" or "empty group". Other browsers still announce it as - // "group" or "empty group". However, other options considered produced even - // worse results, such as: - // - // - Ignore the size of the element and size the focus ring to the text - // content, which is wrong. The HTML text size is irrelevant because - // Flutter renders into canvas, so the focus ring looks wrong. - // - Read out the same label multiple times. - if (semanticsObject.hasChildren) { - semanticsObject.setAriaRole('group', true); - } else if (semanticsObject.hasFlag(ui.SemanticsFlag.isHeader)) { - semanticsObject.setAriaRole('heading', true); - } else { - semanticsObject.setAriaRole('text', true); - } } void _cleanUpDom() { diff --git a/lib/web_ui/lib/src/engine/semantics/live_region.dart b/lib/web_ui/lib/src/engine/semantics/live_region.dart index 1be14a49e756a..239b9b600a13d 100644 --- a/lib/web_ui/lib/src/engine/semantics/live_region.dart +++ b/lib/web_ui/lib/src/engine/semantics/live_region.dart @@ -16,7 +16,7 @@ import 'semantics.dart'; /// no content will be read. class LiveRegion extends RoleManager { LiveRegion(SemanticsObject semanticsObject) - : super(Role.labelAndValue, semanticsObject); + : super(Role.liveRegion, semanticsObject); String? _lastAnnouncement; diff --git a/lib/web_ui/lib/src/engine/semantics/scrollable.dart b/lib/web_ui/lib/src/engine/semantics/scrollable.dart index dd0d66bb6d233..82b9308816ab3 100644 --- a/lib/web_ui/lib/src/engine/semantics/scrollable.dart +++ b/lib/web_ui/lib/src/engine/semantics/scrollable.dart @@ -22,9 +22,9 @@ import 'package:ui/ui.dart' as ui; /// contents is less than the size of the viewport the browser snaps /// "scrollTop" back to zero. If there is more content than available in the /// viewport "scrollTop" may take positive values. -class Scrollable extends RoleManager { +class Scrollable extends PrimaryRoleManager { Scrollable(SemanticsObject semanticsObject) - : super(Role.scrollable, semanticsObject) { + : super.withBasics(PrimaryRole.scrollable, semanticsObject) { _scrollOverflowElement.style ..position = 'absolute' ..transformOrigin = '0 0 0' @@ -95,6 +95,8 @@ class Scrollable extends RoleManager { @override void update() { + super.update(); + semanticsObject.owner.addOneTimePostUpdateCallback(() { _neutralizeDomScrollPosition(); semanticsObject.recomputePositionAndSize(); diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 50d8d16a20e70..c1f99639aafd4 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -326,11 +326,16 @@ class SemanticsNodeUpdate { final double thickness; } -/// Identifies one of the roles a [SemanticsObject] plays. -enum Role { - /// Supplies generic accessibility focus features to semantics nodes that have - /// [ui.SemanticsFlag.isFocusable] set. - focusable, +/// Identifies [PrimaryRoleManager] implementations. +/// +/// Each value corresponds to the most specific role a semantics node plays in +/// the semantics tree. +enum PrimaryRole { + /// A role used when a more specific role cannot be assigend to + /// a [SemanticsObject]. + /// + /// Provides a label or a value. + generic, /// Supports incrementing and/or decrementing its value. incrementable, @@ -338,14 +343,8 @@ enum Role { /// Able to scroll its contents vertically or horizontally. scrollable, - /// Contains a label or a value. - /// - /// The two are combined into the same role because they interact with each - /// other. - labelAndValue, - /// Accepts tap or click gestures. - tappable, + button, /// Contains editable text. textField, @@ -356,14 +355,6 @@ enum Role { /// Visual only element. image, - /// Contains a region whose changes will be announced to the screen reader - /// without having to be in focus. - /// - /// These regions can be a snackbar or a text field error. Once identified - /// with this role, they will be able to get the assistive technology's - /// attention right away. - liveRegion, - /// Adds the "dialog" ARIA role to the node. /// /// This corresponds to a semantics node that has `scopesRoute` bit set. While @@ -386,6 +377,30 @@ enum Role { /// children. For example, a modal barrier has `scopesRoute` set but marking /// it as a dialog would be wrong. dialog, +} + +/// Identifies one of the secondary [RoleManager]s of a [PrimaryRoleManager]. +enum Role { + /// Supplies generic accessibility focus features to semantics nodes that have + /// [ui.SemanticsFlag.isFocusable] set. + focusable, + + /// Supplies generic tapping/clicking functionality. + tappable, + + /// Provides an `aria-label` from `label`, `value`, and/or `tooltip` values. + /// + /// The two are combined into the same role because they interact with each + /// other. + labelAndValue, + + /// Contains a region whose changes will be announced to the screen reader + /// without having to be in focus. + /// + /// These regions can be a snackbar or a text field error. Once identified + /// with this role, they will be able to get the assistive technology's + /// attention right away. + liveRegion, /// Provides a description for an ancestor dialog. /// @@ -397,30 +412,185 @@ enum Role { routeName, } -/// A function that creates a [RoleManager] for a [SemanticsObject]. -typedef RoleManagerFactory = RoleManager Function(SemanticsObject object); - -final Map _roleFactories = { - Role.focusable: (SemanticsObject object) => Focusable(object), - Role.incrementable: (SemanticsObject object) => Incrementable(object), - Role.scrollable: (SemanticsObject object) => Scrollable(object), - Role.labelAndValue: (SemanticsObject object) => LabelAndValue(object), - Role.tappable: (SemanticsObject object) => Tappable(object), - Role.textField: (SemanticsObject object) => TextField(object), - Role.checkable: (SemanticsObject object) => Checkable(object), - 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 -/// [semanticsObject]. +/// Responsible for setting the `role` ARIA attribute and for attaching zero or +/// more secondary [RoleManager]s to a [SemanticsObject]. +abstract class PrimaryRoleManager { + /// Initializes a role for a [semanticsObject] that includes basic + /// functionality for focus, labels, live regions, and route names. + PrimaryRoleManager.withBasics(this.role, this.semanticsObject) { + addFocusManagement(); + addLiveRegion(); + addRouteName(); + addLabelAndValue(); + addTappable(); + } + + /// Initializes a blank role for a [semanticsObject]. + /// + /// Use this constructor for highly specialized cases where + /// [RoleManager.withBasics] does not work, for example when the default focus + /// management intereferes with the widget's functionality. + PrimaryRoleManager.blank(this.role, this.semanticsObject); + + /// The primary role identifier. + final PrimaryRole role; + + /// The semantics object managed by this role. + final SemanticsObject semanticsObject; + + /// Secondary role managers, if any. + List? get secondaryRoleManagers => _secondaryRoleManagers; + List? _secondaryRoleManagers; + + /// Identifiers of secondary roles used by this primary role manager. + /// + /// This is only meant to be used in tests. + @visibleForTesting + List get debugSecondaryRoles => _secondaryRoleManagers?.map((RoleManager manager) => manager.role).toList() ?? const []; + + /// Adds generic focus management features, if applicable. + void addFocusManagement() { + if (semanticsObject.isFocusable) { + addSecondaryRole(Focusable(semanticsObject)); + } + } + + /// Adds generic live region features, if applicable. + void addLiveRegion() { + if (semanticsObject.isLiveRegion) { + addSecondaryRole(LiveRegion(semanticsObject)); + } + } + + /// Adds generic route name features, if applicable. + void addRouteName() { + if (semanticsObject.namesRoute) { + addSecondaryRole(RouteName(semanticsObject)); + } + } + + /// Adds generic label features, if applicable. + void addLabelAndValue() { + if (semanticsObject.hasLabel || semanticsObject.hasValue || semanticsObject.hasTooltip) { + addSecondaryRole(LabelAndValue(semanticsObject)); + } + } + + /// Adds generic functionality for handling taps and clicks. + void addTappable() { + if (semanticsObject.isTappable) { + addSecondaryRole(Tappable(semanticsObject)); + } + } + + /// Adds a secondary role to this primary role manager. + /// + /// This method should be called by concrete implementations of + /// [PrimaryRoleManager] during initialization. + @protected + void addSecondaryRole(RoleManager secondaryRoleManager) { + assert( + _secondaryRoleManagers?.any((RoleManager manager) => manager.role == secondaryRoleManager.role) != true, + 'Cannot add secondary role ${secondaryRoleManager.role}. This object already has this secondary role.', + ); + _secondaryRoleManagers ??= []; + _secondaryRoleManagers!.add(secondaryRoleManager); + } + + /// Called immediately after the fields of the [semanticsObject] are updated + /// by a [SemanticsUpdate]. + /// + /// A concrete implementation of this method would typically use some of the + /// "is*Dirty" getters to find out exactly what's changed and apply the + /// minimum DOM updates. + /// + /// The base implementation requests every secondary role manager to update + /// the object. + @mustCallSuper + void update() { + final List? secondaryRoles = _secondaryRoleManagers; + if (secondaryRoles == null) { + return; + } + for (final RoleManager secondaryRole in secondaryRoles) { + secondaryRole.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. + @mustCallSuper + void dispose() { + semanticsObject.clearAriaRole(); + _isDisposed = true; + } +} + +/// A role used when a more specific role couldn't be assigned to the node. +final class GenericRole extends PrimaryRoleManager { + GenericRole(SemanticsObject semanticsObject) : super.withBasics(PrimaryRole.generic, semanticsObject); + + @override + void update() { + super.update(); + + if (!semanticsObject.hasLabel) { + // The node didn't get a more specific role, and it has no label. It is + // likely that this node is simply there for positioning its children and + // has no other role for the screen reader to be aware of. In this case, + // the element does not need a `role` attribute at all. + return; + } + + // Assign one of three roles to the element: heading, group, text. + // + // - "group" is used when the node has children, irrespective of whether the + // node is marked as a header or not. This is because marking a group + // as a "heading" will prevent the AT from reaching its children. + // - "heading" is used when the framework explicitly marks the node as a + // heading and the node does not have children. + // - "text" is used by default. + // + // As of October 24, 2022, "text" only has effect on Safari. Other browsers + // ignore it. Setting role="text" prevents Safari from treating the element + // as a "group" or "empty group". Other browsers still announce it as + // "group" or "empty group". However, other options considered produced even + // worse results, such as: + // + // - Ignore the size of the element and size the focus ring to the text + // content, which is wrong. The HTML text size is irrelevant because + // Flutter renders into canvas, so the focus ring looks wrong. + // - Read out the same label multiple times. + if (semanticsObject.hasChildren) { + semanticsObject.setAriaRole('group'); + } else if (semanticsObject.hasFlag(ui.SemanticsFlag.isHeader)) { + semanticsObject.setAriaRole('heading'); + } else { + semanticsObject.setAriaRole('text'); + } + } +} + +/// Provides a piece of functionality to a [SemanticsObject]. +/// +/// A secondary role must not set the `role` ARIA attribute. That responsibility +/// falls on the [PrimaryRoleManager]. One [SemanticsObject] may have more than +/// one [RoleManager] but an element may only have one ARIA role, so setting the +/// `role` attribute from a [RoleManager] would cause conflicts. /// -/// The role is determined by [ui.SemanticsFlag]s and [ui.SemanticsAction]s set -/// on the object. +/// The [PrimaryRoleManager] decides the list of [RoleManager]s a given semantics +/// node should use. abstract class RoleManager { - /// Initializes a role for [semanticsObject]. + /// Initializes a secondary role for [semanticsObject]. /// /// A single role object manages exactly one [SemanticsObject]. RoleManager(this.role, this.semanticsObject); @@ -873,11 +1043,6 @@ class SemanticsObject { /// Whether [actions] contains the given action. bool hasAction(ui.SemanticsAction action) => (_actions! & action.index) != 0; - /// Whether this object represents a vertically scrollable area. - bool get isVerticalScrollContainer => - hasAction(ui.SemanticsAction.scrollDown) || - hasAction(ui.SemanticsAction.scrollUp); - /// Whether this object represents a widget that can receive input focus. bool get isFocusable => hasFlag(ui.SemanticsFlag.isFocusable); @@ -896,11 +1061,19 @@ class SemanticsObject { /// This field is only meaningful if [hasEnabledState] is true. bool get isEnabled => hasFlag(ui.SemanticsFlag.isEnabled); - /// Whether this object represents a hotizontally scrollable area. + /// Whether this object represents a vertically scrollable area. + bool get isVerticalScrollContainer => + hasAction(ui.SemanticsAction.scrollDown) || + hasAction(ui.SemanticsAction.scrollUp); + + /// Whether this object represents a horizontally scrollable area. bool get isHorizontalScrollContainer => hasAction(ui.SemanticsAction.scrollLeft) || hasAction(ui.SemanticsAction.scrollRight); + /// Whether this object represents a scrollable area in any direction. + bool get isScrollContainer => isVerticalScrollContainer || isHorizontalScrollContainer; + /// Whether this object has a non-empty list of children. bool get hasChildren => _childrenInTraversalOrder != null && _childrenInTraversalOrder!.isNotEmpty; @@ -916,8 +1089,8 @@ class SemanticsObject { /// Whether this object represents an image with no tappable functionality. bool get isVisualOnly => hasFlag(ui.SemanticsFlag.isImage) && - !hasAction(ui.SemanticsAction.tap) && - !hasFlag(ui.SemanticsFlag.isButton); + !isTappable && + !isButton; /// Whether this node defines a scope for a route. /// @@ -1285,22 +1458,9 @@ class SemanticsObject { _currentChildrenInRenderOrder = childrenInRenderOrder; } - /// Populates the HTML "role" attribute based on a [condition]. - /// - /// If [condition] is true, sets the value to [ariaRoleName]. - /// - /// If [condition] is false, removes the HTML "role" attribute from [element] - /// if the current role is set to [ariaRoleName]. Otherwise, leaves the value - /// unchanged. This is done to gracefully handle multiple competing roles. - /// For example, if the role changes from "button" to "img" and tappable role - /// manager attempts to clean up after the image role manager applied the new - /// role, semantics avoids erasing the new role. - void setAriaRole(String ariaRoleName, bool condition) { - if (condition) { - element.setAttribute('role', ariaRoleName); - } else if (element.getAttribute('role') == ariaRoleName) { - element.removeAttribute('role'); - } + /// Sets the `role` ARIA attribute. + void setAriaRole(String ariaRoleName) { + element.setAttribute('role', ariaRoleName); } /// Removes the `role` HTML attribue, if any. @@ -1308,81 +1468,77 @@ class SemanticsObject { element.removeAttribute('role'); } - /// Role managers. - /// - /// The [_roleManagers] map needs to have a stable order for easier debugging - /// and testing. Dart's map literal guarantees the order as described in the - /// 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 _roleManagers = {}; - - /// The mapping of roles to role managers. - /// - /// This getter is only meant for testing. - Map 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(Role role) => _roleManagers[role]! as R; + /// The primary role of this node. + /// + /// The primary role is assigned by [updateSelf] based on the combination of + /// semantics flags and actions. + PrimaryRoleManager? primaryRole; + + PrimaryRole _getPrimaryRoleIdentifier() { + // The most specific role should take precedence. + if (isTextField) { + return PrimaryRole.textField; + } else if (isIncrementable) { + return PrimaryRole.incrementable; + } else if (isVisualOnly) { + return PrimaryRole.image; + } else if (isCheckable) { + return PrimaryRole.checkable; + } else if (isButton) { + return PrimaryRole.button; + } else if (isScrollContainer) { + return PrimaryRole.scrollable; + } else if (scopesRoute) { + return PrimaryRole.dialog; + } else { + return PrimaryRole.generic; + } + } - /// Returns the role manager for the given [role]. - /// - /// If a role manager does not exist for the given role, returns null. - RoleManager? debugRoleManagerFor(Role role) => _roleManagers[role]; + PrimaryRoleManager _createPrimaryRole(PrimaryRole role) { + return switch (role) { + PrimaryRole.textField => TextField(this), + PrimaryRole.scrollable => Scrollable(this), + PrimaryRole.incrementable => Incrementable(this), + PrimaryRole.button => Button(this), + PrimaryRole.checkable => Checkable(this), + PrimaryRole.dialog => Dialog(this), + PrimaryRole.image => ImageRoleManager(this), + PrimaryRole.generic => GenericRole(this), + }; + } - /// Detects the roles that this semantics object corresponds to and manages - /// the lifecycles of [RoleManager] objects. + /// Detects the roles that this semantics object corresponds to and asks the + /// respective role managers to update the DOM. void _updateRoles() { - // Some role managers manage labels themselves for various role-specific reasons. - final bool managesOwnLabel = isTextField || scopesRoute || isVisualOnly; - _updateRole(Role.labelAndValue, (hasLabel || hasValue || hasTooltip) && !managesOwnLabel); - - _updateRole(Role.dialog, scopesRoute); - _updateRole(Role.routeName, namesRoute && !scopesRoute); - _updateRole(Role.textField, isTextField); - - // The generic `Focusable` role manager can be used for everything except - // text fields and incrementables, which have special needs not satisfied by - // the generic implementation. - _updateRole(Role.focusable, isFocusable && !isTextField && !isIncrementable); - - final bool shouldUseTappableRole = - (hasAction(ui.SemanticsAction.tap) || hasFlag(ui.SemanticsFlag.isButton)) && - // Text fields manage their own focus/tap interactions. Tappable role - // manager is not needed. It only confuses AT. - !isTextField; - - _updateRole(Role.tappable, shouldUseTappableRole); - _updateRole(Role.incrementable, isIncrementable); - _updateRole(Role.scrollable, - isVerticalScrollContainer || isHorizontalScrollContainer); - _updateRole( - Role.checkable, - hasFlag(ui.SemanticsFlag.hasCheckedState) || - hasFlag(ui.SemanticsFlag.hasToggledState)); - _updateRole(Role.image, isVisualOnly); - _updateRole(Role.liveRegion, isLiveRegion); - } - - void _updateRole(Role role, bool enabled) { - RoleManager? manager = _roleManagers[role]; - if (enabled) { - if (manager == null) { - manager = _roleFactories[role]!(this); - _roleManagers[role] = manager; + PrimaryRoleManager? currentPrimaryRole = primaryRole; + final PrimaryRole roleId = _getPrimaryRoleIdentifier(); + + if (currentPrimaryRole != null) { + if (currentPrimaryRole.role == roleId) { + // Already has a primary role assigned and the role is the same as before, + // so simply perform an update. + currentPrimaryRole.update(); + return; + } else { + // Role changed. This should be avoided as much as possible, but the + // web engine will attempt a best with the switch by cleaning old ARIA + // role data and start anew. + currentPrimaryRole.dispose(); + currentPrimaryRole = null; + primaryRole = null; } - manager.update(); - } else if (manager != null) { - manager.dispose(); - _roleManagers.remove(role); } - // Nothing to do in the "else case". There's no existing role manager to - // disable. + + // This handles two cases: + // * The node was just created and needs a primary role manager. + // * (Uncommon) the node changed its primary role, its previous primary + // role manager was disposed of, and now it needs a new one. + if (currentPrimaryRole == null) { + currentPrimaryRole = _createPrimaryRole(roleId); + primaryRole = currentPrimaryRole; + currentPrimaryRole.update(); + } } /// Whether the object represents an UI element with "increase" or "decrease" @@ -1393,6 +1549,17 @@ class SemanticsObject { hasAction(ui.SemanticsAction.increase) || hasAction(ui.SemanticsAction.decrease); + /// Whether the object represents a button. + bool get isButton => hasFlag(ui.SemanticsFlag.isButton); + + /// Represents a tappable or clickable widget, such as button, icon button, + /// "hamburger" menu, etc. + bool get isTappable => hasAction(ui.SemanticsAction.tap); + + bool get isCheckable => + hasFlag(ui.SemanticsFlag.hasCheckedState) || + hasFlag(ui.SemanticsFlag.hasToggledState); + /// Role-specific adjustment of the vertical position of the child container. /// /// This is used, for example, by the [Scrollable] to compensate for the diff --git a/lib/web_ui/lib/src/engine/semantics/tappable.dart b/lib/web_ui/lib/src/engine/semantics/tappable.dart index 9352e4c52a605..39362f9976a0a 100644 --- a/lib/web_ui/lib/src/engine/semantics/tappable.dart +++ b/lib/web_ui/lib/src/engine/semantics/tappable.dart @@ -8,6 +8,24 @@ import '../dom.dart'; import '../platform_dispatcher.dart'; import 'semantics.dart'; +/// Sets the "button" ARIA role. +class Button extends PrimaryRoleManager { + Button(SemanticsObject semanticsObject) : super.withBasics(PrimaryRole.button, semanticsObject) { + semanticsObject.setAriaRole('button'); + } + + @override + void update() { + super.update(); + + if (semanticsObject.enabledState() == EnabledState.disabled) { + semanticsObject.element.setAttribute('aria-disabled', 'true'); + } else { + semanticsObject.element.removeAttribute('aria-disabled'); + } + } +} + /// Listens to HTML "click" gestures detected by the browser. /// /// This gestures is different from the click and tap gestures detected by the @@ -23,34 +41,18 @@ class Tappable extends RoleManager { @override void update() { final DomElement element = semanticsObject.element; - - semanticsObject.setAriaRole( - 'button', semanticsObject.hasFlag(ui.SemanticsFlag.isButton)); - - // Add `aria-disabled` for disabled buttons. - if (semanticsObject.enabledState() == EnabledState.disabled && - semanticsObject.hasFlag(ui.SemanticsFlag.isButton)) { - semanticsObject.element.setAttribute('aria-disabled', 'true'); + if (semanticsObject.enabledState() == EnabledState.disabled || !semanticsObject.isTappable) { _stopListening(); } else { - semanticsObject.element.removeAttribute('aria-disabled'); - // Excluding text fields because text fields have browser-specific logic - // for recognizing taps and activating the keyboard. - if (semanticsObject.hasAction(ui.SemanticsAction.tap) && - !semanticsObject.hasFlag(ui.SemanticsFlag.isTextField)) { - if (_clickListener == null) { - _clickListener = createDomEventListener((_) { - if (semanticsObject.owner.gestureMode != - GestureMode.browserGestures) { - return; - } - EnginePlatformDispatcher.instance.invokeOnSemanticsAction( - semanticsObject.id, ui.SemanticsAction.tap, null); - }); - element.addEventListener('click', _clickListener); - } - } else { - _stopListening(); + if (_clickListener == null) { + _clickListener = createDomEventListener((DomEvent event) { + if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) { + return; + } + EnginePlatformDispatcher.instance.invokeOnSemanticsAction( + semanticsObject.id, ui.SemanticsAction.tap, null); + }); + element.addEventListener('click', _clickListener); } } } @@ -68,6 +70,5 @@ class Tappable extends RoleManager { void dispose() { super.dispose(); _stopListening(); - semanticsObject.setAriaRole('button', false); } } diff --git a/lib/web_ui/lib/src/engine/semantics/text_field.dart b/lib/web_ui/lib/src/engine/semantics/text_field.dart index f6b739925c82a..5f89f88f2f217 100644 --- a/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -209,9 +209,8 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { /// browser gestures when in pointer mode. In Safari on iOS pointer events are /// used to detect text box invocation. This is because Safari issues touch /// events even when Voiceover is enabled. -class TextField extends RoleManager { - TextField(SemanticsObject semanticsObject) - : super(Role.textField, semanticsObject) { +class TextField extends PrimaryRoleManager { + TextField(SemanticsObject semanticsObject) : super.blank(PrimaryRole.textField, semanticsObject) { _setupDomElement(); } @@ -408,6 +407,8 @@ class TextField extends RoleManager { @override void update() { + super.update(); + // Ignore the update if editableElement has not been created yet. // On iOS Safari, when the user dismisses the keyboard using the 'done' button, // we recieve a `blur` event from the browswer and a semantic update with diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index cc768b3f5a350..5013ff3decc28 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -486,8 +486,8 @@ void _testEngineSemanticsOwner() { ); // Rudely replace the role manager with a mock, and trigger an update. - final MockRoleManager mockRoleManager = MockRoleManager(Role.labelAndValue, semanticsObject); - semanticsObject.debugRoleManagers[Role.labelAndValue] = mockRoleManager; + final MockRoleManager mockRoleManager = MockRoleManager(PrimaryRole.generic, semanticsObject); + semanticsObject.primaryRole = mockRoleManager; pumpSemantics(label: 'World'); @@ -508,8 +508,8 @@ typedef MockRoleManagerLogEntry = ({ SemanticsUpdatePhase phase, }); -class MockRoleManager extends RoleManager { - MockRoleManager(super.role, super.semanticsObject); +class MockRoleManager extends PrimaryRoleManager { + MockRoleManager(super.role, super.semanticsObject) : super.blank(); final List log = []; @@ -522,6 +522,7 @@ class MockRoleManager extends RoleManager { @override void update() { + super.update(); _log('update'); } } @@ -1334,11 +1335,11 @@ void _testIncrementables() { '''); final SemanticsObject node = semantics().debugSemanticsTree![0]!; - expect(node.debugRoleManagerFor(Role.incrementable), isNotNull); + expect(node.primaryRole?.role, PrimaryRole.incrementable); expect( reason: 'Incrementables use custom focus management', - node.debugRoleManagerFor(Role.focusable), - isNull, + node.primaryRole!.debugSecondaryRoles, + isNot(contains(Role.focusable)), ); semantics().semanticsEnabled = false; @@ -1504,11 +1505,11 @@ void _testTextField() { '''); final SemanticsObject node = semantics().debugSemanticsTree![0]!; - expect(node.debugRoleManagerFor(Role.textField), isNotNull); + expect(node.primaryRole?.role, PrimaryRole.textField); expect( reason: 'Text fields use custom focus management', - node.debugRoleManagerFor(Role.focusable), - isNull, + node.primaryRole!.debugSecondaryRoles, + isNot(contains(Role.focusable)), ); semantics().semanticsEnabled = false; @@ -1561,6 +1562,7 @@ void _testCheckables() { updateNode( builder, actions: 0 | ui.SemanticsAction.tap.index, + label: 'test label', flags: 0 | ui.SemanticsFlag.isEnabled.index | ui.SemanticsFlag.hasEnabledState.index | @@ -1573,12 +1575,16 @@ void _testCheckables() { semantics().updateSemantics(builder.build()); expectSemanticsTree(''' - + '''); final SemanticsObject node = semantics().debugSemanticsTree![0]!; - expect(node.debugRoleManagerFor(Role.checkable), isNotNull); - expect(node.debugRoleManagerFor(Role.focusable), isNotNull); + expect(node.primaryRole?.role, PrimaryRole.checkable); + expect( + reason: 'Checkables use generic secondary roles', + node.primaryRole!.debugSecondaryRoles, + containsAll([Role.focusable, Role.tappable]), + ); semantics().semanticsEnabled = false; }); @@ -1859,8 +1865,11 @@ void _testTappable() { '''); final SemanticsObject node = semantics().debugSemanticsTree![0]!; - expect(node.debugRoleManagerFor(Role.tappable), isNotNull); - expect(node.debugRoleManagerFor(Role.focusable), isNotNull); + expect(node.primaryRole?.role, PrimaryRole.button); + expect( + node.primaryRole?.debugSecondaryRoles, + containsAll([Role.focusable, Role.tappable]), + ); expect(tester.getSemanticsObject(0).element.tabIndex, 0); semantics().semanticsEnabled = false; @@ -2446,8 +2455,8 @@ void _testDialog() { '''); expect( - semantics().debugSemanticsTree![0]!.debugRoleManagerFor(Role.dialog), - isA(), + semantics().debugSemanticsTree![0]!.primaryRole?.role, + PrimaryRole.dialog, ); semantics().semanticsEnabled = false; @@ -2491,8 +2500,8 @@ void _testDialog() { '''); expect( - semantics().debugSemanticsTree![0]!.debugRoleManagerFor(Role.dialog), - isA(), + semantics().debugSemanticsTree![0]!.primaryRole?.role, + PrimaryRole.dialog, ); semantics().semanticsEnabled = false; @@ -2540,12 +2549,16 @@ void _testDialog() { pumpSemantics(label: 'Dialog label'); expect( - semantics().debugSemanticsTree![0]!.debugRoleManagerFor(Role.dialog), - isA(), + semantics().debugSemanticsTree![0]!.primaryRole?.role, + PrimaryRole.dialog, + ); + expect( + semantics().debugSemanticsTree![2]!.primaryRole?.role, + PrimaryRole.generic, ); expect( - semantics().debugSemanticsTree![2]!.debugRoleManagerFor(Role.routeName), - isA(), + semantics().debugSemanticsTree![2]!.primaryRole?.debugSecondaryRoles, + contains(Role.routeName), ); pumpSemantics(label: 'Updated dialog label'); @@ -2574,12 +2587,12 @@ void _testDialog() { '''); expect( - semantics().debugSemanticsTree![0]!.debugRoleManagerFor(Role.dialog), - isA(), + semantics().debugSemanticsTree![0]!.primaryRole?.role, + PrimaryRole.dialog, ); expect( - semantics().debugSemanticsTree![0]!.debugRoleManagerFor(Role.routeName), - isNull, + semantics().debugSemanticsTree![0]!.primaryRole?.secondaryRoleManagers, + isNot(contains(Role.routeName)), ); semantics().semanticsEnabled = false; @@ -2622,12 +2635,12 @@ void _testDialog() { '''); expect( - semantics().debugSemanticsTree![0]!.debugRoleManagerFor(Role.dialog), - isNull, + semantics().debugSemanticsTree![0]!.primaryRole?.role, + PrimaryRole.generic, ); expect( - semantics().debugSemanticsTree![2]!.debugRoleManagerFor(Role.routeName), - isA(), + semantics().debugSemanticsTree![2]!.primaryRole?.debugSecondaryRoles, + contains(Role.routeName), ); semantics().semanticsEnabled = false; @@ -2726,7 +2739,14 @@ void _testFocusable() { final SemanticsObject node = semantics().debugSemanticsTree![1]!; expect(node.isFocusable, isTrue); - expect(node.debugRoleManagerFor(Role.focusable), isA()); + expect( + node.primaryRole?.role, + PrimaryRole.generic, + ); + expect( + node.primaryRole?.debugSecondaryRoles, + contains(Role.focusable), + ); final DomElement element = node.element; expect(domDocument.activeElement, isNot(element)); diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index ce8817244fc3f..d8884e7eb955a 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -344,14 +344,9 @@ class SemanticsTester { return owner.debugSemanticsTree![id]!; } - /// Locates the role manager of the semantics object with the give [id]. - RoleManager? getRoleManager(int id, Role role) { - return getSemanticsObject(id).debugRoleManagerFor(role); - } - /// Locates the [TextField] role manager of the semantics object with the give [id]. TextField getTextField(int id) { - return getRoleManager(id, Role.textField)! as TextField; + return getSemanticsObject(id).primaryRole! as TextField; } } diff --git a/lib/web_ui/test/engine/semantics/text_field_test.dart b/lib/web_ui/test/engine/semantics/text_field_test.dart index 52d575dfcbeb8..503f1cdd11bd9 100644 --- a/lib/web_ui/test/engine/semantics/text_field_test.dart +++ b/lib/web_ui/test/engine/semantics/text_field_test.dart @@ -54,8 +54,7 @@ void testMain() { value: 'hi', isFocused: true, ); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; // ensureInitialized() isn't called prior to calling dispose() here. // Since we are conditionally calling dispose() on our @@ -136,8 +135,7 @@ void testMain() { rect: const ui.Rect.fromLTWH(0, 0, 10, 15), ); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; expect(appHostNode.ownerDocument?.activeElement, strategy.domElement); expect(textField.editableElement, strategy.domElement); expect(textField.activeEditableElement.getAttribute('aria-label'), 'greeting'); @@ -181,8 +179,7 @@ void testMain() { isFocused: true, rect: const ui.Rect.fromLTWH(0, 0, 10, 15)); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; final DomHTMLInputElement editableElement = textField.activeEditableElement as DomHTMLInputElement; @@ -212,8 +209,7 @@ void testMain() { isFocused: true, rect: const ui.Rect.fromLTWH(0, 0, 10, 15)); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; final DomHTMLInputElement editableElement = textField.activeEditableElement as DomHTMLInputElement; @@ -253,8 +249,7 @@ void testMain() { isFocused: true, ); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; expect(textField.editableElement, strategy.domElement); expect(appHostNode.ownerDocument?.activeElement, strategy.domElement); @@ -286,8 +281,7 @@ void testMain() { expect(strategy.domElement, isNull); // It doesn't remove the DOM element. - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; expect(appHostNode.contains(textField.editableElement), isTrue); // Editing element is not enabled. expect(strategy.isEnabled, isFalse); @@ -517,9 +511,8 @@ void testMain() { isFocused: true, rect: const ui.Rect.fromLTWH(0, 0, 10, 15), ); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; expect(appHostNode.ownerDocument?.activeElement, strategy.domElement); expect(textField.editableElement, strategy.domElement); expect(textField.activeEditableElement.getAttribute('aria-label'), 'greeting'); @@ -563,8 +556,7 @@ void testMain() { isFocused: true, rect: const ui.Rect.fromLTWH(0, 0, 10, 15)); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; final DomHTMLInputElement editableElement = textField.activeEditableElement as DomHTMLInputElement; @@ -594,8 +586,7 @@ void testMain() { isFocused: true, rect: const ui.Rect.fromLTWH(0, 0, 10, 15)); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; final DomHTMLInputElement editableElement = textField.activeEditableElement as DomHTMLInputElement; @@ -634,9 +625,8 @@ void testMain() { value: 'hello', isFocused: true, ); - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; expect(textField.editableElement, strategy.domElement); expect(appHostNode.ownerDocument?.activeElement, strategy.domElement); @@ -671,7 +661,7 @@ void testMain() { expect(strategy.domElement, isNull); // It removes the DOM element. - final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; expect(appHostNode.contains(textField.editableElement), isFalse); // Editing element is not enabled. expect(strategy.isEnabled, isFalse); @@ -850,8 +840,7 @@ void testMain() { SemanticsObject textFieldSemantics = createTextFieldSemanticsForIos( value: 'hello', ); - TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + TextField textField = textFieldSemantics.primaryRole! as TextField; expect(textField.editableElement, isNull); textField.dispose(); expect(textField.editableElement, isNull); @@ -860,8 +849,7 @@ void testMain() { value: 'hi', isFocused: true, ); - textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + textField = textFieldSemantics.primaryRole! as TextField; expect(textField.editableElement, isNotNull); textField.dispose(); @@ -938,8 +926,7 @@ SemanticsObject createTextFieldSemanticsForIos({ ); if (isFocused) { - final TextField textField = - textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField; + final TextField textField = textFieldSemantics.primaryRole! as TextField; simulateTap(textField.semanticsObject.element);