Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 11a32d4

Browse files
authored
Add support for setting the heading level for web semantics (#97894) (#41435)
This change adds a new property in Semantics widget that would take an integer value corresponding to the heading levels defined by the ARIA heading role. This is necessary in order to get proper accessibility and usability in a website for users who rely on screen readers and other assistive technologies. Issue fixed by this PR: flutter/flutter#97894 Framework part: flutter/flutter#125771
1 parent 9e0630f commit 11a32d4

File tree

14 files changed

+139
-5
lines changed

14 files changed

+139
-5
lines changed

ci/licenses_golden/licenses_flutter

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43347,6 +43347,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart
4334743347
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart + ../../../flutter/LICENSE
4334843348
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart + ../../../flutter/LICENSE
4334943349
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart + ../../../flutter/LICENSE
43350+
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart + ../../../flutter/LICENSE
4335043351
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../../flutter/LICENSE
4335143352
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE
4335243353
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
4621846219
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart
4621946220
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart
4622046221
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart
46222+
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart
4622146223
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart
4622246224
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart
4622346225
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart

lib/ui/fixtures/ui_test.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ void sendSemanticsUpdate() {
231231
transform: transform,
232232
childrenInTraversalOrder: childrenInTraversalOrder,
233233
childrenInHitTestOrder: childrenInHitTestOrder,
234-
additionalActions: additionalActions);
234+
additionalActions: additionalActions,
235+
headingLevel: 0,
236+
);
235237
_semanticsUpdate(builder.build());
236238
}
237239

lib/ui/semantics.dart

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,18 @@ abstract class SemanticsUpdateBuilder {
841841
/// z-direction starting at `elevation`. Basically, in the z-direction the
842842
/// node starts at `elevation` above the parent and ends at `elevation` +
843843
/// `thickness` above the parent.
844+
///
845+
/// The `headingLevel` describes that this node is a heading and the hierarchy
846+
/// level this node represents as a heading. A value of 0 indicates that this
847+
/// node is not a heading. A value of 1 or greater indicates that this node is
848+
/// a heading at the specified level. The valid value range is from 1 to 6,
849+
/// inclusive. This attribute is only used for Web platform, and it will have
850+
/// no effect on other platforms.
851+
///
852+
/// See also:
853+
///
854+
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role
855+
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-level
844856
void updateNode({
845857
required int id,
846858
required int flags,
@@ -875,6 +887,7 @@ abstract class SemanticsUpdateBuilder {
875887
required Int32List childrenInTraversalOrder,
876888
required Int32List childrenInHitTestOrder,
877889
required Int32List additionalActions,
890+
int headingLevel = 0,
878891
});
879892

880893
/// Update the custom semantics action associated with the given `id`.
@@ -945,8 +958,13 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem
945958
required Int32List childrenInTraversalOrder,
946959
required Int32List childrenInHitTestOrder,
947960
required Int32List additionalActions,
961+
int headingLevel = 0,
948962
}) {
949963
assert(_matrix4IsValid(transform));
964+
assert (
965+
headingLevel >= 0 && headingLevel <= 6,
966+
'Heading level must be between 1 and 6, or 0 to indicate that this node is not a heading.'
967+
);
950968
_updateNode(
951969
id,
952970
flags,
@@ -984,6 +1002,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem
9841002
childrenInTraversalOrder,
9851003
childrenInHitTestOrder,
9861004
additionalActions,
1005+
headingLevel,
9871006
);
9881007
}
9891008
@Native<
@@ -1024,7 +1043,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem
10241043
Handle,
10251044
Handle,
10261045
Handle,
1027-
Handle)>(symbol: 'SemanticsUpdateBuilder::updateNode')
1046+
Handle,
1047+
Int32)>(symbol: 'SemanticsUpdateBuilder::updateNode')
10281048
external void _updateNode(
10291049
int id,
10301050
int flags,
@@ -1061,7 +1081,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem
10611081
Float64List transform,
10621082
Int32List childrenInTraversalOrder,
10631083
Int32List childrenInHitTestOrder,
1064-
Int32List additionalActions);
1084+
Int32List additionalActions,
1085+
int headingLevel);
10651086

