Skip to content

Commit fe07fb4

Browse files
authored
fix heading level absorption, diagnostics; add tests and an a11y use-case (flutter#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 flutter#46789 and general accessibility of headings.
1 parent 72f83d3 commit fe07fb4

File tree

7 files changed

+218
-11
lines changed

7 files changed

+218
-11
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
{{flutter_js}}
6+
{{flutter_build_config}}
7+
_flutter.loader.load({
8+
config: {
9+
// Use the local CanvasKit bundle instead of the CDN to reduce test flakiness.
10+
canvasKitBaseUrl: "/canvaskit/",
11+
// Set this to `true` to make semantic DOM nodes visible on the UI. This is
12+
// sometimes useful for debugging.
13+
debugShowSemanticsNodes: false,
14+
},
15+
});

dev/a11y_assessments/web/index.html

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,6 @@
3535

3636
<title>a11y_assessments</title>
3737
<link rel="manifest" href="manifest.json">
38-
39-
<script>
40-
// The value below is injected by flutter build, do not touch.
41-
const serviceWorkerVersion = null;
42-
</script>
43-
<!-- This script adds the flutter initialization JS code -->
4438
</head>
4539
<body>
4640
<script src="flutter_bootstrap.js" async></script>

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2755,8 +2755,11 @@ class SemanticsNode with DiagnosticableTreeMixin {
27552755
platformViewId ??= node._platformViewId;
27562756
maxValueLength ??= node._maxValueLength;
27572757
currentValueLength ??= node._currentValueLength;
2758-
headingLevel = node._headingLevel;
27592758
linkUrl ??= node._linkUrl;
2759+
headingLevel = _mergeHeadingLevels(
2760+
sourceLevel: node._headingLevel,
2761+
targetLevel: headingLevel,
2762+
);
27602763

27612764
if (identifier == '') {
27622765
identifier = node._identifier;
@@ -3069,6 +3072,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
30693072
properties.add(IntProperty('indexInParent', indexInParent, defaultValue: null));
30703073
properties.add(DoubleProperty('elevation', elevation, defaultValue: 0.0));
30713074
properties.add(DoubleProperty('thickness', thickness, defaultValue: 0.0));
3075+
properties.add(IntProperty('headingLevel', _headingLevel, defaultValue: 0));
30723076
}
30733077

30743078
/// Returns a string representation of this node and its descendants.
@@ -3539,10 +3543,10 @@ class SemanticsOwner extends ChangeNotifier {
35393543
assert(node.parent == null || !node.parent!.isPartOfNodeMerging || node.isMergedIntoParent);
35403544
if (node.isPartOfNodeMerging) {
35413545
assert(node.mergeAllDescendantsIntoThisNode || node.parent != null);
3542-
// if we're merged into our parent, make sure our parent is added to the dirty list
3546+
// If child node is merged into its parent, make sure the parent is marked as dirty
35433547
if (node.parent != null && node.parent!.isPartOfNodeMerging) {
35443548
node.parent!._markDirty(); // this can add the node to the dirty list
3545-
node._dirty = false; // We don't want to send update for this node.
3549+
node._dirty = false; // Do not send update for this node, as it's now part of its parent
35463550
}
35473551
}
35483552
}
@@ -5079,6 +5083,11 @@ class SemanticsConfiguration {
50795083
_maxValueLength ??= child._maxValueLength;
50805084
_currentValueLength ??= child._currentValueLength;
50815085

5086+
_headingLevel = _mergeHeadingLevels(
5087+
sourceLevel: child._headingLevel,
5088+
targetLevel: _headingLevel,
5089+
);
5090+
50825091
textDirection ??= child.textDirection;
50835092
_sortKey ??= child._sortKey;
50845093
if (_identifier == '') {
@@ -5316,3 +5325,16 @@ class OrdinalSortKey extends SemanticsSortKey {
53165325
properties.add(DoubleProperty('order', order, defaultValue: null));
53175326
}
53185327
}
5328+
5329+
/// Picks the most accurate heading level when two nodes, with potentially
5330+
/// different heading levels, are merged.
5331+
///
5332+
/// Argument [sourceLevel] is the heading level of the source node that is being
5333+
/// merged into a target node, which has heading level [targetLevel].
5334+
///
5335+
/// If the target node is not a heading, the the source heading level is used.
5336+
/// Otherwise, the target heading level is used irrespective of the source
5337+
/// heading level.
5338+
int _mergeHeadingLevels({required int sourceLevel, required int targetLevel}) {
5339+
return targetLevel == 0 ? sourceLevel : targetLevel;
5340+
}

packages/flutter/test/semantics/semantics_test.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,8 @@ void main() {
701701
' scrollExtentMax: null\n'
702702
' indexInParent: null\n'
703703
' elevation: 0.0\n'
704-
' thickness: 0.0\n',
704+
' thickness: 0.0\n'
705+
' headingLevel: 0\n',
705706
);
706707

707708
final SemanticsConfiguration config = SemanticsConfiguration()
@@ -826,7 +827,8 @@ void main() {
826827
' scrollExtentMax: null\n'
827828
' indexInParent: null\n'
828829
' elevation: 0.0\n'
829-
' thickness: 0.0\n',
830+
' thickness: 0.0\n'
831+
' headingLevel: 0\n',
830832
);
831833
});
832834

packages/flutter/test/widgets/semantics_test.dart

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,6 +1790,136 @@ void main() {
17901790
),
17911791
);
17921792
});
1793+
1794+
testWidgets('supports heading levels', (WidgetTester tester) async {
1795+
// Default: not a heading.
1796+
expect(
1797+
Semantics(child: const Text('dummy text')).properties.headingLevel,
1798+
isNull,
1799+
);
1800+
1801+
// Headings level 1-6.
1802+
for (int level = 1; level <= 6; level++) {
1803+
final Semantics semantics = Semantics(
1804+
headingLevel: level,
1805+
child: const Text('dummy text'),
1806+
);
1807+
expect(semantics.properties.headingLevel, level);
1808+
}
1809+
1810+
// Invalid heading levels.
1811+
for (final int badLevel in const <int>[-1, 0, 7, 8, 9]) {
1812+
expect(
1813+
() => Semantics(
1814+
headingLevel: badLevel,
1815+
child: const Text('dummy text'),
1816+
),
1817+
throwsAssertionError,
1818+
);
1819+
}
1820+
});
1821+
1822+
testWidgets('parent heading level takes precendence when it absorbs a child', (WidgetTester tester) async {
1823+
final SemanticsTester semantics = SemanticsTester(tester);
1824+
1825+
Future<SemanticsConfiguration> pumpHeading(int? level) async {
1826+
final ValueKey<String> key = ValueKey<String>('heading-$level');
1827+
await tester.pumpWidget(
1828+
Semantics(
1829+
key: key,
1830+
headingLevel: level,
1831+
child: Text(
1832+
'Heading level $level',
1833+
textDirection: TextDirection.ltr,
1834+
),
1835+
)
1836+
);
1837+
final RenderSemanticsAnnotations object = tester.renderObject<RenderSemanticsAnnotations>(find.byKey(key));
1838+
final SemanticsConfiguration config = SemanticsConfiguration();
1839+
object.describeSemanticsConfiguration(config);
1840+
return config;
1841+
}
1842+
1843+
// Tuples contain (parent level, child level, expected combined level).
1844+
final List<(int, int, int)> scenarios = <(int, int, int)>[
1845+
// Case: neither are headings
1846+
(0, 0, 0), // expect not a heading
1847+
1848+
// Case: parent not a heading, child always wins.
1849+
(0, 1, 1),
1850+
(0, 2, 2),
1851+
1852+
// Case: child not a heading, parent always wins.
1853+
(1, 0, 1),
1854+
(2, 0, 2),
1855+
1856+
// Case: child heading level higher, parent still wins.
1857+
(3, 2, 3),
1858+
(4, 1, 4),
1859+
1860+
// Case: parent heading level higher, parent still wins.
1861+
(2, 3, 2),
1862+
(1, 5, 1),
1863+
];
1864+
1865+
for (final (int, int, int) scenario in scenarios) {
1866+
final int parentLevel = scenario.$1;
1867+
final int childLevel = scenario.$2;
1868+
final int resultLevel = scenario.$3;
1869+
1870+
final SemanticsConfiguration parent = await pumpHeading(parentLevel == 0 ? null : parentLevel);
1871+
final SemanticsConfiguration child = SemanticsConfiguration()
1872+
..headingLevel = childLevel;
1873+
parent.absorb(child);
1874+
expect(
1875+
reason: 'parent heading level is $parentLevel, '
1876+
'child heading level is $childLevel, '
1877+
'expecting $resultLevel.',
1878+
parent.headingLevel, resultLevel);
1879+
}
1880+
1881+
semantics.dispose();
1882+
});
1883+
1884+
testWidgets('applies heading semantics to semantics tree', (WidgetTester tester) async {
1885+
final SemanticsTester semantics = SemanticsTester(tester);
1886+
1887+
await tester.pumpWidget(
1888+
MaterialApp(
1889+
home: Scaffold(
1890+
appBar: AppBar(title: const Text('Headings')),
1891+
body: ListView(
1892+
children: <Widget>[
1893+
for (int level = 1; level <= 6; level++)
1894+
Semantics(
1895+
key: ValueKey<String>('heading-$level'),
1896+
headingLevel: level,
1897+
child: Text('Heading level $level'),
1898+
),
1899+
const Text('This is not a heading'),
1900+
],
1901+
),
1902+
),
1903+
),
1904+
);
1905+
1906+
for (int level = 1; level <= 6; level++) {
1907+
final ValueKey<String> key = ValueKey<String>('heading-$level');
1908+
final SemanticsNode node = tester.getSemantics(find.byKey(key));
1909+
expect(
1910+
'$node',
1911+
contains('headingLevel: $level'),
1912+
);
1913+
}
1914+
1915+
final SemanticsNode notHeading = tester.getSemantics(find.text('This is not a heading'));
1916+
expect(
1917+
notHeading,
1918+
isNot(contains('headingLevel')),
1919+
);
1920+
1921+
semantics.dispose();
1922+
});
17931923
}
17941924

