[web:a11y] treat empty tappables as buttons (#161360)

The situation will improve even further when we have proper ARIA roles,
but for now if a node is a leaf node and has a tap action, present it to
semantics as a button even if the `isButton` flag is missing.

Fixes https://github.com/flutter/flutter/issues/157743
This commit is contained in:
Yegor 2025-01-13 15:47:58 -08:00 committed by GitHub
parent 067afb7e69
commit 0d906f5ecf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 54 additions and 66 deletions

View File

@ -1771,6 +1771,8 @@ class SemanticsObject {
return EngineSemanticsRole.link; return EngineSemanticsRole.link;
} else if (isHeader) { } else if (isHeader) {
return EngineSemanticsRole.header; return EngineSemanticsRole.header;
} else if (isButtonLike) {
return EngineSemanticsRole.button;
} else { } else {
return EngineSemanticsRole.generic; return EngineSemanticsRole.generic;
} }
@ -1852,8 +1854,14 @@ class SemanticsObject {
hasAction(ui.SemanticsAction.increase) || hasAction(ui.SemanticsAction.decrease); hasAction(ui.SemanticsAction.increase) || hasAction(ui.SemanticsAction.decrease);
/// Whether the object represents a button. /// Whether the object represents a button.
///
/// See also [isButtonLike].
bool get isButton => hasFlag(ui.SemanticsFlag.isButton); bool get isButton => hasFlag(ui.SemanticsFlag.isButton);
/// Whether the object behaves like a button even if it does not formally have
/// the [ui.SemanticsFlag.isButton] flag.
bool get isButtonLike => isTappable && !hasChildren;
/// Represents a tappable or clickable widget, such as button, icon button, /// Represents a tappable or clickable widget, such as button, icon button,
/// "hamburger" menu, etc. /// "hamburger" menu, etc.
bool get isTappable => hasAction(ui.SemanticsAction.tap); bool get isTappable => hasAction(ui.SemanticsAction.tap);

View File

@ -62,9 +62,6 @@ void runSemanticsTests() {
group('Roles', () { group('Roles', () {
_testRoleLifecycle(); _testRoleLifecycle();
}); });
group('Text', () {
_testText();
});
group('labels', () { group('labels', () {
_testLabels(); _testLabels();
}); });
@ -859,58 +856,6 @@ void _testLongestIncreasingSubsequence() {
}); });
} }
void _testText() {
test('renders a piece of plain text', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(builder, label: 'plain text', rect: const ui.Rect.fromLTRB(0, 0, 100, 50));
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''<sem><span>plain text</span></sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, EngineSemanticsRole.generic);
expect(node.semanticRole!.behaviors!.map((m) => m.runtimeType).toList(), <Type>[
Focusable,
LiveRegion,
RouteName,
LabelAndValue,
]);
semantics().semanticsEnabled = false;
});
test('renders a tappable piece of text', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
hasTap: true,
label: 'tappable text',
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(owner(), '''<sem flt-tappable=""><span>tappable text</span></sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, EngineSemanticsRole.generic);
expect(node.semanticRole!.behaviors!.map((m) => m.runtimeType).toList(), <Type>[
Focusable,
LiveRegion,
RouteName,
LabelAndValue,
Tappable,
]);
semantics().semanticsEnabled = false;
});
}
void _testLabels() { void _testLabels() {
test('computeDomSemanticsLabel combines tooltip, label, value, and hint', () { test('computeDomSemanticsLabel combines tooltip, label, value, and hint', () {
expect(computeDomSemanticsLabel(tooltip: 'tooltip'), 'tooltip'); expect(computeDomSemanticsLabel(tooltip: 'tooltip'), 'tooltip');
@ -2306,7 +2251,7 @@ void _testSelectables() {
} }
void _testTappable() { void _testTappable() {
test('renders an enabled tappable widget', () async { test('renders an enabled button', () async {
semantics() semantics()
..debugOverrideTimestampFunction(() => _testTime) ..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true; ..semanticsEnabled = true;
@ -2335,21 +2280,23 @@ void _testTappable() {
semantics().semanticsEnabled = false; semantics().semanticsEnabled = false;
}); });
test('renders a disabled tappable widget', () async { test('renders a disabled button', () async {
semantics() semantics()
..debugOverrideTimestampFunction(() => _testTime) ..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true; ..semanticsEnabled = true;
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder(); final SemanticsTester tester = SemanticsTester(owner());
updateNode( tester.updateNode(
builder, id: 0,
actions: 0 | ui.SemanticsAction.tap.index, isFocusable: true,
flags: 0 | ui.SemanticsFlag.hasEnabledState.index | ui.SemanticsFlag.isButton.index, hasTap: true,
transform: Matrix4.identity().toFloat64(), hasEnabledState: true,
isEnabled: false,
isButton: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50), rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
); );
tester.apply();
owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), ''' expectSemanticsTree(owner(), '''
<sem role="button" aria-disabled="true"></sem> <sem role="button" aria-disabled="true"></sem>
'''); ''');
@ -2357,7 +2304,40 @@ void _testTappable() {
semantics().semanticsEnabled = false; semantics().semanticsEnabled = false;
}); });
test('can switch tappable between enabled and disabled', () async { test('tappable leaf node is a button', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
isFocusable: true,
hasEnabledState: true,
isEnabled: true,
// Not a button
isButton: false,
// But has a tap action and no children
hasTap: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
expectSemanticsTree(owner(), '''
<sem role="button" flt-tappable></sem>
''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, EngineSemanticsRole.button);
expect(node.semanticRole?.debugSemanticBehaviorTypes, containsAll(<Type>[Focusable, Tappable]));
expect(tester.getSemanticsObject(0).element.tabIndex, 0);
semantics().semanticsEnabled = false;
});
test('can switch a button between enabled and disabled', () async {
semantics() semantics()
..debugOverrideTimestampFunction(() => _testTime) ..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true; ..semanticsEnabled = true;
@ -2390,7 +2370,7 @@ void _testTappable() {
semantics().semanticsEnabled = false; semantics().semanticsEnabled = false;
}); });
test('focuses on tappable after element has been attached', () async { test('focuses on a button after element has been attached', () async {
semantics() semantics()
..debugOverrideTimestampFunction(() => _testTime) ..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true; ..semanticsEnabled = true;