From 007bc8126e4a3d71d96ee73cf5ae03fa7c2ef798 Mon Sep 17 00:00:00 2001 From: Yegor Date: Tue, 27 Jun 2023 13:49:03 -0700 Subject: [PATCH] [web:a11y] introduce primary role responsible for ARIA roles (flutter/engine#43159) This PR fixes https://github.com/flutter/flutter/issues/128468 by changing the relationship between semantics nodes and their roles from this: ``` SemanticsNode one-to-many RoleManager ``` To this: ``` SemanticsNode one-to-one PrimaryRoleManager one-to-many RoleManager ``` Previously a node would simply have multiple role managers, some of which would be responsible for setting the `role` attribute. It wasn't clear which role manager should be doing this. It also wasn't clear which role managers were safe to reuse across multiple types of nodes. This led to the unfortunate situation in https://github.com/flutter/flutter/issues/128468 where `LabelAndValue` ended up overriding the role assigned by `Checkable`. With this PR, a `SemanticsNode` has exactly one `PrimaryRoleManager`. A primary role manager is responsible for setting the `role` attribute, and importantly, it's the _only_ thing responsible for it. It's _not safe_ to share primary role managers across different kinds of nodes. They are meant to provide very specific functionality for the widget's main role. OTOH, a non-primary `RoleManager` provides a piece of functionality that's safe to share. A `Checkable` is a `PrimaryRoleManager` and is the only thing that decides on the `role` attribute. `LabelAndValue` is now a `RoleManager` that's not responsible for setting the role. It's only responsible for `aria-label`. No more confusion. This also drastically simplifies the logic for role assignment. There's no more [logical soup](https://github.com/flutter/engine/blob/d4889c682d64451fe8129e16a190424d6321811f/lib/web_ui/lib/src/engine/semantics/semantics.dart#L1340) attempting to find a good subset of roles to assign to a node. [Finding](https://github.com/yjbanov/engine/blob/93df91df9575f8fc212aac115ccccc23f8fba26f/lib/web_ui/lib/src/engine/semantics/semantics.dart#L1477) and [instantiating](https://github.com/yjbanov/engine/blob/93df91df9575f8fc212aac115ccccc23f8fba26f/lib/web_ui/lib/src/engine/semantics/semantics.dart#L1498) primary roles are very linear steps, as is [assigning a set of secondary roles](https://github.com/yjbanov/engine/blob/93df91df9575f8fc212aac115ccccc23f8fba26f/lib/web_ui/lib/src/engine/semantics/image.dart#L16). --- .../lib/src/engine/semantics/checkable.dart | 20 +- .../lib/src/engine/semantics/dialog.dart | 22 +- .../lib/src/engine/semantics/image.dart | 18 +- .../src/engine/semantics/incrementable.dart | 13 +- .../src/engine/semantics/label_and_value.dart | 29 -- .../lib/src/engine/semantics/live_region.dart | 2 +- .../lib/src/engine/semantics/scrollable.dart | 6 +- .../lib/src/engine/semantics/semantics.dart | 433 ++++++++++++------ .../lib/src/engine/semantics/tappable.dart | 55 +-- .../lib/src/engine/semantics/text_field.dart | 7 +- .../test/engine/semantics/semantics_test.dart | 84 ++-- .../engine/semantics/semantics_tester.dart | 7 +- .../engine/semantics/text_field_test.dart | 41 +- 13 files changed, 451 insertions(+), 286 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart index 8704c771b2..e6f5b2efcc 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart index df2c743d4a..7e8f58ec6c 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/image.dart index e867544439..0f0eb298b0 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/image.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart index f58e2a8edd..f1de98d026 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart index 087b30dc83..643f305fc7 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart index 1be14a49e7..239b9b600a 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart index dd0d66bb6d..82b9308816 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index 50d8d16a20..c1f99639aa 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/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); +/// 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(); + } -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), -}; + /// 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); -/// Provides the functionality associated with the role of the given -/// [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]. /// -/// The role is determined by [ui.SemanticsFlag]s and [ui.SemanticsAction]s set -/// on the object. +/// 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 [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 primary role of this node. /// - /// 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 primary role is assigned by [updateSelf] based on the combination of + /// semantics flags and actions. + PrimaryRoleManager? primaryRole; - /// 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; - - /// 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]; - - /// Detects the roles that this semantics object corresponds to and manages - /// the lifecycles of [RoleManager] objects. - 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); + 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; + } } - void _updateRole(Role role, bool enabled) { - RoleManager? manager = _roleManagers[role]; - if (enabled) { - if (manager == null) { - manager = _roleFactories[role]!(this); - _roleManagers[role] = manager; + 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 asks the + /// respective role managers to update the DOM. + void _updateRoles() { + 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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart index 9352e4c52a..39362f9976 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart index f6b739925c..5f89f88f2f 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index cc768b3f5a..5013ff3dec 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/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]!.debugRoleManagerFor(Role.routeName), - isA(), + semantics().debugSemanticsTree![2]!.primaryRole?.role, + PrimaryRole.generic, + ); + expect( + 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/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart index ce8817244f..d8884e7eb9 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/engine/src/flutter/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/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart index 52d575dfcb..503f1cdd11 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart +++ b/engine/src/flutter/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);