diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 9adebc0edfd92..3dc7f6568fd73 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -43347,6 +43347,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart + ../../../flutter/LICENSE @@ -46218,6 +46219,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart diff --git a/lib/ui/fixtures/ui_test.dart b/lib/ui/fixtures/ui_test.dart index 0dd6bfb328831..492a234b859c4 100644 --- a/lib/ui/fixtures/ui_test.dart +++ b/lib/ui/fixtures/ui_test.dart @@ -231,7 +231,9 @@ void sendSemanticsUpdate() { transform: transform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, - additionalActions: additionalActions); + additionalActions: additionalActions, + headingLevel: 0, + ); _semanticsUpdate(builder.build()); } diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index da3a8a060f7ee..57bc1fa30726d 100644 --- a/lib/ui/semantics.dart +++ b/lib/ui/semantics.dart @@ -841,6 +841,18 @@ abstract class SemanticsUpdateBuilder { /// z-direction starting at `elevation`. Basically, in the z-direction the /// node starts at `elevation` above the parent and ends at `elevation` + /// `thickness` above the parent. + /// + /// The `headingLevel` describes that this node is a heading and the hierarchy + /// level this node represents as a heading. A value of 0 indicates that this + /// node is not a heading. A value of 1 or greater indicates that this node is + /// a heading at the specified level. The valid value range is from 1 to 6, + /// inclusive. This attribute is only used for Web platform, and it will have + /// no effect on other platforms. + /// + /// See also: + /// + /// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role + /// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-level void updateNode({ required int id, required int flags, @@ -875,6 +887,7 @@ abstract class SemanticsUpdateBuilder { required Int32List childrenInTraversalOrder, required Int32List childrenInHitTestOrder, required Int32List additionalActions, + int headingLevel = 0, }); /// Update the custom semantics action associated with the given `id`. @@ -945,8 +958,13 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem required Int32List childrenInTraversalOrder, required Int32List childrenInHitTestOrder, required Int32List additionalActions, + int headingLevel = 0, }) { assert(_matrix4IsValid(transform)); + assert ( + headingLevel >= 0 && headingLevel <= 6, + 'Heading level must be between 1 and 6, or 0 to indicate that this node is not a heading.' + ); _updateNode( id, flags, @@ -984,6 +1002,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem childrenInTraversalOrder, childrenInHitTestOrder, additionalActions, + headingLevel, ); } @Native< @@ -1024,7 +1043,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem Handle, Handle, Handle, - Handle)>(symbol: 'SemanticsUpdateBuilder::updateNode') + Handle, + Int32)>(symbol: 'SemanticsUpdateBuilder::updateNode') external void _updateNode( int id, int flags, @@ -1061,7 +1081,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem Float64List transform, Int32List childrenInTraversalOrder, Int32List childrenInHitTestOrder, - Int32List additionalActions); + Int32List additionalActions, + int headingLevel); @override void updateCustomAction({required int id, String? label, String? hint, int overrideId = -1}) { diff --git a/lib/ui/semantics/semantics_node.h b/lib/ui/semantics/semantics_node.h index e3c620fc1aa27..c27ac35f00a5e 100644 --- a/lib/ui/semantics/semantics_node.h +++ b/lib/ui/semantics/semantics_node.h @@ -143,6 +143,7 @@ struct SemanticsNode { std::vector childrenInTraversalOrder; std::vector childrenInHitTestOrder; std::vector customAccessibilityActions; + int32_t headingLevel = 0; }; // Contains semantic nodes that need to be updated. diff --git a/lib/ui/semantics/semantics_update_builder.cc b/lib/ui/semantics/semantics_update_builder.cc index f7c0d8294fb4f..96eb0ce2e1c5e 100644 --- a/lib/ui/semantics/semantics_update_builder.cc +++ b/lib/ui/semantics/semantics_update_builder.cc @@ -66,7 +66,8 @@ void SemanticsUpdateBuilder::updateNode( const tonic::Float64List& transform, const tonic::Int32List& childrenInTraversalOrder, const tonic::Int32List& childrenInHitTestOrder, - const tonic::Int32List& localContextActions) { + const tonic::Int32List& localContextActions, + int headingLevel) { FML_CHECK(scrollChildren == 0 || (scrollChildren > 0 && childrenInHitTestOrder.data())) << "Semantics update contained scrollChildren but did not have " @@ -118,6 +119,8 @@ void SemanticsUpdateBuilder::updateNode( localContextActions.data(), localContextActions.data() + localContextActions.num_elements()); nodes_[id] = node; + + node.headingLevel = headingLevel; } void SemanticsUpdateBuilder::updateCustomAction(int id, diff --git a/lib/ui/semantics/semantics_update_builder.h b/lib/ui/semantics/semantics_update_builder.h index abe4c2806b210..cab294b9047a5 100644 --- a/lib/ui/semantics/semantics_update_builder.h +++ b/lib/ui/semantics/semantics_update_builder.h @@ -65,7 +65,8 @@ class SemanticsUpdateBuilder const tonic::Float64List& transform, const tonic::Int32List& childrenInTraversalOrder, const tonic::Int32List& childrenInHitTestOrder, - const tonic::Int32List& customAccessibilityActions); + const tonic::Int32List& customAccessibilityActions, + int headingLevel); void updateCustomAction(int id, std::string label, diff --git a/lib/web_ui/lib/semantics.dart b/lib/web_ui/lib/semantics.dart index 3656abd92375e..e4698e40f2f2c 100644 --- a/lib/web_ui/lib/semantics.dart +++ b/lib/web_ui/lib/semantics.dart @@ -286,6 +286,7 @@ class SemanticsUpdateBuilder { required Int32List childrenInTraversalOrder, required Int32List childrenInHitTestOrder, required Int32List additionalActions, + int headingLevel = 0, }) { if (transform.length != 16) { throw ArgumentError('transform argument must have 16 entries.'); @@ -324,6 +325,7 @@ class SemanticsUpdateBuilder { childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, platformViewId: platformViewId, + headingLevel: headingLevel, )); } diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index 1890fa1a1648f..8598f850eff71 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -146,6 +146,7 @@ export 'engine/semantics/accessibility.dart'; export 'engine/semantics/checkable.dart'; export 'engine/semantics/dialog.dart'; export 'engine/semantics/focusable.dart'; +export 'engine/semantics/heading.dart'; export 'engine/semantics/image.dart'; export 'engine/semantics/incrementable.dart'; export 'engine/semantics/label_and_value.dart'; diff --git a/lib/web_ui/lib/src/engine/semantics.dart b/lib/web_ui/lib/src/engine/semantics.dart index 0ba3230ee96a1..6bfca5a58571a 100644 --- a/lib/web_ui/lib/src/engine/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics.dart @@ -5,6 +5,7 @@ export 'semantics/accessibility.dart'; export 'semantics/checkable.dart'; export 'semantics/focusable.dart'; +export 'semantics/heading.dart'; export 'semantics/image.dart'; export 'semantics/incrementable.dart'; export 'semantics/label_and_value.dart'; diff --git a/lib/web_ui/lib/src/engine/semantics/heading.dart b/lib/web_ui/lib/src/engine/semantics/heading.dart new file mode 100644 index 0000000000000..eb97725bd5b7a --- /dev/null +++ b/lib/web_ui/lib/src/engine/semantics/heading.dart @@ -0,0 +1,37 @@ +// Copyright 2013 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. + +import '../dom.dart'; +import 'semantics.dart'; + +/// Renders semantics objects as headings with the corresponding +/// level (h1 ... h6). +class Heading extends PrimaryRoleManager { + Heading(SemanticsObject semanticsObject) + : super.blank(PrimaryRole.heading, semanticsObject) { + addHeadingRole(); + } + + @override + void update() { + super.update(); + + if (!semanticsObject.isHeadingLevelDirty) { + return; + } + + addHeadingLevel(semanticsObject.headingLevel); + } + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; + + void addHeadingRole() { + setAriaRole('heading'); + } + + void addHeadingLevel(int headingLevel) { + semanticsObject.element.setAttribute('aria-level', headingLevel); + } +} diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 505531afcc080..0fbd8596e9f00 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -22,6 +22,7 @@ import 'accessibility.dart'; import 'checkable.dart'; import 'dialog.dart'; import 'focusable.dart'; +import 'heading.dart'; import 'image.dart'; import 'incrementable.dart'; import 'label_and_value.dart'; @@ -232,6 +233,7 @@ class SemanticsNodeUpdate { required this.childrenInTraversalOrder, required this.childrenInHitTestOrder, required this.additionalActions, + required this.headingLevel, }); /// See [ui.SemanticsUpdateBuilder.updateNode]. @@ -332,6 +334,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final double thickness; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final int headingLevel; } /// Identifies [PrimaryRoleManager] implementations. @@ -354,6 +359,10 @@ enum PrimaryRole { /// A control that has a checked state, such as a check box or a radio button. checkable, + /// Adds the "heading" ARIA role to the node. The attribute "aria-level" is + /// also assigned. + heading, + /// Visual only element. image, @@ -1097,6 +1106,19 @@ class SemanticsObject { _dirtyFields |= _platformViewIdIndex; } + /// See [ui.SemanticsUpdateBuilder.updateNode]. + int get headingLevel => _headingLevel; + int _headingLevel = 0; + + static const int _headingLevelIndex = 1 << 24; + + /// Whether the [headingLevel] field has been updated but has not been + /// applied to the DOM yet. + bool get isHeadingLevelDirty => _isDirty(_headingLevelIndex); + void _markHeadingLevelDirty() { + _dirtyFields |= _headingLevelIndex; + } + /// A unique permanent identifier of the semantics node in the tree. final int id; @@ -1201,6 +1223,9 @@ class SemanticsObject { /// Whether this object represents an editable text field. bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField); + /// Whether this object represents a heading element. + bool get isHeading => headingLevel != 0; + /// Whether this object represents an editable text field. bool get isLink => hasFlag(ui.SemanticsFlag.isLink); @@ -1358,6 +1383,11 @@ class SemanticsObject { _markTooltipDirty(); } + if (_headingLevel != update.headingLevel) { + _headingLevel = update.headingLevel; + _markHeadingLevelDirty(); + } + if (_textDirection != update.textDirection) { _textDirection = update.textDirection; _markTextDirectionDirty(); @@ -1591,6 +1621,8 @@ class SemanticsObject { // The most specific role should take precedence. if (isPlatformView) { return PrimaryRole.platformView; + } else if (isHeading) { + return PrimaryRole.heading; } else if (isTextField) { return PrimaryRole.textField; } else if (isIncrementable) { @@ -1623,6 +1655,7 @@ class SemanticsObject { PrimaryRole.image => ImageRoleManager(this), PrimaryRole.platformView => PlatformViewRoleManager(this), PrimaryRole.link => Link(this), + PrimaryRole.heading => Heading(this), PrimaryRole.generic => GenericRole(this), }; } diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 2b0ce161193b7..117ebc55cb60a 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -721,6 +721,27 @@ void _testHeader() { owner().updateSemantics(builder.build()); expectSemanticsTree(owner(), ''' +'''); + + semantics().semanticsEnabled = false; + }); + + test('renders aria-level tag for headings with heading level', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder(); + updateNode( + builder, + headingLevel: 2, + transform: Matrix4.identity().toFloat64(), + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + + owner().updateSemantics(builder.build()); + expectSemanticsTree(owner(), ''' + '''); semantics().semanticsEnabled = false; @@ -3557,6 +3578,7 @@ void updateNode( Int32List? childrenInTraversalOrder, Int32List? childrenInHitTestOrder, Int32List? additionalActions, + int headingLevel = 0, }) { transform ??= Float64List.fromList(Matrix4.identity().storage); childrenInTraversalOrder ??= Int32List(0); @@ -3596,6 +3618,7 @@ void updateNode( childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, + headingLevel: headingLevel, ); } diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index aedda526b9e05..f9a626c486cf7 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -111,6 +111,7 @@ class SemanticsTester { Float64List? transform, Int32List? additionalActions, List? children, + int? headingLevel, }) { // Flags if (hasCheckedState ?? false) { @@ -311,6 +312,7 @@ class SemanticsTester { childrenInTraversalOrder: childIds, childrenInHitTestOrder: childIds, additionalActions: additionalActions ?? Int32List(0), + headingLevel: headingLevel ?? 0, ); _nodeUpdates.add(update); return update; diff --git a/shell/platform/embedder/fixtures/main.dart b/shell/platform/embedder/fixtures/main.dart index f1c46a5040153..0336036f00ee2 100644 --- a/shell/platform/embedder/fixtures/main.dart +++ b/shell/platform/embedder/fixtures/main.dart @@ -178,6 +178,7 @@ Future a11y_main() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), + headingLevel: 0 ) ..updateNode( id: 84, @@ -213,6 +214,7 @@ Future a11y_main() async { additionalActions: Int32List(0), childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), + headingLevel: 0 ) ..updateNode( id: 96, @@ -248,6 +250,7 @@ Future a11y_main() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), + headingLevel: 0 ) ..updateNode( id: 128, @@ -283,6 +286,7 @@ Future a11y_main() async { textDirection: TextDirection.ltr, childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), + headingLevel: 0 ) ..updateCustomAction( id: 21, @@ -379,6 +383,7 @@ Future a11y_string_attributes() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), + headingLevel: 0, ); PlatformDispatcher.instance.views.first.updateSemantics(builder.build());