Skip to content

Commit

Permalink
[web] implement selectable semantics (#55970)
Browse files Browse the repository at this point in the history
Implement `SemanticsFlag.hasSelectedState` and `SemanticsFlag.isSelected` for web in terms of `aria-selected`.

Fixes flutter/flutter#66673
  • Loading branch information
yjbanov authored Oct 21, 2024
1 parent 5eb21d2 commit 4e6405e
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 1 deletion.
30 changes: 29 additions & 1 deletion lib/web_ui/lib/src/engine/semantics/checkable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ _CheckableKind _checkableKindFromSemanticsFlag(
///
/// See also [ui.SemanticsFlag.hasCheckedState], [ui.SemanticsFlag.isChecked],
/// [ui.SemanticsFlag.isInMutuallyExclusiveGroup], [ui.SemanticsFlag.isToggled],
/// [ui.SemanticsFlag.hasToggledState]
/// [ui.SemanticsFlag.hasToggledState].
///
/// See also [Selectable] behavior, which expresses a similar but different
/// boolean state of being "selected".
class SemanticCheckable extends SemanticRole {
SemanticCheckable(SemanticsObject semanticsObject)
: _kind = _checkableKindFromSemanticsFlag(semanticsObject),
Expand Down Expand Up @@ -113,3 +116,28 @@ class SemanticCheckable extends SemanticRole {
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}

/// Adds selectability behavior to a semantic node.
///
/// A selectable node would have the `aria-selected` set to "true" if the node
/// is currently selected (i.e. [SemanticsObject.isSelected] is true), and set
/// to "false" if it's not selected (i.e. [SemanticsObject.isSelected] is
/// false). If the node is not selectable (i.e. [SemanticsObject.isSelectable]
/// is false), then `aria-selected` is unset.
///
/// See also [SemanticCheckable], which expresses a similar but different
/// boolean state of being "checked" or "toggled".
class Selectable extends SemanticBehavior {
Selectable(super.semanticsObject, super.owner);

@override
void update() {
if (semanticsObject.isFlagsDirty) {
if (semanticsObject.isSelectable) {
owner.setAttribute('aria-selected', semanticsObject.isSelected);
} else {
owner.removeAttribute('aria-selected');
}
}
}
}
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/heading.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class SemanticHeading extends SemanticRole {
addLiveRegion();
addRouteName();
addLabelAndValue(preferredRepresentation: LabelRepresentation.domText);
addSelectableBehavior();
}

@override
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics/image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class SemanticImage extends SemanticRole {
addLiveRegion();
addRouteName();
addTappable();
addSelectableBehavior();
}

@override
Expand Down
36 changes: 36 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/semantics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ abstract class SemanticRole {
addLiveRegion();
addRouteName();
addLabelAndValue(preferredRepresentation: preferredLabelRepresentation);
addSelectableBehavior();
}

/// Initializes a blank role for a [semanticsObject].
Expand Down Expand Up @@ -569,6 +570,16 @@ abstract class SemanticRole {
addSemanticBehavior(Tappable(semanticsObject, this));
}

/// Adds the [Selectable] behavior, if the node is selectable but not checkable.
void addSelectableBehavior() {
// Do not use the [Selectable] behavior on checkables. Checkables use
// special ARIA roles and `aria-checked`. Adding `aria-selected` in addition
// to `aria-checked` would be confusing.
if (semanticsObject.isSelectable && !semanticsObject.isCheckable) {
addSemanticBehavior(Selectable(semanticsObject, this));
}
}

/// Adds a semantic behavior to this role.
///
/// This method should be called by concrete implementations of
Expand Down Expand Up @@ -1778,10 +1789,35 @@ class SemanticsObject {
/// "hamburger" menu, etc.
bool get isTappable => hasAction(ui.SemanticsAction.tap);

/// If true, this node represents something that can be in a "checked" or
/// "toggled" state, such as checkboxes, radios, and switches.
///
/// Because such widgets require the use of specific ARIA roles and HTML
/// elements, they are managed by the [SemanticCheckable] role, and they do
/// not use the [Selectable] behavior.
bool get isCheckable =>
hasFlag(ui.SemanticsFlag.hasCheckedState) ||
hasFlag(ui.SemanticsFlag.hasToggledState);

/// If true, this node represents something that can be annotated as
/// "selected", such as a tab, or an item in a list.
///
/// Selectability is managed by `aria-selected` and is compatible with
/// multiple ARIA roles (tabs, gridcells, options, rows, etc). It is therefore
/// mapped onto the [Selectable] behavior.
///
/// [Selectable] and [SemanticCheckable] are not used together on the same
/// node. [SemanticCheckable] has precendence over [Selectable].
///
/// See also:
///
/// * [isSelected], which indicates whether the node is currently selected.
bool get isSelectable => hasFlag(ui.SemanticsFlag.hasSelectedState);

/// If [isSelectable] is true, indicates whether the node is currently
/// selected.
bool get isSelected => hasFlag(ui.SemanticsFlag.isSelected);

/// Role-specific adjustment of the vertical position of the child container.
///
/// This is used, for example, by the [SemanticScrollable] to compensate for the
Expand Down
111 changes: 111 additions & 0 deletions lib/web_ui/test/engine/semantics/semantics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ void runSemanticsTests() {
group('checkboxes, radio buttons and switches', () {
_testCheckables();
});
group('selectables', () {
_testSelectables();
});
group('tappable', () {
_testTappable();
});
Expand Down Expand Up @@ -2285,6 +2288,114 @@ void _testCheckables() {
});
}

void _testSelectables() {
test('renders and updates non-selectable, selected, and unselected nodes', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;

final tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
rect: const ui.Rect.fromLTRB(0, 0, 100, 60),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
isSelectable: false,
rect: const ui.Rect.fromLTRB(0, 0, 100, 20),
),
tester.updateNode(
id: 2,
isSelectable: true,
isSelected: false,
rect: const ui.Rect.fromLTRB(0, 20, 100, 40),
),
tester.updateNode(
id: 3,
isSelectable: true,
isSelected: true,
rect: const ui.Rect.fromLTRB(0, 40, 100, 60),
),
],
);
tester.apply();

expectSemanticsTree(owner(), '''
<sem>
<sem-c>
<sem></sem>
<sem aria-selected="false"></sem>
<sem aria-selected="true"></sem>
</sem-c>
</sem>
''');

// Missing attributes cannot be expressed using HTML patterns, so check directly.
final nonSelectable = owner().debugSemanticsTree![1]!.element;
expect(nonSelectable.getAttribute('aria-selected'), isNull);

// Flip the values and check that that ARIA attribute is updated.
tester.updateNode(
id: 2,
isSelectable: true,
isSelected: true,
rect: const ui.Rect.fromLTRB(0, 20, 100, 40),
);
tester.updateNode(
id: 3,
isSelectable: true,
isSelected: false,
rect: const ui.Rect.fromLTRB(0, 40, 100, 60),
);
tester.apply();

expectSemanticsTree(owner(), '''
<sem>
<sem-c>
<sem></sem>
<sem aria-selected="true"></sem>
<sem aria-selected="false"></sem>
</sem-c>
</sem>
''');

semantics().semanticsEnabled = false;
});