17951925
class CustomSortKey extends OrdinalSortKey {

packages/flutter/test/widgets/semantics_tester.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class TestSemantics {
3838
this.label = '',
3939
this.value = '',
4040
this.tooltip = '',
41+
this.headingLevel,
4142
this.increasedValue = '',
4243
this.decreasedValue = '',
4344
this.hint = '',
@@ -66,6 +67,7 @@ class TestSemantics {
6667
this.decreasedValue = '',
6768
this.hint = '',
6869
this.tooltip = '',
70+
this.headingLevel,
6971
this.textDirection,
7072
this.transform,
7173
this.textSelection,
@@ -99,6 +101,7 @@ class TestSemantics {
99101
this.hint = '',
100102
this.value = '',
101103
this.tooltip = '',
104+
this.headingLevel,
102105
this.increasedValue = '',
103106
this.decreasedValue = '',
104107
this.textDirection,
@@ -237,6 +240,8 @@ class TestSemantics {
237240
/// The tags of this node.
238241
final Set<SemanticsTag> tags;
239242

243+
final int? headingLevel;
244+
240245
bool _matches(
241246
SemanticsNode? node,
242247
Map<dynamic, dynamic> matchState, {
@@ -322,6 +327,9 @@ class TestSemantics {
322327
if (children.length != childrenCount) {
323328
return fail('expected node id $id to have ${children.length} child${ children.length == 1 ? "" : "ren" } but found $childrenCount.');
324329
}
330+
if (headingLevel != null && headingLevel != node.headingLevel) {
331+
return fail('expected node id $id to have headingLevel $headingLevel but found headingLevel ${node.headingLevel}');
332+
}
325333

326334
if (children.isEmpty) {
327335
return true;

packages/flutter/test/widgets/text_test.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,6 +1737,42 @@ void main() {
17371737

17381738
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click);
17391739
});
1740+
1741+
testWidgets('can set heading level', (WidgetTester tester) async {
1742+
final SemanticsTester semantics = SemanticsTester(tester);
1743+
1744+
for (int level = 1; level <= 6; level++) {
1745+
await tester.pumpWidget(
1746+
Semantics(
1747+
headingLevel: 1,
1748+
child: Text(
1749+
'Heading level $level',
1750+
textDirection: TextDirection.ltr,
1751+
),
1752+
)
1753+
);
1754+
final TestSemantics expectedSemantics = TestSemantics.root(
1755+
children: <TestSemantics>[
1756+
TestSemantics.rootChild(
1757+
label: 'Heading level $level',
1758+
headingLevel: 1,
1759+
textDirection: TextDirection.ltr,
1760+
),
1761+
],
1762+
);
1763+
expect(
1764+
semantics,
1765+
hasSemantics(
1766+
expectedSemantics,
1767+
ignoreTransform: true,
1768+
ignoreId: true,
1769+
ignoreRect: true,
1770+
),
1771+
);
1772+
}
1773+
1774+
semantics.dispose();
1775+
});
17401776
}
17411777

17421778
Future<void> _pumpTextWidget({

0 commit comments

Comments
 (0)