Adds list and list item roles (#164809)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

fixes https://github.com/flutter/flutter/issues/162121

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
chunhtai
2025-03-13 14:41:15 -07:00
committed by GitHub
parent 212d7d8209
commit 5122ffd069
13 changed files with 199 additions and 13 deletions

View File

@@ -42625,6 +42625,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/list.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart + ../../../flutter/LICENSE
@@ -45594,6 +45595,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/list.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart

View File

@@ -114,6 +114,7 @@ export 'engine/semantics/image.dart';
export 'engine/semantics/incrementable.dart';
export 'engine/semantics/label_and_value.dart';
export 'engine/semantics/link.dart';
export 'engine/semantics/list.dart';
export 'engine/semantics/live_region.dart';
export 'engine/semantics/platform_view.dart';
export 'engine/semantics/route.dart';

View File

@@ -13,6 +13,7 @@ export 'semantics/image.dart';
export 'semantics/incrementable.dart';
export 'semantics/label_and_value.dart';
export 'semantics/link.dart';
export 'semantics/list.dart';
export 'semantics/live_region.dart';
export 'semantics/platform_view.dart';
export 'semantics/scrollable.dart';

View File

@@ -9,7 +9,7 @@ import 'semantics.dart';
///
/// Uses aria img role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticImage extends SemanticRole {
SemanticImage(SemanticsObject semanticsObject)
: super.blank(EngineSemanticsRole.image, semanticsObject) {

View File

@@ -0,0 +1,44 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'label_and_value.dart';
import 'semantics.dart';
/// Indicates a list container.
///
/// Uses aria list role to convey this semantic information to the element.
///
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticList extends SemanticRole {
SemanticList(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.list,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('list');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
/// Indicates an item in a list.
///
/// Uses aria listitem role to convey this semantic information to the element.
///
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticListItem extends SemanticRole {
SemanticListItem(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.listItem,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('listitem');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}

View File

@@ -137,7 +137,7 @@ class SemanticDialog extends SemanticRouteBase {
/// Setting this role will also set aria-modal to true, which helps screen
/// reader better understand this section of screen.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
///
/// See also:
///

View File

@@ -29,6 +29,7 @@ import 'image.dart';
import 'incrementable.dart';
import 'label_and_value.dart';
import 'link.dart';
import 'list.dart';
import 'live_region.dart';
import 'platform_view.dart';
import 'route.dart';
@@ -451,6 +452,12 @@ enum EngineSemanticsRole {
/// A component provide important and usually time-sensitive information.
alert,
/// A container whose children are logically a list of items.
list,
/// An item in a [list].
listItem,
/// A role used when a more specific role cannot be assigend to
/// a [SemanticsObject].
///
@@ -1859,6 +1866,10 @@ class SemanticsObject {
return EngineSemanticsRole.alert;
case ui.SemanticsRole.status:
return EngineSemanticsRole.status;
case ui.SemanticsRole.list:
return EngineSemanticsRole.list;
case ui.SemanticsRole.listItem:
return EngineSemanticsRole.listItem;
// TODO(chunhtai): implement these roles.
// https://github.com/flutter/flutter/issues/159741.
case ui.SemanticsRole.searchBox:
@@ -1868,8 +1879,6 @@ class SemanticsObject {
case ui.SemanticsRole.menuBar:
case ui.SemanticsRole.menu:
case ui.SemanticsRole.menuItem:
case ui.SemanticsRole.list:
case ui.SemanticsRole.listItem:
case ui.SemanticsRole.form:
case ui.SemanticsRole.tooltip:
case ui.SemanticsRole.loadingSpinner:
@@ -1920,6 +1929,8 @@ class SemanticsObject {
EngineSemanticsRole.image => SemanticImage(this),
EngineSemanticsRole.platformView => SemanticPlatformView(this),
EngineSemanticsRole.link => SemanticLink(this),
EngineSemanticsRole.list => SemanticList(this),
EngineSemanticsRole.listItem => SemanticListItem(this),
EngineSemanticsRole.heading => SemanticHeading(this),
EngineSemanticsRole.header => SemanticHeader(this),
EngineSemanticsRole.tab => SemanticTab(this),

View File

@@ -9,7 +9,7 @@ import 'semantics.dart';
///
/// Uses aria table role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticTable extends SemanticRole {
SemanticTable(SemanticsObject semanticsObject)
: super.withBasics(
@@ -28,7 +28,7 @@ class SemanticTable extends SemanticRole {
///
/// Uses aria cell role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticCell extends SemanticRole {
SemanticCell(SemanticsObject semanticsObject)
: super.withBasics(
@@ -47,7 +47,7 @@ class SemanticCell extends SemanticRole {
///
/// Uses aria row role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticRow extends SemanticRole {
SemanticRow(SemanticsObject semanticsObject)
: super.withBasics(
@@ -66,7 +66,7 @@ class SemanticRow extends SemanticRole {
///
/// Uses aria columnheader role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticColumnHeader extends SemanticRole {
SemanticColumnHeader(SemanticsObject semanticsObject)
: super.withBasics(

View File

@@ -10,7 +10,7 @@ import 'semantics.dart';
///
/// Uses aria tab role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticTab extends SemanticRole {
SemanticTab(SemanticsObject semanticsObject)
: super.withBasics(
@@ -29,7 +29,7 @@ class SemanticTab extends SemanticRole {
///
/// Uses aria tabpanel role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticTabPanel extends SemanticRole {
SemanticTabPanel(SemanticsObject semanticsObject)
: super.withBasics(
@@ -48,7 +48,7 @@ class SemanticTabPanel extends SemanticRole {
///
/// Uses aria tablist role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
/// Screen-readers take advantage of "aria-label" to describe the visual.
class SemanticTabList extends SemanticRole {
SemanticTabList(SemanticsObject semanticsObject)
: super.withBasics(

View File

@@ -132,6 +132,9 @@ void runSemanticsTests() {
group('table', () {
_testTables();
});
group('list', () {
_testLists();
});
group('controlsNodes', () {
_testControlsNodes();
});
@@ -4088,6 +4091,52 @@ void _testTables() {
semantics().semanticsEnabled = false;
}
void _testLists() {
test('nodes with list role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.list,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.list);
expect(object.element.getAttribute('role'), 'list');
});
test('nodes with list item role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.listItem,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.listItem);
expect(object.element.getAttribute('role'), 'listitem');
});
semantics().semanticsEnabled = false;
}
void _testControlsNodes() {
test('can have multiple controlled nodes', () {
semantics()

View File

@@ -117,6 +117,8 @@ sealed class _DebugSemanticsRoleChecks {
SemanticsRole.radioGroup => _semanticsRadioGroup,
SemanticsRole.alert => _noLiveRegion,
SemanticsRole.status => _noLiveRegion,
SemanticsRole.list => _noCheckRequired,
SemanticsRole.listItem => _semanticsListItem,
// TODO(chunhtai): add checks when the roles are used in framework.
// https://github.com/flutter/flutter/issues/159741.
SemanticsRole.searchBox => _unimplemented,
@@ -126,8 +128,6 @@ sealed class _DebugSemanticsRoleChecks {
SemanticsRole.menuBar => _unimplemented,
SemanticsRole.menu => _unimplemented,
SemanticsRole.menuItem => _unimplemented,
SemanticsRole.list => _unimplemented,
SemanticsRole.listItem => _unimplemented,
SemanticsRole.form => _unimplemented,
SemanticsRole.tooltip => _unimplemented,
SemanticsRole.loadingSpinner => _unimplemented,
@@ -251,6 +251,25 @@ sealed class _DebugSemanticsRoleChecks {
}
return null;
}
static FlutterError? _semanticsListItem(SemanticsNode node) {
final SemanticsData data = node.getSemanticsData();
final SemanticsNode? parent = node.parent;
if (parent == null) {
return FlutterError(
"Semantics node ${node.id} has role ${data.role} but doesn't have a parent",
);
}
final SemanticsData parentSemanticsData = parent.getSemanticsData();
if (parentSemanticsData.role != SemanticsRole.list) {
return FlutterError(
'Semantics node ${node.id} has role ${data.role}, but its '
"parent node ${parent.id} doesn't have the role ${SemanticsRole.list}. "
'Please assign the ${SemanticsRole.list} to node ${parent.id}',
);
}
return null;
}
}
/// A tag for a [SemanticsNode].

View File

@@ -504,6 +504,33 @@ void main() {
expect(attributedHint.attributes[1].range, const TextRange(start: 6, end: 7));
});
testWidgets('Semantics can use list and list item', (WidgetTester tester) async {
final UniqueKey key1 = UniqueKey();
final UniqueKey key2 = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Semantics(
key: key1,
role: SemanticsRole.list,
container: true,
child: Semantics(
key: key2,
role: SemanticsRole.listItem,
container: true,
child: const Placeholder(),
),
),
),
),
);
final SemanticsNode listNode = tester.getSemantics(find.byKey(key1));
final SemanticsNode listItemNode = tester.getSemantics(find.byKey(key2));
expect(listNode.role, SemanticsRole.list);
expect(listItemNode.role, SemanticsRole.listItem);
});
testWidgets('Semantics can merge attributed strings with non attributed string', (
WidgetTester tester,
) async {

View File

@@ -51,6 +51,38 @@ void main() {
});
});
group('list', () {
testWidgets('failure case, list item without list parent', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Semantics(role: SemanticsRole.listItem, child: const Text('some child')),
),
);
final Object? exception = tester.takeException();
expect(exception, isFlutterError);
final FlutterError error = exception! as FlutterError;
expect(
error.message,
startsWith('Semantics node 1 has role ${SemanticsRole.listItem}, but its parent'),
);
});
testWidgets('Success case', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Semantics(
role: SemanticsRole.list,
explicitChildNodes: true,
child: Semantics(role: SemanticsRole.listItem, child: const Text('some child')),
),
),
);
expect(tester.takeException(), isNull);
});
});
group('tabBar', () {
testWidgets('failure case, empty child', (WidgetTester tester) async {
await tester.pumpWidget(