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

Commit 076688d

Browse files
authored
[web:a11y] make header a <header> when non-empty and heading when empty (#55996)
This relands the [reverted](#55993) [original PR](#55747) with one important adjustment: if the header is empty and has a label, it is rendered as a heading (`<h1>`, `<h2>`, etc) instead of a `<header>`. This is to be consistent with mobile, where headers are frequently used as headings, and screen readers do indeed read it as "heading". Changing all headers to the `<header>` tag turned to out to be too disruptive to existing usages of `SemanticsProperties.header`. Long-term, when flutter/flutter#155928 is implemented, we could migrate the framework to use `SemanticsProperties.headingLevel` to communicate that something is a heading, and encourage our users to move from `header` to `headingLevel` as well. After that migration is done, we could make all headers proper `<header>` tags, and not special-case empty headers. Fixes flutter/flutter#152268
1 parent 2be456e commit 076688d

File tree

7 files changed

+133
-28
lines changed

7 files changed

+133
-28
lines changed

ci/licenses_golden/licenses_flutter

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43885,6 +43885,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart + ../../../flu
4388543885
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart + ../../../flutter/LICENSE
4388643886
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart + ../../../flutter/LICENSE
4388743887
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart + ../../../flutter/LICENSE
43888+
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/header.dart + ../../../flutter/LICENSE
4388843889
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart + ../../../flutter/LICENSE
4388943890
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../../flutter/LICENSE
4389043891
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE
@@ -46752,6 +46753,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart
4675246753
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart
4675346754
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart
4675446755
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart
46756+
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/header.dart
4675546757
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart
4675646758
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart
4675746759
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart

lib/web_ui/lib/src/engine.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export 'engine/scene_view.dart';
147147
export 'engine/semantics/accessibility.dart';
148148
export 'engine/semantics/checkable.dart';
149149
export 'engine/semantics/focusable.dart';
150+
export 'engine/semantics/header.dart';
150151
export 'engine/semantics/heading.dart';
151152
export 'engine/semantics/image.dart';
152153
export 'engine/semantics/incrementable.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/header.dart';
89
export 'semantics/heading.dart';
910
export 'semantics/image.dart';
1011
export 'semantics/incrementable.dart';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 'label_and_value.dart';
7+
import 'semantics.dart';
8+
9+
/// Renders a semantic header.
10+
///
11+
/// A header is a group of nodes that together introduce the content of the
12+
/// current screen or page.
13+
///
14+
/// Uses the `<header>` element, which implies ARIA role "banner".
15+
///
16+
/// See also:
17+
/// * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/header
18+
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/banner_role
19+
class SemanticHeader extends SemanticRole {
20+
SemanticHeader(SemanticsObject semanticsObject) : super.withBasics(
21+
SemanticRoleKind.header,
22+
semanticsObject,
23+
24+
// Why use sizedSpan?
25+
//
26+
// On an empty <header> aria-label alone will read the label but also add
27+
// "empty banner". Additionally, if the label contains information that's
28+
// meant to be crawlable, it will be lost by moving into aria-label, because
29+
// most crawlers ignore ARIA labels.
30+
//
31+
// Using DOM text, such as <header>DOM text</header> causes the browser to
32+
// generate two a11y nodes, one for the <header> element, and one for the
33+
// "DOM text" text node. The text node is sized according to the text size,
34+
// and does not match the size of the <header> element, which is the same
35+
// issue as https://github.com/flutter/flutter/issues/146774.
36+
preferredLabelRepresentation: LabelRepresentation.sizedSpan,
37+
);
38+
39+
@override
40+
DomElement createElement() => createDomElement('header');
41+
42+
@override
43+
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
44+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class SemanticHeading extends SemanticRole {
2020

2121
@override
2222
DomElement createElement() {
23-
final element = createDomElement('h${semanticsObject.headingLevel}');
23+
final element = createDomElement('h${semanticsObject.effectiveHeadingLevel}');
2424
element.style
2525
// Browser adds default non-zero margins/paddings to <h*> tags, which
2626
// affects the size of the element. As the element size is fully defined

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

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import '../window.dart';
2121
import 'accessibility.dart';
2222
import 'checkable.dart';
2323
import 'focusable.dart';
24+
import 'header.dart';
2425
import 'heading.dart';
2526
import 'image.dart';
2627
import 'incrementable.dart';
@@ -396,14 +397,17 @@ enum SemanticRoleKind {
396397
/// The node's role is to host a platform view.
397398
platformView,
398399

400+
/// Contains a link.
401+
link,
402+
403+
/// Denotes a header.
404+
header,
405+
399406
/// A role used when a more specific role cannot be assigend to
400407
/// a [SemanticsObject].
401408
///
402409
/// Provides a label or a value.
403410
generic,
404-
405-
/// Contains a link.
406-
link,
407411
}
408412

409413
/// Responsible for setting the `role` ARIA attribute, for attaching
@@ -688,23 +692,18 @@ final class GenericRole extends SemanticRole {
688692
return;
689693
}
690694

691-
// Assign one of three roles to the element: group, heading, text.
695+
// Assign one of two roles to the element: group or text.
692696
//
693697
// - "group" is used when the node has children, irrespective of whether the
694698
// node is marked as a header or not. This is because marking a group
695699
// as a "heading" will prevent the AT from reaching its children.
696-
// - "heading" is used when the framework explicitly marks the node as a
697-
// heading and the node does not have children.
698700
// - If a node has a label and no children, assume is a paragraph of text.
699701
// In HTML text has no ARIA role. It's just a DOM node with text inside
700702
// it. Previously, role="text" was used, but it was only supported by
701703
// Safari, and it was removed starting Safari 17.
702704
if (semanticsObject.hasChildren) {
703705
labelAndValue!.preferredRepresentation = LabelRepresentation.ariaLabel;
704706
setAriaRole('group');
705-
} else if (semanticsObject.hasFlag(ui.SemanticsFlag.isHeader)) {
706-
labelAndValue!.preferredRepresentation = LabelRepresentation.domText;
707-
setAriaRole('heading');
708707
} else {
709708
labelAndValue!.preferredRepresentation = LabelRepresentation.sizedSpan;
710709
removeAttribute('role');
@@ -1123,10 +1122,24 @@ class SemanticsObject {
11231122
_dirtyFields |= _platformViewIdIndex;
11241123
}
11251124

1126-
/// See [ui.SemanticsUpdateBuilder.updateNode].
1127-
int get headingLevel => _headingLevel;
1125+
// This field is not exposed publicly because code that applies heading levels
1126+
// should use [effectiveHeadingLevel] instead.
11281127
int _headingLevel = 0;
11291128

1129+
/// The effective heading level value to be used when rendering this node as
1130+
/// a heading.
1131+
///
1132+
/// If a heading is rendered from a header, uses heading level 2.
1133+
int get effectiveHeadingLevel {
1134+
if (_headingLevel != 0) {
1135+
return _headingLevel;
1136+
} else {
1137+
// This branch may be taken when a heading is rendered from a header,
1138+
// where the heading level is not provided.
1139+
return 2;
1140+
}
1141+
}
1142+
11301143
static const int _headingLevelIndex = 1 << 24;
11311144

11321145
/// Whether the [headingLevel] field has been updated but has not been
@@ -1136,6 +1149,36 @@ class SemanticsObject {
11361149
_dirtyFields |= _headingLevelIndex;
11371150
}
11381151

1152+
/// Whether this object represents a heading.
1153+
///
1154+
/// Typically, a heading is a prominent piece of text that provides a title
1155+
/// for a section in the UI.
1156+
///
1157+
/// Labeled empty headers are treated as headings too.
1158+
///
1159+
/// See also:
1160+
///
1161+
/// * [isHeader], which also describes the rest of the screen, and is
1162+
/// sometimes presented to the user as a heading.
1163+
bool get isHeading => _headingLevel != 0 || isHeader && hasLabel && !hasChildren;
1164+
1165+
/// Whether this object represents a header.
1166+
///
1167+
/// A header is used for one of two purposes:
1168+
///
1169+
/// * Introduce the content of the main screen or a page. In this case, the
1170+
/// header is a, possibly labeled, container of widgets that together
1171+
/// provide the description of the screen.
1172+
/// * Provide a heading (like [isHeading]). Native mobile apps do not have a
1173+
/// notion of "heading". It is common to mark headings as headers instead
1174+
/// and the screen readers will announce "heading". Labeled empty headers
1175+
/// are treated as heading by the web engine.
1176+
///
1177+
/// See also:
1178+
///
1179+
/// * [isHeading], which determines whether this node represents a heading.
1180+
bool get isHeader => hasFlag(ui.SemanticsFlag.isHeader);
1181+
11391182
/// See [ui.SemanticsUpdateBuilder.updateNode].
11401183
String? get identifier => _identifier;
11411184
String? _identifier;
@@ -1271,10 +1314,7 @@ class SemanticsObject {
12711314
/// Whether this object represents an editable text field.
12721315
bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField);
12731316

1274-
/// Whether this object represents a heading element.
1275-
bool get isHeading => headingLevel != 0;
1276-
1277-
/// Whether this object represents an editable text field.
1317+
/// Whether this object represents an interactive link.
12781318
bool get isLink => hasFlag(ui.SemanticsFlag.isLink);
12791319

12801320
/// Whether this object needs screen readers attention right away.
@@ -1673,6 +1713,8 @@ class SemanticsObject {
16731713
if (isPlatformView) {
16741714
return SemanticRoleKind.platformView;
16751715
} else if (isHeading) {
1716+
// IMPORTANT: because headings also cover certain kinds of headers, the
1717+
// `heading` role has precedence over the `header` role.
16761718
return SemanticRoleKind.heading;
16771719
} else if (isTextField) {
16781720
return SemanticRoleKind.textField;
@@ -1690,6 +1732,8 @@ class SemanticsObject {
16901732
return SemanticRoleKind.route;
16911733
} else if (isLink) {
16921734
return SemanticRoleKind.link;
1735+
} else if (isHeader) {
1736+
return SemanticRoleKind.header;
16931737
} else {
16941738
return SemanticRoleKind.generic;
16951739
}
@@ -1707,6 +1751,7 @@ class SemanticsObject {
17071751
SemanticRoleKind.platformView => SemanticPlatformView(this),
17081752
SemanticRoleKind.link => SemanticLink(this),
17091753
SemanticRoleKind.heading => SemanticHeading(this),
1754+
SemanticRoleKind.header => SemanticHeader(this),
17101755
SemanticRoleKind.generic => GenericRole(this),
17111756
};
17121757
}

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

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -742,7 +742,7 @@ class MockSemanticsEnabler implements SemanticsEnabler {
742742
}
743743

744744
void _testHeader() {
745-
test('renders heading role for headers', () {
745+
test('renders an empty labeled header as a heading with a label and uses a sized span for label', () {
746746
semantics()
747747
..debugOverrideTimestampFunction(() => _testTime)
748748
..semanticsEnabled = true;
@@ -757,20 +757,32 @@ void _testHeader() {
757757
);
758758

759759
owner().updateSemantics(builder.build());
760-
expectSemanticsTree(owner(), '''
761-
<sem role="heading">Header of the page</sem>
762-
''');
760+
expectSemanticsTree(owner(), '<h2>Header of the page</span></h2>');
761+
762+
semantics().semanticsEnabled = false;
763+
});
764+
765+
// This is a useless case, but we should at least not crash if it happens.
766+
test('renders an empty unlabeled header', () {
767+
semantics()
768+
..debugOverrideTimestampFunction(() => _testTime)
769+
..semanticsEnabled = true;
770+
771+
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
772+
updateNode(
773+
builder,
774+
flags: 0 | ui.SemanticsFlag.isHeader.index,
775+
transform: Matrix4.identity().toFloat64(),
776+
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
777+
);
778+
779+
owner().updateSemantics(builder.build());
780+
expectSemanticsTree(owner(), '<header></header>');
763781

764782
semantics().semanticsEnabled = false;
765783
});
766784

767-
// When a header has child elements, role="heading" prevents AT from reaching
768-
// child elements. To fix that role="group" is used, even though that causes
769-
// the heading to not be announced as a heading. If the app really needs the
770-
// heading to be announced as a heading, the developer can restructure the UI
771-
// such that the heading is not a parent node, but a side-note, e.g. preceding
772-
// the child list.
773-
test('uses group role for headers when children are present', () {
785+
test('renders a header with children and uses aria-label', () {
774786
semantics()
775787
..debugOverrideTimestampFunction(() => _testTime)
776788
..semanticsEnabled = true;
@@ -794,7 +806,7 @@ void _testHeader() {
794806

795807
owner().updateSemantics(builder.build());
796808
expectSemanticsTree(owner(), '''
797-
<sem role="group" aria-label="Header of the page"><sem-c><sem></sem></sem-c></sem>
809+
<header aria-label="Header of the page"><sem-c><sem></sem></sem-c></header>
798810
''');
799811

800812
semantics().semanticsEnabled = false;

0 commit comments

Comments
 (0)