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({