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

Commit 3d30145

Browse files
authored
[web:a11y] introduce primary role responsible for ARIA roles (#43159)
This PR fixes flutter/flutter#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 flutter/flutter#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/eca910dd5e3f1d8e18b10f3a46ce8d1454a232c8/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).
1 parent 41413a4 commit 3d30145

File tree

13 files changed

+454
-289
lines changed

13 files changed

+454
-289
lines changed

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

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,25 @@ _CheckableKind _checkableKindFromSemanticsFlag(
4949
/// See also [ui.SemanticsFlag.hasCheckedState], [ui.SemanticsFlag.isChecked],
5050
/// [ui.SemanticsFlag.isInMutuallyExclusiveGroup], [ui.SemanticsFlag.isToggled],
5151
/// [ui.SemanticsFlag.hasToggledState]
52-
class Checkable extends RoleManager {
52+
class Checkable extends PrimaryRoleManager {
5353
Checkable(SemanticsObject semanticsObject)
5454
: _kind = _checkableKindFromSemanticsFlag(semanticsObject),
55-
super(Role.checkable, semanticsObject);
55+
super.withBasics(PrimaryRole.checkable, semanticsObject);
5656

5757
final _CheckableKind _kind;
5858

5959
@override
6060
void update() {
61+
super.update();
62+
6163
if (semanticsObject.isFlagsDirty) {
6264
switch (_kind) {
6365
case _CheckableKind.checkbox:
64-
semanticsObject.setAriaRole('checkbox', true);
66+
semanticsObject.setAriaRole('checkbox');
6567
case _CheckableKind.radio:
66-
semanticsObject.setAriaRole('radio', true);
68+
semanticsObject.setAriaRole('radio');
6769
case _CheckableKind.toggle:
68-
semanticsObject.setAriaRole('switch', true);
70+
semanticsObject.setAriaRole('switch');
6971
}
7072

7173
/// Adding disabled and aria-disabled attribute to notify the assistive
@@ -85,14 +87,6 @@ class Checkable extends RoleManager {
8587
@override
8688
void dispose() {
8789
super.dispose();
88-
switch (_kind) {
89-
case _CheckableKind.checkbox:
90-
semanticsObject.setAriaRole('checkbox', false);
91-
case _CheckableKind.radio:
92-
semanticsObject.setAriaRole('radio', false);
93-
case _CheckableKind.toggle:
94-
semanticsObject.setAriaRole('switch', false);
95-
}
9690
_removeDisabledAttribute();
9791
}
9892

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ import '../util.dart';
99
/// Provides accessibility for dialogs.
1010
///
1111
/// See also [Role.dialog].
12-
class Dialog extends RoleManager {
13-
Dialog(SemanticsObject semanticsObject) : super(Role.dialog, semanticsObject);
12+
class Dialog extends PrimaryRoleManager {
13+
Dialog(SemanticsObject semanticsObject) : super.blank(PrimaryRole.dialog, semanticsObject) {
14+
// The following secondary roles can coexist with dialog. Generic `RouteName`
15+
// and `LabelAndValue` are not used by this role because when the dialog
16+
// names its own route an `aria-label` is used instead of `aria-describedby`.
17+
addFocusManagement();
18+
addLiveRegion();
19+
}
1420

1521
@override
1622
void update() {
23+
super.update();
24+
1725
// If semantic object corresponding to the dialog also provides the label
1826
// for itself it is applied as `aria-label`. See also [describeBy].
1927
if (semanticsObject.namesRoute) {
@@ -31,7 +39,7 @@ class Dialog extends RoleManager {
3139
return true;
3240
}());
3341
semanticsObject.element.setAttribute('aria-label', label ?? '');
34-
semanticsObject.setAriaRole('dialog', true);
42+
semanticsObject.setAriaRole('dialog');
3543
}
3644
}
3745

@@ -43,7 +51,7 @@ class Dialog extends RoleManager {
4351
return;
4452
}
4553

46-
semanticsObject.setAriaRole('dialog', true);
54+
semanticsObject.setAriaRole('dialog');
4755
semanticsObject.element.setAttribute(
4856
'aria-describedby',
4957
routeName.semanticsObject.element.id,
@@ -88,11 +96,11 @@ class RouteName extends RoleManager {
8896

8997
void _lookUpNearestAncestorDialog() {
9098
SemanticsObject? parent = semanticsObject.parent;
91-
while (parent != null && !parent.hasRole(Role.dialog)) {
99+
while (parent != null && parent.primaryRole?.role != PrimaryRole.dialog) {
92100
parent = parent.parent;
93101
}
94-
if (parent != null && parent.hasRole(Role.dialog)) {
95-
_dialog = parent.getRole<Dialog>(Role.dialog);
102+
if (parent != null && parent.primaryRole?.role == PrimaryRole.dialog) {
103+
_dialog = parent.primaryRole! as Dialog;
96104
}
97105
}
98106
}

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,18 @@ import 'semantics.dart';
1010
/// Uses aria img role to convey this semantic information to the element.
1111
///
1212
/// Screen-readers takes advantage of "aria-label" to describe the visual.
13-
class ImageRoleManager extends RoleManager {
13+
class ImageRoleManager extends PrimaryRoleManager {
1414
ImageRoleManager(SemanticsObject semanticsObject)
15-
: super(Role.image, semanticsObject);
15+
: super.blank(PrimaryRole.image, semanticsObject) {
16+
// The following secondary roles can coexist with images. `LabelAndValue` is
17+
// not used because this role manager uses special auxiliary elements to
18+
// supply ARIA labels.
19+
// TODO(yjbanov): reevaluate usage of aux elements, https://github.com/flutter/flutter/issues/129317
20+
addFocusManagement();
21+
addLiveRegion();
22+
addRouteName();
23+
addTappable();
24+
}
1625

1726
/// The element with role="img" and aria-label could block access to all
1827
/// children elements, therefore create an auxiliary element and describe the
@@ -21,6 +30,8 @@ class ImageRoleManager extends RoleManager {
2130

2231
@override
2332
void update() {
33+
super.update();
34+
2435
if (semanticsObject.isVisualOnly && semanticsObject.hasChildren) {
2536
if (_auxiliaryImageElement == null) {
2637
_auxiliaryImageElement = domDocument.createElement('flt-semantics-img');
@@ -44,7 +55,7 @@ class ImageRoleManager extends RoleManager {
4455
_auxiliaryImageElement!.setAttribute('role', 'img');
4556
_setLabel(_auxiliaryImageElement);
4657
} else if (semanticsObject.isVisualOnly) {
47-
semanticsObject.setAriaRole('img', true);
58+
semanticsObject.setAriaRole('img');
4859
_setLabel(semanticsObject.element);
4960
_cleanUpAuxiliaryElement();
5061
} else {
@@ -67,7 +78,6 @@ class ImageRoleManager extends RoleManager {
6778
}
6879

6980
void _cleanupElement() {
70-
semanticsObject.setAriaRole('img', false);
7181
semanticsObject.element.removeAttribute('aria-label');
7282
}
7383

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,17 @@ import 'semantics.dart';
1818
/// The input element is disabled whenever the gesture mode switches to pointer
1919
/// events. This is to prevent the browser from taking over drag gestures. Drag
2020
/// gestures must be interpreted by the Flutter framework.
21-
class Incrementable extends RoleManager {
21+
class Incrementable extends PrimaryRoleManager {
2222
Incrementable(SemanticsObject semanticsObject)
2323
: _focusManager = AccessibilityFocusManager(semanticsObject.owner),
24-
super(Role.incrementable, semanticsObject) {
24+
super.blank(PrimaryRole.incrementable, semanticsObject) {
25+
// The following generic roles can coexist with incrementables. Generic focus
26+
// management is not used by this role because the root DOM element is not
27+
// the one being focused on, but the internal `<input>` element.
28+
addLiveRegion();
29+
addRouteName();
30+
addLabelAndValue();
31+
2532
semanticsObject.element.append(_element);
2633
_element.type = 'range';
2734
_element.setAttribute('role', 'slider');
@@ -80,6 +87,8 @@ class Incrementable extends RoleManager {
8087

8188
@override
8289
void update() {
90+
super.update();
91+
8392
switch (semanticsObject.owner.gestureMode) {
8493
case GestureMode.browserGestures:
8594
_enableBrowserGestureHandling();

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

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import 'package:ui/ui.dart' as ui;
6-
75
import '../dom.dart';
86
import 'semantics.dart';
97

@@ -66,33 +64,6 @@ class LabelAndValue extends RoleManager {
6664

6765
semanticsObject.element
6866
.setAttribute('aria-label', combinedValue.toString());
69-
70-
// Assign one of three roles to the element: heading, group, text.
71-
//
72-
// - "group" is used when the node has children, irrespective of whether the
73-
// node is marked as a header or not. This is because marking a group
74-
// as a "heading" will prevent the AT from reaching its children.
75-
// - "heading" is used when the framework explicitly marks the node as a
76-
// heading and the node does not have children.
77-
// - "text" is used by default.
78-
//
79-
// As of October 24, 2022, "text" only has effect on Safari. Other browsers
80-
// ignore it. Setting role="text" prevents Safari from treating the element
81-
// as a "group" or "empty group". Other browsers still announce it as
82-
// "group" or "empty group". However, other options considered produced even
83-
// worse results, such as:
84-
//
85-
// - Ignore the size of the element and size the focus ring to the text
86-
// content, which is wrong. The HTML text size is irrelevant because
87-
// Flutter renders into canvas, so the focus ring looks wrong.
88-
// - Read out the same label multiple times.
89-
if (semanticsObject.hasChildren) {
90-
semanticsObject.setAriaRole('group', true);
91-
} else if (semanticsObject.hasFlag(ui.SemanticsFlag.isHeader)) {
92-
semanticsObject.setAriaRole('heading', true);
93-
} else {
94-
semanticsObject.setAriaRole('text', true);
95-
}
9667
}
9768

9869
void _cleanUpDom() {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import 'semantics.dart';
1616
/// no content will be read.
1717
class LiveRegion extends RoleManager {
1818
LiveRegion(SemanticsObject semanticsObject)
19-
: super(Role.labelAndValue, semanticsObject);
19+
: super(Role.liveRegion, semanticsObject);
2020

2121
String? _lastAnnouncement;
2222

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ import 'package:ui/ui.dart' as ui;
2222
/// contents is less than the size of the viewport the browser snaps
2323
/// "scrollTop" back to zero. If there is more content than available in the
2424
/// viewport "scrollTop" may take positive values.
25-
class Scrollable extends RoleManager {
25+
class Scrollable extends PrimaryRoleManager {
2626
Scrollable(SemanticsObject semanticsObject)
27-
: super(Role.scrollable, semanticsObject) {
27+
: super.withBasics(PrimaryRole.scrollable, semanticsObject) {
2828
_scrollOverflowElement.style
2929
..position = 'absolute'
3030
..transformOrigin = '0 0 0'
@@ -95,6 +95,8 @@ class Scrollable extends RoleManager {
9595

9696
@override
9797
void update() {
98+
super.update();
99+
98100
semanticsObject.owner.addOneTimePostUpdateCallback(() {
99101
_neutralizeDomScrollPosition();
100102
semanticsObject.recomputePositionAndSize();

0 commit comments

Comments
 (0)