10661087
@override
10671088
void updateCustomAction({required int id, String? label, String? hint, int overrideId = -1}) {

lib/ui/semantics/semantics_node.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ struct SemanticsNode {
143143
std::vector<int32_t> childrenInTraversalOrder;
144144
std::vector<int32_t> childrenInHitTestOrder;
145145
std::vector<int32_t> customAccessibilityActions;
146+
int32_t headingLevel = 0;
146147
};
147148

148149
// Contains semantic nodes that need to be updated.

lib/ui/semantics/semantics_update_builder.cc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ void SemanticsUpdateBuilder::updateNode(
6666
const tonic::Float64List& transform,
6767
const tonic::Int32List& childrenInTraversalOrder,
6868
const tonic::Int32List& childrenInHitTestOrder,
69-
const tonic::Int32List& localContextActions) {
69+
const tonic::Int32List& localContextActions,
70+
int headingLevel) {
7071
FML_CHECK(scrollChildren == 0 ||
7172
(scrollChildren > 0 && childrenInHitTestOrder.data()))
7273
<< "Semantics update contained scrollChildren but did not have "
@@ -118,6 +119,8 @@ void SemanticsUpdateBuilder::updateNode(
118119
localContextActions.data(),
119120
localContextActions.data() + localContextActions.num_elements());
120121
nodes_[id] = node;
122+
123+
node.headingLevel = headingLevel;
121124
}
122125

123126
void SemanticsUpdateBuilder::updateCustomAction(int id,

lib/ui/semantics/semantics_update_builder.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ class SemanticsUpdateBuilder
6565
const tonic::Float64List& transform,
6666
const tonic::Int32List& childrenInTraversalOrder,
6767
const tonic::Int32List& childrenInHitTestOrder,
68-
const tonic::Int32List& customAccessibilityActions);
68+
const tonic::Int32List& customAccessibilityActions,
69+
int headingLevel);
6970

7071
void updateCustomAction(int id,
7172
std::string label,

lib/web_ui/lib/semantics.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ class SemanticsUpdateBuilder {
286286
required Int32List childrenInTraversalOrder,
287287
required Int32List childrenInHitTestOrder,
288288
required Int32List additionalActions,
289+
int headingLevel = 0,
289290
}) {
290291
if (transform.length != 16) {
291292
throw ArgumentError('transform argument must have 16 entries.');
@@ -324,6 +325,7 @@ class SemanticsUpdateBuilder {
324325
childrenInHitTestOrder: childrenInHitTestOrder,
325326
additionalActions: additionalActions,
326327
platformViewId: platformViewId,
328+
headingLevel: headingLevel,
327329
));
328330
}
329331

lib/web_ui/lib/src/engine.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export 'engine/semantics/accessibility.dart';
146146
export 'engine/semantics/checkable.dart';
147147
export 'engine/semantics/dialog.dart';
148148
export 'engine/semantics/focusable.dart';
149+
export 'engine/semantics/heading.dart';
149150
export 'engine/semantics/image.dart';
150151
export 'engine/semantics/incrementable.dart';
151152
export 'engine/semantics/label_and_value.dart';

lib/web_ui/lib/src/engine/semantics.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
export 'semantics/accessibility.dart';
66
export 'semantics/checkable.dart';
77
export 'semantics/focusable.dart';
8+
export 'semantics/heading.dart';
89
export 'semantics/image.dart';
910
export 'semantics/incrementable.dart';
1011
export 'semantics/label_and_value.dart';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2013 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+
import '../dom.dart';
6+
import 'semantics.dart';
7+
8+
/// Renders semantics objects as headings with the corresponding
9+
/// level (h1 ... h6).
10+
class Heading extends PrimaryRoleManager {
11+
Heading(SemanticsObject semanticsObject)
12+
: super.blank(PrimaryRole.heading, semanticsObject) {
13+
addHeadingRole();
14+
}
15+
16+
@override
17+
void update() {
18+
super.update();
19+
20+
if (!semanticsObject.isHeadingLevelDirty) {
21+
return;
22+
}
23+
24+
addHeadingLevel(semanticsObject.headingLevel);
25+
}
26+
27+
@override
28+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
29+
30+
void addHeadingRole() {
31+
setAriaRole('heading');
32+
}
33+
34+
void addHeadingLevel(int headingLevel) {
35+
semanticsObject.element.setAttribute('aria-level', headingLevel);
36+
}
37+
}

