[web] implement selectable semantics (flutter/engine#55970)
Implement `SemanticsFlag.hasSelectedState` and `SemanticsFlag.isSelected` for web in terms of `aria-selected`. Fixes https://github.com/flutter/flutter/issues/66673
This commit is contained in:
parent
9a7492dc88
commit
5d341acdc5
@ -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),
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ class SemanticHeading extends SemanticRole {
|
||||
addLiveRegion();
|
||||
addRouteName();
|
||||
addLabelAndValue(preferredRepresentation: LabelRepresentation.domText);
|
||||
addSelectableBehavior();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -21,6 +21,7 @@ class SemanticImage extends SemanticRole {
|
||||
addLiveRegion();
|
||||
addRouteName();
|
||||
addTappable();
|
||||
addSelectableBehavior();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -424,6 +424,7 @@ abstract class SemanticRole {
|
||||
addLiveRegion();
|
||||
addRouteName();
|
||||
addLabelAndValue(preferredRepresentation: preferredLabelRepresentation);
|
||||
addSelectableBehavior();
|
||||
}
|
||||
|
||||
/// Initializes a blank role for a [semanticsObject].
|
||||
@ -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
|
||||
@ -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
|
||||
|
@ -78,6 +78,9 @@ void runSemanticsTests() {
|
||||
group('checkboxes, radio buttons and switches', () {
|
||||
_testCheckables();
|
||||
});
|
||||
group('selectables', () {
|
||||
_testSelectables();
|
||||
});
|
||||
group('tappable', () {
|
||||
_testTappable();
|
||||
});
|
||||
@ -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()
|
||||
|
@ -32,6 +32,7 @@ class SemanticsTester {
|
||||
int flags = 0,
|
||||
bool? hasCheckedState,
|
||||
bool? isChecked,
|
||||
bool? isSelectable,
|
||||
bool? isSelected,
|
||||
bool? isButton,
|
||||
bool? isLink,
|
||||
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user