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>
|
<title>a11y_assessments</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
@ -2755,8 +2755,11 @@ class SemanticsNode with DiagnosticableTreeMixin {
|
|||||||
platformViewId ??= node._platformViewId;
|
platformViewId ??= node._platformViewId;
|
||||||
maxValueLength ??= node._maxValueLength;
|
maxValueLength ??= node._maxValueLength;
|
||||||
currentValueLength ??= node._currentValueLength;
|
currentValueLength ??= node._currentValueLength;
|
||||||
headingLevel = node._headingLevel;
|
|
||||||
linkUrl ??= node._linkUrl;
|
linkUrl ??= node._linkUrl;
|
||||||
|
headingLevel = _mergeHeadingLevels(
|
||||||
|
sourceLevel: node._headingLevel,
|
||||||
|
targetLevel: headingLevel,
|
||||||
|
);
|
||||||
|
|
||||||
if (identifier == '') {
|
if (identifier == '') {
|
||||||
identifier = node._identifier;
|
identifier = node._identifier;
|
||||||
@ -3069,6 +3072,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
|
|||||||
properties.add(IntProperty('indexInParent', indexInParent, defaultValue: null));
|
properties.add(IntProperty('indexInParent', indexInParent, defaultValue: null));
|
||||||
properties.add(DoubleProperty('elevation', elevation, defaultValue: 0.0));
|
properties.add(DoubleProperty('elevation', elevation, defaultValue: 0.0));
|
||||||
properties.add(DoubleProperty('thickness', thickness, 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.
|
/// 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);
|
assert(node.parent == null || !node.parent!.isPartOfNodeMerging || node.isMergedIntoParent);
|
||||||
if (node.isPartOfNodeMerging) {
|
if (node.isPartOfNodeMerging) {
|
||||||
assert(node.mergeAllDescendantsIntoThisNode || node.parent != null);
|
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) {
|
if (node.parent != null && node.parent!.isPartOfNodeMerging) {
|
||||||
node.parent!._markDirty(); // this can add the node to the dirty list
|
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;
|
_maxValueLength ??= child._maxValueLength;
|
||||||
_currentValueLength ??= child._currentValueLength;
|
_currentValueLength ??= child._currentValueLength;
|
||||||
|
|
||||||
|
_headingLevel = _mergeHeadingLevels(
|
||||||
|
sourceLevel: child._headingLevel,
|
||||||
|
targetLevel: _headingLevel,
|
||||||
|
);
|
||||||
|
|
||||||
textDirection ??= child.textDirection;
|
textDirection ??= child.textDirection;
|
||||||
_sortKey ??= child._sortKey;
|
_sortKey ??= child._sortKey;
|
||||||
if (_identifier == '') {
|
if (_identifier == '') {
|
||||||
@ -5316,3 +5325,16 @@ class OrdinalSortKey extends SemanticsSortKey {
|
|||||||
properties.add(DoubleProperty('order', order, defaultValue: null));
|
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'
|
' scrollExtentMax: null\n'
|
||||||
' indexInParent: null\n'
|
' indexInParent: null\n'
|
||||||
' elevation: 0.0\n'
|
' elevation: 0.0\n'
|
||||||
' thickness: 0.0\n',
|
' thickness: 0.0\n'
|
||||||
|
' headingLevel: 0\n',
|
||||||
);
|
);
|
||||||
|
|
||||||
final SemanticsConfiguration config = SemanticsConfiguration()
|
final SemanticsConfiguration config = SemanticsConfiguration()
|
||||||
@ -826,7 +827,8 @@ void main() {
|
|||||||
' scrollExtentMax: null\n'
|
' scrollExtentMax: null\n'
|
||||||
' indexInParent: null\n'
|
' indexInParent: null\n'
|
||||||
' elevation: 0.0\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 {
|
class CustomSortKey extends OrdinalSortKey {
|
||||||
|
@ -38,6 +38,7 @@ class TestSemantics {
|
|||||||
this.label = '',
|
this.label = '',
|
||||||
this.value = '',
|
this.value = '',
|
||||||
this.tooltip = '',
|
this.tooltip = '',
|
||||||
|
this.headingLevel,
|
||||||
this.increasedValue = '',
|
this.increasedValue = '',
|
||||||
this.decreasedValue = '',
|
this.decreasedValue = '',
|
||||||
this.hint = '',
|
this.hint = '',
|
||||||
@ -66,6 +67,7 @@ class TestSemantics {
|
|||||||
this.decreasedValue = '',
|
this.decreasedValue = '',
|
||||||
this.hint = '',
|
this.hint = '',
|
||||||
this.tooltip = '',
|
this.tooltip = '',
|
||||||
|
this.headingLevel,
|
||||||
this.textDirection,
|
this.textDirection,
|
||||||
this.transform,
|
this.transform,
|
||||||
this.textSelection,
|
this.textSelection,
|
||||||
@ -99,6 +101,7 @@ class TestSemantics {
|
|||||||
this.hint = '',
|
this.hint = '',
|
||||||
this.value = '',
|
this.value = '',
|
||||||
this.tooltip = '',
|
this.tooltip = '',
|
||||||
|
this.headingLevel,
|
||||||
this.increasedValue = '',
|
this.increasedValue = '',
|
||||||
this.decreasedValue = '',
|
this.decreasedValue = '',
|
||||||
this.textDirection,
|
this.textDirection,
|
||||||
@ -237,6 +240,8 @@ class TestSemantics {
|
|||||||
/// The tags of this node.
|
/// The tags of this node.
|
||||||
final Set<SemanticsTag> tags;
|
final Set<SemanticsTag> tags;
|
||||||
|
|
||||||
|
final int? headingLevel;
|
||||||
|
|
||||||
bool _matches(
|
bool _matches(
|
||||||
SemanticsNode? node,
|
SemanticsNode? node,
|
||||||
Map<dynamic, dynamic> matchState, {
|
Map<dynamic, dynamic> matchState, {
|
||||||
@ -322,6 +327,9 @@ class TestSemantics {
|
|||||||
if (children.length != childrenCount) {
|
if (children.length != childrenCount) {
|
||||||
return fail('expected node id $id to have ${children.length} child${ children.length == 1 ? "" : "ren" } but found $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) {
|
if (children.isEmpty) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -1737,6 +1737,42 @@ void main() {
|
|||||||
|
|
||||||
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
|
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({
|
Future<void> _pumpTextWidget({
|
||||||
|
Loading…
x
Reference in New Issue
Block a user