diff --git a/dev/a11y_assessments/web/flutter_bootstrap.js b/dev/a11y_assessments/web/flutter_bootstrap.js
new file mode 100644
index 0000000000..312a437d24
--- /dev/null
+++ b/dev/a11y_assessments/web/flutter_bootstrap.js
@@ -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,
+ },
+});
diff --git a/dev/a11y_assessments/web/index.html b/dev/a11y_assessments/web/index.html
index e354f8a7d2..b3ce3240ca 100644
--- a/dev/a11y_assessments/web/index.html
+++ b/dev/a11y_assessments/web/index.html
@@ -35,12 +35,6 @@ found in the LICENSE file. -->
a11y_assessments
-
-
-
diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart
index e5291ae321..a7e5ea75a0 100644
--- a/packages/flutter/lib/src/semantics/semantics.dart
+++ b/packages/flutter/lib/src/semantics/semantics.dart
@@ -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;
+}
diff --git a/packages/flutter/test/semantics/semantics_test.dart b/packages/flutter/test/semantics/semantics_test.dart
index 73b36ffba8..3936342b28 100644
--- a/packages/flutter/test/semantics/semantics_test.dart
+++ b/packages/flutter/test/semantics/semantics_test.dart
@@ -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',
);
});
diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart
index 6231d67140..8b0d18375f 100644
--- a/packages/flutter/test/widgets/semantics_test.dart
+++ b/packages/flutter/test/widgets/semantics_test.dart
@@ -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 [-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 pumpHeading(int? level) async {
+ final ValueKey key = ValueKey('heading-$level');
+ await tester.pumpWidget(
+ Semantics(
+ key: key,
+ headingLevel: level,
+ child: Text(
+ 'Heading level $level',
+ textDirection: TextDirection.ltr,
+ ),
+ )
+ );
+ final RenderSemanticsAnnotations object = tester.renderObject(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: [
+ for (int level = 1; level <= 6; level++)
+ Semantics(
+ key: ValueKey('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 key = ValueKey('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 {
diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart
index 6eff271306..ed0048c203 100644
--- a/packages/flutter/test/widgets/semantics_tester.dart
+++ b/packages/flutter/test/widgets/semantics_tester.dart
@@ -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 tags;
+ final int? headingLevel;
+
bool _matches(
SemanticsNode? node,
Map 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;
diff --git a/packages/flutter/test/widgets/text_test.dart b/packages/flutter/test/widgets/text_test.dart
index f75b1d8074..cde4879eea 100644
--- a/packages/flutter/test/widgets/text_test.dart
+++ b/packages/flutter/test/widgets/text_test.dart
@@ -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.rootChild(
+ label: 'Heading level $level',
+ headingLevel: 1,
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ );
+ expect(
+ semantics,
+ hasSemantics(
+ expectedSemantics,
+ ignoreTransform: true,
+ ignoreId: true,
+ ignoreRect: true,
+ ),
+ );
+ }
+
+ semantics.dispose();
+ });
}
Future _pumpTextWidget({