test('Checkable takes precedence over selectable', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;

final tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
isSelectable: true,
isSelected: true,
hasCheckedState: true,
isChecked: true,
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 60),
);
tester.apply();

expectSemanticsTree(
owner(),
'<sem flt-tappable role="checkbox" aria-checked="true"></sem>',
);

final node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole!.kind, SemanticRoleKind.checkable);
expect(
node.semanticRole!.debugSemanticBehaviorTypes,
isNot(contains(Selectable)),
);
expect(node.element.getAttribute('aria-selected'), isNull);

semantics().semanticsEnabled = false;
});
}

void _testTappable() {
test('renders an enabled tappable widget', () async {
semantics()
Expand Down
4 changes: 4 additions & 0 deletions lib/web_ui/test/engine/semantics/semantics_tester.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class SemanticsTester {
int flags = 0,
bool? hasCheckedState,
bool? isChecked,
bool? isSelectable,
bool? isSelected,
bool? isButton,
bool? isLink,
Expand Down Expand Up @@ -122,6 +123,9 @@ class SemanticsTester {
if (isChecked ?? false) {
flags |= ui.SemanticsFlag.isChecked.index;
}
if (isSelectable ?? false) {
flags |= ui.SemanticsFlag.hasSelectedState.index;
}
if (isSelected ?? false) {
flags |= ui.SemanticsFlag.isSelected.index;
}
Expand Down

0 comments on commit 4e6405e

Please sign in to comment.