Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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