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

Commit 2fbb0c1

Browse files
authored
[web:a11y] make header a proper <header> (#55747)
Now that we have [proper headings](https://github.com/flutter/engine/blob/main/lib/web_ui/lib/src/engine/semantics/heading.dart), headers should become proper headers. Fixes flutter/flutter#152268
1 parent 0eddf6c commit 2fbb0c1

File tree

6 files changed

+77
-20
lines changed

6 files changed

+77
-20
lines changed

ci/licenses_golden/licenses_flutter

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43700,6 +43700,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart + ../../../flu
4370043700
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart + ../../../flutter/LICENSE
4370143701
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart + ../../../flutter/LICENSE
4370243702
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart + ../../../flutter/LICENSE
43703+
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/header.dart + ../../../flutter/LICENSE
4370343704
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart + ../../../flutter/LICENSE
4370443705
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../../flutter/LICENSE
4370543706
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE
@@ -46578,6 +46579,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart
4657846579
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart
4657946580
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart
4658046581
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart
46582+
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/header.dart
4658146583
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart
4658246584
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart
4658346585
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/semantics.dart

Lines changed: 25 additions & 10 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
@@ -677,23 +681,18 @@ final class GenericRole extends SemanticRole {
677681
return;
678682
}
679683

680-
// Assign one of three roles to the element: group, heading, text.
684+
// Assign one of two roles to the element: group or text.
681685
//
682686
// - "group" is used when the node has children, irrespective of whether the
683687
// node is marked as a header or not. This is because marking a group
684688
// as a "heading" will prevent the AT from reaching its children.
685-
// - "heading" is used when the framework explicitly marks the node as a
686-
// heading and the node does not have children.
687689
// - If a node has a label and no children, assume is a paragraph of text.
688690
// In HTML text has no ARIA role. It's just a DOM node with text inside
689691
// it. Previously, role="text" was used, but it was only supported by
690692
// Safari, and it was removed starting Safari 17.
691693
if (semanticsObject.hasChildren) {
692694
labelAndValue!.preferredRepresentation = LabelRepresentation.ariaLabel;
693695
setAriaRole('group');
694-
} else if (semanticsObject.hasFlag(ui.SemanticsFlag.isHeader)) {
695-
labelAndValue!.preferredRepresentation = LabelRepresentation.domText;
696-
setAriaRole('heading');
697696
} else {
698697
labelAndValue!.preferredRepresentation = LabelRepresentation.sizedSpan;
699698
removeAttribute('role');
@@ -1261,11 +1260,24 @@ class SemanticsObject {
12611260
bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField);
12621261

12631262
/// Whether this object represents a heading element.
1263+
///
1264+
/// Typically, a heading is a prominent piece of text that describes what the
1265+
/// rest of the screen or page is about.
1266+
///
1267+
/// Not to be confused with [isHeader].
12641268
bool get isHeading => headingLevel != 0;
12651269

1266-
/// Whether this object represents an editable text field.
1270+
/// Whether this object represents an interactive link.
12671271
bool get isLink => hasFlag(ui.SemanticsFlag.isLink);
12681272

1273+
/// Whether this object represents a header.
1274+
///
1275+
/// A header is a group of widgets that introduce the content of the screen
1276+
/// or a page.
1277+
///
1278+
/// Not to be confused with [isHeading].
1279+
bool get isHeader => hasFlag(ui.SemanticsFlag.isHeader);
1280+
12691281
/// Whether this object needs screen readers attention right away.
12701282
bool get isLiveRegion =>
12711283
hasFlag(ui.SemanticsFlag.isLiveRegion) &&
@@ -1679,6 +1691,8 @@ class SemanticsObject {
16791691
return SemanticRoleKind.route;
16801692
} else if (isLink) {
16811693
return SemanticRoleKind.link;
1694+
} else if (isHeader) {
1695+
return SemanticRoleKind.header;
16821696
} else {
16831697
return SemanticRoleKind.generic;
16841698
}
@@ -1696,6 +1710,7 @@ class SemanticsObject {
16961710
SemanticRoleKind.platformView => SemanticPlatformView(this),
16971711
SemanticRoleKind.link => SemanticLink(this),
16981712
SemanticRoleKind.heading => SemanticHeading(this),
1713+
SemanticRoleKind.header => SemanticHeader(this),
16991714
SemanticRoleKind.generic => GenericRole(this),
17001715
};
17011716
}

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

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,7 @@ class MockSemanticsEnabler implements SemanticsEnabler {
736736
}
737737

738738
void _testHeader() {
739-
test('renders heading role for headers', () {
739+
test('renders a header with a label and uses a sized span for label', () {
740740
semantics()
741741
..debugOverrideTimestampFunction(() => _testTime)
742742
..semanticsEnabled = true;
@@ -752,19 +752,13 @@ void _testHeader() {
752752

753753
owner().updateSemantics(builder.build());
754754
expectSemanticsTree(owner(), '''
755-
<sem role="heading">Header of the page</sem>
755+
<header><span>Header of the page</span></header>
756756
''');
757757

758758
semantics().semanticsEnabled = false;
759759
});
760760

761-
// When a header has child elements, role="heading" prevents AT from reaching
762-
// child elements. To fix that role="group" is used, even though that causes
763-
// the heading to not be announced as a heading. If the app really needs the
764-
// heading to be announced as a heading, the developer can restructure the UI
765-
// such that the heading is not a parent node, but a side-note, e.g. preceding
766-
// the child list.
767-
test('uses group role for headers when children are present', () {
761+
test('renders a header with children and uses aria-label', () {
768762
semantics()
769763
..debugOverrideTimestampFunction(() => _testTime)
770764
..semanticsEnabled = true;
@@ -788,7 +782,7 @@ void _testHeader() {
788782

789783
owner().updateSemantics(builder.build());
790784
expectSemanticsTree(owner(), '''
791-
<sem role="group" aria-label="Header of the page"><sem-c><sem></sem></sem-c></sem>
785+
<header aria-label="Header of the page"><sem-c><sem></sem></sem-c></header>
792786
''');
793787

794788
semantics().semanticsEnabled = false;

0 commit comments

Comments
 (0)