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:
parent
72f83d3237
commit
fe07fb4eba
15
dev/a11y_assessments/web/flutter_bootstrap.js
Normal file
15
dev/a11y_assessments/web/flutter_bootstrap.js
Normal 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,
|
||||
},
|
||||
});
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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({
|
||||
|
Loading…
x
Reference in New Issue
Block a user