Skip to content

Commit 07ab4ad

Browse files
authored
Adds semantics role checks (#162290)
Adds test and error detection system for semantics roles. ## 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
1 parent e24013a commit 07ab4ad

File tree

5 files changed

+211
-30
lines changed

5 files changed

+211
-30
lines changed

packages/flutter/lib/src/material/tabs.dart

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,9 @@ class Tab extends StatelessWidget implements PreferredSizeWidget {
209209
);
210210
}
211211

212-
return Semantics(
213-
role: SemanticsRole.tab,
214-
child: SizedBox(
215-
height: height ?? calculatedHeight,
216-
child: Center(widthFactor: 1.0, child: label),
217-
),
212+
return SizedBox(
213+
height: height ?? calculatedHeight,
214+
child: Center(widthFactor: 1.0, child: label),
218215
);
219216
}
220217

@@ -1909,6 +1906,7 @@ class _TabBarState extends State<TabBar> {
19091906
children: <Widget>[
19101907
wrappedTabs[index],
19111908
Semantics(
1909+
role: SemanticsRole.tab,
19121910
selected: index == _currentIndex,
19131911
label:
19141912
kIsWeb ? null : localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount),
@@ -1924,6 +1922,8 @@ class _TabBarState extends State<TabBar> {
19241922

19251923
Widget tabBar = Semantics(
19261924
role: SemanticsRole.tabBar,
1925+
container: true,
1926+
explicitChildNodes: true,
19271927
child: CustomPaint(
19281928
painter: _indicatorPainter,
19291929
child: _TabStyle(

packages/flutter/lib/src/semantics/semantics.dart

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,62 @@ final int _kUnblockedUserActions =
101101
SemanticsAction.didGainAccessibilityFocus.index |
102102
SemanticsAction.didLoseAccessibilityFocus.index;
103103

104+
/// Function signature for checks in [DebugSemanticsRoleChecks.kChecks].
105+
///
106+
/// The check is run against any `node` that is sent to the platform.
107+
///
108+
/// To access the flags and properties, one should call the
109+
/// [SemanticsNode.getSemanticsData].
110+
@visibleForTesting
111+
typedef DebugSemanticsRoleCheck = FlutterError? Function(SemanticsNode node);
112+
113+
/// A static class to conduct semantics role checks.
114+
///
115+
/// When adding a new [SemanticsRole], one must also add a corresponding check
116+
/// to [kChecks].
117+
@visibleForTesting
118+
sealed class DebugSemanticsRoleChecks {
119+
/// A map to map each [SemanticsRole] to its check.
120+
static const Map<SemanticsRole, DebugSemanticsRoleCheck> kChecks =
121+
<SemanticsRole, DebugSemanticsRoleCheck>{
122+
SemanticsRole.none: _noCheckRequired,
123+
SemanticsRole.tab: _semanticsTab,
124+
SemanticsRole.tabBar: _semanticsTabBar,
125+
SemanticsRole.tabPanel: _noCheckRequired,
126+
};
127+
128+
static FlutterError? _checkSemanticsData(SemanticsNode node) => kChecks[node.role]!(node);
129+
130+
static FlutterError? _noCheckRequired(SemanticsNode node) => null;
131+
132+
static FlutterError? _semanticsTab(SemanticsNode node) {
133+
final SemanticsData data = node.getSemanticsData();
134+
if (!data.hasFlag(SemanticsFlag.hasSelectedState)) {
135+
return FlutterError('A tab needs selected states');
136+
}
137+
138+
if (!node.areUserActionsBlocked && !data.hasAction(SemanticsAction.tap)) {
139+
return FlutterError('A tab must have a tap action');
140+
}
141+
142+
return null;
143+
}
144+
145+
static FlutterError? _semanticsTabBar(SemanticsNode node) {
146+
if (node.childrenCount < 1) {
147+
return FlutterError('a TabBar cannot be empty');
148+
}
149+
FlutterError? error;
150+
node.visitChildren((SemanticsNode child) {
151+
if (child.getSemanticsData().role != SemanticsRole.tab) {
152+
error = FlutterError('Children of TabBar must have the tab role');
153+
}
154+
return error == null;
155+
});
156+
return error;
157+
}
158+
}
159+
104160
/// A tag for a [SemanticsNode].
105161
///
106162
/// Tags can be interpreted by the parent of a [SemanticsNode]
@@ -3003,6 +3059,13 @@ class SemanticsNode with DiagnosticableTreeMixin {
30033059
void _addToUpdate(SemanticsUpdateBuilder builder, Set<int> customSemanticsActionIdsUpdate) {
30043060
assert(_dirty);
30053061
final SemanticsData data = getSemanticsData();
3062+
assert(() {
3063+
final FlutterError? error = DebugSemanticsRoleChecks._checkSemanticsData(this);
3064+
if (error != null) {
3065+
throw error;
3066+
}
3067+
return true;
3068+
}());
30063069
final Int32List childrenInTraversalOrder;
30073070
final Int32List childrenInHitTestOrder;
30083071
if (!hasChildren || mergeAllDescendantsIntoThisNode) {

packages/flutter/test/material/app_bar_sliver_test.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2162,7 +2162,7 @@ void main() {
21622162

21632163
// Regression test for https://github.com/flutter/flutter/issues/158158.
21642164
testWidgets('SliverAppBar should update TabBar before TabBar build', (WidgetTester tester) async {
2165-
final List<Tab> tabs = <Tab>[];
2165+
final List<Tab> tabs = <Tab>[const Tab(text: 'initial tab')];
21662166

21672167
await tester.pumpWidget(
21682168
MaterialApp(
@@ -2179,7 +2179,7 @@ void main() {
21792179
child: const Text('Add Tab'),
21802180
onPressed: () {
21812181
setState(() {
2182-
tabs.add(Tab(text: 'Tab ${tabs.length + 1}'));
2182+
tabs.add(Tab(text: 'Tab ${tabs.length}'));
21832183
});
21842184
},
21852185
),
@@ -2195,7 +2195,8 @@ void main() {
21952195
),
21962196
);
21972197

2198-
// Initializes with zero tabs.
2198+
// Initializes with only initial tabs.
2199+
expect(find.text('initial tab'), findsOneWidget);
21992200
expect(find.text('Tab 1'), findsNothing);
22002201
expect(find.text('Tab 2'), findsNothing);
22012202

packages/flutter/test/material/tabs_test.dart

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6002,39 +6002,44 @@ void main() {
60026002
label: 'Tab 1 of 2',
60036003
id: 1,
60046004
rect: TestSemantics.fullScreen,
6005-
role: SemanticsRole.tabBar,
60066005
children: <TestSemantics>[
60076006
TestSemantics(
6008-
label: 'TAB1${kIsWeb ? '' : '\nTab 1 of 2'}',
6009-
flags: <SemanticsFlag>[
6010-
SemanticsFlag.isFocusable,
6011-
SemanticsFlag.isSelected,
6012-
SemanticsFlag.hasSelectedState,
6013-
],
60146007
id: 2,
6015-
rect: TestSemantics.fullScreen,
6016-
actions: 1 | SemanticsAction.focus.index,
6017-
role: SemanticsRole.tab,
6018-
),
6019-
TestSemantics(
6020-
label: 'TAB2${kIsWeb ? '' : '\nTab 2 of 2'}',
6021-
flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState],
6022-
id: 3,
6023-
rect: TestSemantics.fullScreen,
6024-
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
6025-
role: SemanticsRole.tab,
6008+
role: SemanticsRole.tabBar,
6009+
children: <TestSemantics>[
6010+
TestSemantics(
6011+
label: 'TAB1${kIsWeb ? '' : '\nTab 1 of 2'}',
6012+
flags: <SemanticsFlag>[
6013+
SemanticsFlag.isFocusable,
6014+
SemanticsFlag.isSelected,
6015+
SemanticsFlag.hasSelectedState,
6016+
],
6017+
id: 3,
6018+
rect: TestSemantics.fullScreen,
6019+
actions: 1 | SemanticsAction.focus.index,
6020+
role: SemanticsRole.tab,
6021+
),
6022+
TestSemantics(
6023+
label: 'TAB2${kIsWeb ? '' : '\nTab 2 of 2'}',
6024+
flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState],
6025+
id: 4,
6026+
rect: TestSemantics.fullScreen,
6027+
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
6028+
role: SemanticsRole.tab,
6029+
),
6030+
],
60266031
),
60276032
TestSemantics(
6028-
id: 4,
6033+
id: 5,
60296034
rect: TestSemantics.fullScreen,
60306035
children: <TestSemantics>[
60316036
TestSemantics(
6032-
id: 6,
6037+
id: 7,
60336038
rect: TestSemantics.fullScreen,
60346039
actions: <SemanticsAction>[SemanticsAction.scrollLeft],
60356040
children: <TestSemantics>[
60366041
TestSemantics(
6037-
id: 5,
6042+
id: 6,
60386043
rect: TestSemantics.fullScreen,
60396044
label: 'PAGE1',
60406045
role: SemanticsRole.tabPanel,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:ui';
6+
7+
import 'package:flutter/rendering.dart';
8+
import 'package:flutter/widgets.dart';
9+
import 'package:flutter_test/flutter_test.dart';
10+
11+
void main() {
12+
test('All semantics roles has checks', () {
13+
expect(SemanticsRole.values.toSet(), DebugSemanticsRoleChecks.kChecks.keys.toSet());
14+
});
15+
16+
group('tab', () {
17+
testWidgets('failure case, empty', (WidgetTester tester) async {
18+
await tester.pumpWidget(
19+
Directionality(
20+
textDirection: TextDirection.ltr,
21+
child: Semantics(role: SemanticsRole.tab, child: const Text('a tab')),
22+
),
23+
);
24+
final Object? exception = tester.takeException();
25+
expect(exception, isFlutterError);
26+
final FlutterError error = exception! as FlutterError;
27+
expect(error.message, 'A tab needs selected states');
28+
});
29+
30+
testWidgets('failure case, no tap', (WidgetTester tester) async {
31+
await tester.pumpWidget(
32+
Directionality(
33+
textDirection: TextDirection.ltr,
34+
child: Semantics(role: SemanticsRole.tab, selected: false, child: const Text('a tab')),
35+
),
36+
);
37+
final Object? exception = tester.takeException();
38+
expect(exception, isFlutterError);
39+
final FlutterError error = exception! as FlutterError;
40+
expect(error.message, 'A tab must have a tap action');
41+
});
42+
43+
testWidgets('success case', (WidgetTester tester) async {
44+
await tester.pumpWidget(
45+
Directionality(
46+
textDirection: TextDirection.ltr,
47+
child: Semantics(
48+
role: SemanticsRole.tab,
49+
selected: false,
50+
onTap: () {},
51+
child: const Text('a tab'),
52+
),
53+
),
54+
);
55+
expect(tester.takeException(), isNull);
56+
});
57+
});
58+
59+
group('tabBar', () {
60+
testWidgets('failure case, empty child', (WidgetTester tester) async {
61+
await tester.pumpWidget(
62+
Directionality(
63+
textDirection: TextDirection.ltr,
64+
child: Semantics(
65+
role: SemanticsRole.tabBar,
66+
child: const ExcludeSemantics(child: Text('something')),
67+
),
68+
),
69+
);
70+
final Object? exception = tester.takeException();
71+
expect(exception, isFlutterError);
72+
final FlutterError error = exception! as FlutterError;
73+
expect(error.message, 'a TabBar cannot be empty');
74+
});
75+
76+
testWidgets('failure case, non tab child', (WidgetTester tester) async {
77+
await tester.pumpWidget(
78+
Directionality(
79+
textDirection: TextDirection.ltr,
80+
child: Semantics(
81+
role: SemanticsRole.tabBar,
82+
explicitChildNodes: true,
83+
child: Semantics(child: const Text('some child')),
84+
),
85+
),
86+
);
87+
final Object? exception = tester.takeException();
88+
expect(exception, isFlutterError);
89+
final FlutterError error = exception! as FlutterError;
90+
expect(error.message, 'Children of TabBar must have the tab role');
91+
});
92+
93+
testWidgets('Success case', (WidgetTester tester) async {
94+
await tester.pumpWidget(
95+
Directionality(
96+
textDirection: TextDirection.ltr,
97+
child: Semantics(
98+
role: SemanticsRole.tabBar,
99+
explicitChildNodes: true,
100+
child: Semantics(
101+
role: SemanticsRole.tab,
102+
selected: false,
103+
onTap: () {},
104+
child: const Text('some child'),
105+
),
106+
),
107+
),
108+
);
109+
expect(tester.takeException(), isNull);
110+
});
111+
});
112+
}

0 commit comments

Comments
 (0)