lib/web_ui/lib/src/engine/semantics/semantics.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'accessibility.dart';
2222
import 'checkable.dart';
2323
import 'dialog.dart';
2424
import 'focusable.dart';
25+
import 'heading.dart';
2526
import 'image.dart';
2627
import 'incrementable.dart';
2728
import 'label_and_value.dart';
@@ -232,6 +233,7 @@ class SemanticsNodeUpdate {
232233
required this.childrenInTraversalOrder,
233234
required this.childrenInHitTestOrder,
234235
required this.additionalActions,
236+
required this.headingLevel,
235237
});
236238

237239
/// See [ui.SemanticsUpdateBuilder.updateNode].
@@ -332,6 +334,9 @@ class SemanticsNodeUpdate {
332334

333335
/// See [ui.SemanticsUpdateBuilder.updateNode].
334336
final double thickness;
337+
338+
/// See [ui.SemanticsUpdateBuilder.updateNode].
339+
final int headingLevel;
335340
}
336341

337342
/// Identifies [PrimaryRoleManager] implementations.
@@ -354,6 +359,10 @@ enum PrimaryRole {
354359
/// A control that has a checked state, such as a check box or a radio button.
355360
checkable,
356361

362+
/// Adds the "heading" ARIA role to the node. The attribute "aria-level" is
363+
/// also assigned.
364+
heading,
365+
357366
/// Visual only element.
358367
image,
359368

@@ -1097,6 +1106,19 @@ class SemanticsObject {
10971106
_dirtyFields |= _platformViewIdIndex;
10981107
}
10991108

1109+
/// See [ui.SemanticsUpdateBuilder.updateNode].
1110+
int get headingLevel => _headingLevel;
1111+
int _headingLevel = 0;
1112+
1113+
static const int _headingLevelIndex = 1 << 24;
1114+
1115+
/// Whether the [headingLevel] field has been updated but has not been
1116+
/// applied to the DOM yet.
1117+
bool get isHeadingLevelDirty => _isDirty(_headingLevelIndex);
1118+
void _markHeadingLevelDirty() {
1119+
_dirtyFields |= _headingLevelIndex;
1120+
}
1121+
11001122
/// A unique permanent identifier of the semantics node in the tree.
11011123
final int id;
11021124

@@ -1201,6 +1223,9 @@ class SemanticsObject {
12011223
/// Whether this object represents an editable text field.
12021224
bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField);
12031225

1226+
/// Whether this object represents a heading element.
1227+
bool get isHeading => headingLevel != 0;
1228+
12041229
/// Whether this object represents an editable text field.
12051230
bool get isLink => hasFlag(ui.SemanticsFlag.isLink);
12061231

@@ -1358,6 +1383,11 @@ class SemanticsObject {
13581383
_markTooltipDirty();
13591384
}
13601385

1386+
if (_headingLevel != update.headingLevel) {
1387+
_headingLevel = update.headingLevel;
1388+
_markHeadingLevelDirty();
1389+
}
1390+
13611391
if (_textDirection != update.textDirection) {
13621392
_textDirection = update.textDirection;
13631393
_markTextDirectionDirty();
@@ -1591,6 +1621,8 @@ class SemanticsObject {
15911621
// The most specific role should take precedence.
15921622
if (isPlatformView) {
15931623
return PrimaryRole.platformView;
1624+
} else if (isHeading) {
1625+
return PrimaryRole.heading;
15941626
} else if (isTextField) {
15951627
return PrimaryRole.textField;
15961628
} else if (isIncrementable) {
@@ -1623,6 +1655,7 @@ class SemanticsObject {
16231655
PrimaryRole.image => ImageRoleManager(this),
16241656
PrimaryRole.platformView => PlatformViewRoleManager(this),
16251657
PrimaryRole.link => Link(this),
1658+
PrimaryRole.heading => Heading(this),
16261659
PrimaryRole.generic => GenericRole(this),
16271660
};
16281661
}

lib/web_ui/test/engine/semantics/semantics_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,27 @@ void _testHeader() {
721721
owner().updateSemantics(builder.build());
722722
expectSemanticsTree(owner(), '''
723723
<sem role="group" aria-label="Header of the page"><sem-c><sem></sem></sem-c></sem>
724+
''');
725+
726+
semantics().semanticsEnabled = false;
727+
});
728+
729+
test('renders aria-level tag for headings with heading level', () {
730+
semantics()
731+
..debugOverrideTimestampFunction(() => _testTime)
732+
..semanticsEnabled = true;
733+
734+
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
735+
updateNode(
736+
builder,
737+
headingLevel: 2,
738+
transform: Matrix4.identity().toFloat64(),
739+
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
740+
);
741+
742+
owner().updateSemantics(builder.build());
743+
expectSemanticsTree(owner(), '''
744+
<sem aria-level="2" role="heading" style="filter: opacity(0%); color: rgba(0, 0, 0, 0)"></sem>
724745
''');
725746

726747
semantics().semanticsEnabled = false;
@@ -3557,6 +3578,7 @@ void updateNode(
35573578
Int32List? childrenInTraversalOrder,
35583579
Int32List? childrenInHitTestOrder,
35593580
Int32List? additionalActions,
3581+
int headingLevel = 0,
35603582
}) {
35613583
transform ??= Float64List.fromList(Matrix4.identity().storage);
35623584
childrenInTraversalOrder ??= Int32List(0);
@@ -3596,6 +3618,7 @@ void updateNode(
35963618
childrenInTraversalOrder: childrenInTraversalOrder,
35973619
childrenInHitTestOrder: childrenInHitTestOrder,
35983620
additionalActions: additionalActions,
3621+
headingLevel: headingLevel,
35993622
);
36003623
}
36013624

lib/web_ui/test/engine/semantics/semantics_tester.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class SemanticsTester {
111111
Float64List? transform,
112112
Int32List? additionalActions,
113113
List<SemanticsNodeUpdate>? children,
114+
int? headingLevel,
114115
}) {
115116
// Flags
116117
if (hasCheckedState ?? false) {
@@ -311,6 +312,7 @@ class SemanticsTester {
311312
childrenInTraversalOrder: childIds,
312313
childrenInHitTestOrder: childIds,
313314
additionalActions: additionalActions ?? Int32List(0),
315+
headingLevel: headingLevel ?? 0,
314316
);
315317
_nodeUpdates.add(update);
316318
return update;

shell/platform/embedder/fixtures/main.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ Future<void> a11y_main() async {
178178
tooltip: 'tooltip',
179179
textDirection: TextDirection.ltr,
180180
additionalActions: Int32List(0),
181+
headingLevel: 0
181182
)
182183
..updateNode(
183184
id: 84,
@@ -213,6 +214,7 @@ Future<void> a11y_main() async {
213214
additionalActions: Int32List(0),
214215
childrenInHitTestOrder: Int32List(0),
215216
childrenInTraversalOrder: Int32List(0),
217+
headingLevel: 0
216218
)
217219
..updateNode(
218220
id: 96,
@@ -248,6 +250,7 @@ Future<void> a11y_main() async {
248250
tooltip: 'tooltip',
249251
textDirection: TextDirection.ltr,
250252
additionalActions: Int32List(0),
253+
headingLevel: 0
251254
)
252255
..updateNode(
253256
id: 128,
@@ -283,6 +286,7 @@ Future<void> a11y_main() async {
283286
textDirection: TextDirection.ltr,
284287
childrenInHitTestOrder: Int32List(0),
285288
childrenInTraversalOrder: Int32List(0),
289+
headingLevel: 0
286290
)
287291
..updateCustomAction(
288292
id: 21,
@@ -379,6 +383,7 @@ Future<void> a11y_string_attributes() async {
379383
tooltip: 'tooltip',
380384
textDirection: TextDirection.ltr,
381385
additionalActions: Int32List(0),
386+
headingLevel: 0,
382387
);
383388

384389
PlatformDispatcher.instance.views.first.updateSemantics(builder.build());

0 commit comments

Comments
 (0)