fix heading level absorption, diagnostics; add tests and an a11y use-case (#151421)

Multiple fixes related to heading levels:

* Fix heading level absorption. Heading level would get erased when a semantics config is absorbed into another. With this change the highest heading level wins.
* Add `headingLevel` to the diagnostics of `SemanticsNode`.
* Add unit-tests for heading levels.
* Add an a11y use-case for headings.

Improves https://github.com/flutter/flutter/issues/46789 and general accessibility of headings.
This commit is contained in:
Yegor 2024-07-09 22:11:38 -07:00 committed by GitHub
parent 72f83d3237
commit fe07fb4eba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 218 additions and 11 deletions

View File

@ -0,0 +1,15 @@
// Copyright 2014 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.
{{flutter_js}}
{{flutter_build_config}}
_flutter.loader.load({
config: {
// Use the local CanvasKit bundle instead of the CDN to reduce test flakiness.
canvasKitBaseUrl: "/canvaskit/",
// Set this to `true` to make semantic DOM nodes visible on the UI. This is
// sometimes useful for debugging.
debugShowSemanticsNodes: false,
},
});

View File

@ -35,12 +35,6 @@ found in the LICENSE file. -->
<title>a11y_assessments</title>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
</head>
<body>
<script src="flutter_bootstrap.js" async></script>

View File

@ -2755,8 +2755,11 @@ class SemanticsNode with DiagnosticableTreeMixin {
platformViewId ??= node._platformViewId;
maxValueLength ??= node._maxValueLength;
currentValueLength ??= node._currentValueLength;
headingLevel = node._headingLevel;
linkUrl ??= node._linkUrl;
headingLevel = _mergeHeadingLevels(
sourceLevel: node._headingLevel,
targetLevel: headingLevel,
);
if (identifier == '') {
identifier = node._identifier;
@ -3069,6 +3072,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
properties.add(IntProperty('indexInParent', indexInParent, defaultValue: null));
properties.add(DoubleProperty('elevation', elevation, defaultValue: 0.0));
properties.add(DoubleProperty('thickness', thickness, defaultValue: 0.0));
properties.add(IntProperty('headingLevel', _headingLevel, defaultValue: 0));
}
/// Returns a string representation of this node and its descendants.
@ -3539,10 +3543,10 @@ class SemanticsOwner extends ChangeNotifier {
assert(node.parent == null || !node.parent!.isPartOfNodeMerging || node.isMergedIntoParent);
if (node.isPartOfNodeMerging) {
assert(node.mergeAllDescendantsIntoThisNode || node.parent != null);
// if we're merged into our parent, make sure our parent is added to the dirty list
// If child node is merged into its parent, make sure the parent is marked as dirty
if (node.parent != null && node.parent!.isPartOfNodeMerging) {
node.parent!._markDirty(); // this can add the node to the dirty list
node._dirty = false; // We don't want to send update for this node.
node._dirty = false; // Do not send update for this node, as it's now part of its parent
}
}
}
@ -5079,6 +5083,11 @@ class SemanticsConfiguration {
_maxValueLength ??= child._maxValueLength;
_currentValueLength ??= child._currentValueLength;
_headingLevel = _mergeHeadingLevels(
sourceLevel: child._headingLevel,
targetLevel: _headingLevel,
);
textDirection ??= child.textDirection;
_sortKey ??= child._sortKey;
if (_identifier == '') {
@ -5316,3 +5325,16 @@ class OrdinalSortKey extends SemanticsSortKey {
properties.add(DoubleProperty('order', order, defaultValue: null));
}
}
/// Picks the most accurate heading level when two nodes, with potentially
/// different heading levels, are merged.
///
/// Argument [sourceLevel] is the heading level of the source node that is being
/// merged into a target node, which has heading level [targetLevel].
///
/// If the target node is not a heading, the the source heading level is used.
/// Otherwise, the target heading level is used irrespective of the source
/// heading level.
int _mergeHeadingLevels({required int sourceLevel, required int targetLevel}) {
return targetLevel == 0 ? sourceLevel : targetLevel;
}

View File

@ -701,7 +701,8 @@ void main() {
' scrollExtentMax: null\n'
' indexInParent: null\n'
' elevation: 0.0\n'
' thickness: 0.0\n',
' thickness: 0.0\n'
' headingLevel: 0\n',
);
final SemanticsConfiguration config = SemanticsConfiguration()
@ -826,7 +827,8 @@ void main() {
' scrollExtentMax: null\n'
' indexInParent: null\n'
' elevation: 0.0\n'
' thickness: 0.0\n',
' thickness: 0.0\n'
' headingLevel: 0\n',
);
});

View File

@ -1790,6 +1790,136 @@ void main() {
),
);
});
testWidgets('supports heading levels', (WidgetTester tester) async {
// Default: not a heading.
expect(
Semantics(child: const Text('dummy text')).properties.headingLevel,
isNull,
);
// Headings level 1-6.
for (int level = 1; level <= 6; level++) {
final Semantics semantics = Semantics(
headingLevel: level,
child: const Text('dummy text'),
);
expect(semantics.properties.headingLevel, level);
}
// Invalid heading levels.
for (final int badLevel in const <int>[-1, 0, 7, 8, 9]) {
expect(
() => Semantics(
headingLevel: badLevel,
child: const Text('dummy text'),
),
throwsAssertionError,
);
}
});
testWidgets('parent heading level takes precendence when it absorbs a child', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
Future<SemanticsConfiguration> pumpHeading(int? level) async {
final ValueKey<String> key = ValueKey<String>('heading-$level');
await tester.pumpWidget(
Semantics(
key: key,
headingLevel: level,
child: Text(
'Heading level $level',
textDirection: TextDirection.ltr,
),
)
);
final RenderSemanticsAnnotations object = tester.renderObject<RenderSemanticsAnnotations>(find.byKey(key));
final SemanticsConfiguration config = SemanticsConfiguration();
object.describeSemanticsConfiguration(config);
return config;
}
// Tuples contain (parent level, child level, expected combined level).
final List<(int, int, int)> scenarios = <(int, int, int)>[
// Case: neither are headings
(0, 0, 0), // expect not a heading
// Case: parent not a heading, child always wins.
(0, 1, 1),
(0, 2, 2),
// Case: child not a heading, parent always wins.
(1, 0, 1),
(2, 0, 2),
// Case: child heading level higher, parent still wins.
(3, 2, 3),
(4, 1, 4),
// Case: parent heading level higher, parent still wins.
(2, 3, 2),
(1, 5, 1),
];
for (final (int, int, int) scenario in scenarios) {
final int parentLevel = scenario.$1;
final int childLevel = scenario.$2;
final int resultLevel = scenario.$3;
final SemanticsConfiguration parent = await pumpHeading(parentLevel == 0 ? null : parentLevel);
final SemanticsConfiguration child = SemanticsConfiguration()
..headingLevel = childLevel;
parent.absorb(child);
expect(
reason: 'parent heading level is $parentLevel, '
'child heading level is $childLevel, '
'expecting $resultLevel.',
parent.headingLevel, resultLevel);
}
semantics.dispose();
});
testWidgets('applies heading semantics to semantics tree', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Headings')),
body: ListView(
children: <Widget>[
for (int level = 1; level <= 6; level++)
Semantics(
key: ValueKey<String>('heading-$level'),
headingLevel: level,
child: Text('Heading level $level'),
),
const Text('This is not a heading'),
],
),
),
),
);
for (int level = 1; level <= 6; level++) {
final ValueKey<String> key = ValueKey<String>('heading-$level');
final SemanticsNode node = tester.getSemantics(find.byKey(key));
expect(
'$node',
contains('headingLevel: $level'),
);
}
final SemanticsNode notHeading = tester.getSemantics(find.text('This is not a heading'));
expect(
notHeading,
isNot(contains('headingLevel')),
);
semantics.dispose();
});
}
class CustomSortKey extends OrdinalSortKey {

View File

@ -38,6 +38,7 @@ class TestSemantics {
this.label = '',
this.value = '',
this.tooltip = '',
this.headingLevel,
this.increasedValue = '',
this.decreasedValue = '',
this.hint = '',
@ -66,6 +67,7 @@ class TestSemantics {
this.decreasedValue = '',
this.hint = '',
this.tooltip = '',
this.headingLevel,
this.textDirection,
this.transform,
this.textSelection,
@ -99,6 +101,7 @@ class TestSemantics {
this.hint = '',
this.value = '',
this.tooltip = '',
this.headingLevel,
this.increasedValue = '',
this.decreasedValue = '',
this.textDirection,
@ -237,6 +240,8 @@ class TestSemantics {
/// The tags of this node.
final Set<SemanticsTag> tags;
final int? headingLevel;
bool _matches(
SemanticsNode? node,
Map<dynamic, dynamic> matchState, {
@ -322,6 +327,9 @@ class TestSemantics {
if (children.length != childrenCount) {
return fail('expected node id $id to have ${children.length} child${ children.length == 1 ? "" : "ren" } but found $childrenCount.');
}
if (headingLevel != null && headingLevel != node.headingLevel) {
return fail('expected node id $id to have headingLevel $headingLevel but found headingLevel ${node.headingLevel}');
}
if (children.isEmpty) {
return true;

View File

@ -1737,6 +1737,42 @@ void main() {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
});
testWidgets('can set heading level', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
for (int level = 1; level <= 6; level++) {
await tester.pumpWidget(
Semantics(
headingLevel: 1,
child: Text(
'Heading level $level',
textDirection: TextDirection.ltr,
),
)
);
final TestSemantics expectedSemantics = TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
label: 'Heading level $level',
headingLevel: 1,
textDirection: TextDirection.ltr,
),
],
);
expect(
semantics,
hasSemantics(
expectedSemantics,
ignoreTransform: true,
ignoreId: true,
ignoreRect: true,
),
);
}
semantics.dispose();
});
}
Future<void> _pumpTextWidget({