Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[web:a11y] make header a <header> when non-empty and heading when empty #55996

Merged
merged 3 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ci/licenses_golden/licenses_flutter
Original file line number Diff line number Diff line change
Expand Up @@ -43885,6 +43885,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart + ../../../flu
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.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/header.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
Expand Down Expand Up @@ -46752,6 +46753,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart
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/focusable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/header.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
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export 'engine/scene_view.dart';
export 'engine/semantics/accessibility.dart';
export 'engine/semantics/checkable.dart';
export 'engine/semantics/focusable.dart';
export 'engine/semantics/header.dart';
export 'engine/semantics/heading.dart';
export 'engine/semantics/image.dart';
export 'engine/semantics/incrementable.dart';
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine/semantics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
export 'semantics/accessibility.dart';
export 'semantics/checkable.dart';
export 'semantics/focusable.dart';
export 'semantics/header.dart';
export 'semantics/heading.dart';
export 'semantics/image.dart';
export 'semantics/incrementable.dart';
Expand Down
44 changes: 44 additions & 0 deletions lib/web_ui/lib/src/engine/semantics/header.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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 'label_and_value.dart';
import 'semantics.dart';

/// Renders a semantic header.
///
/// A header is a group of nodes that together introduce the content of the
/// current screen or page.
///
/// Uses the `<header>` element, which implies ARIA role "banner".
///
/// See also:
/// * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/header
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/banner_role
class SemanticHeader extends SemanticRole {
SemanticHeader(SemanticsObject semanticsObject) : super.withBasics(
SemanticRoleKind.header,
semanticsObject,

// Why use sizedSpan?
//
// On an empty <header> aria-label alone will read the label but also add
// "empty banner". Additionally, if the label contains information that's
// meant to be crawlable, it will be lost by moving into aria-label, because
// most crawlers ignore ARIA labels.
//
// Using DOM text, such as <header>DOM text</header> causes the browser to
// generate two a11y nodes, one for the <header> element, and one for the
// "DOM text" text node. The text node is sized according to the text size,
// and does not match the size of the <header> element, which is the same
// issue as https://github.com/flutter/flutter/issues/146774.
preferredLabelRepresentation: LabelRepresentation.sizedSpan,
);

@override
DomElement createElement() => createDomElement('header');

@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
2 changes: 1 addition & 1 deletion lib/web_ui/lib/src/engine/semantics/heading.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class SemanticHeading extends SemanticRole {

@override
DomElement createElement() {
final element = createDomElement('h${semanticsObject.headingLevel}');
final element = createDomElement('h${semanticsObject.effectiveHeadingLevel}');
element.style
// Browser adds default non-zero margins/paddings to <h*> tags, which
// affects the size of the element. As the element size is fully defined
Expand Down
75 changes: 60 additions & 15 deletions lib/web_ui/lib/src/engine/semantics/semantics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import '../window.dart';
import 'accessibility.dart';
import 'checkable.dart';
import 'focusable.dart';
import 'header.dart';
import 'heading.dart';
import 'image.dart';
import 'incrementable.dart';
Expand Down Expand Up @@ -396,14 +397,17 @@ enum SemanticRoleKind {
/// The node's role is to host a platform view.
platformView,

/// Contains a link.
link,

/// Denotes a header.
header,

/// A role used when a more specific role cannot be assigend to
/// a [SemanticsObject].
///
/// Provides a label or a value.
generic,

/// Contains a link.
link,
}

/// Responsible for setting the `role` ARIA attribute, for attaching
Expand Down Expand Up @@ -688,23 +692,18 @@ final class GenericRole extends SemanticRole {
return;
}

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

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

/// The effective heading level value to be used when rendering this node as
/// a heading.
///
/// If a heading is rendered from a header, uses heading level 2.
int get effectiveHeadingLevel {
if (_headingLevel != 0) {
return _headingLevel;
} else {
// This branch may be taken when a heading is rendered from a header,
// where the heading level is not provided.
return 2;
}
}

static const int _headingLevelIndex = 1 << 24;

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

/// Whether this object represents a heading.
///
/// Typically, a heading is a prominent piece of text that provides a title
/// for a section in the UI.
///
/// Labeled empty headers are treated as headings too.
///
/// See also:
///
/// * [isHeader], which also describes the rest of the screen, and is
/// sometimes presented to the user as a heading.
bool get isHeading => _headingLevel != 0 || isHeader && hasLabel && !hasChildren;

/// Whether this object represents a header.
///
/// A header is used for one of two purposes:
///
/// * Introduce the content of the main screen or a page. In this case, the
/// header is a, possibly labeled, container of widgets that together
/// provide the description of the screen.
/// * Provide a heading (like [isHeading]). Native mobile apps do not have a
/// notion of "heading". It is common to mark headings as headers instead
/// and the screen readers will announce "heading". Labeled empty headers
/// are treated as heading by the web engine.
///
/// See also:
///
/// * [isHeading], which determines whether this node represents a heading.
bool get isHeader => hasFlag(ui.SemanticsFlag.isHeader);

/// See [ui.SemanticsUpdateBuilder.updateNode].
String? get identifier => _identifier;
String? _identifier;
Expand Down Expand Up @@ -1271,10 +1314,7 @@ 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.
/// Whether this object represents an interactive link.
bool get isLink => hasFlag(ui.SemanticsFlag.isLink);

/// Whether this object needs screen readers attention right away.
Expand Down Expand Up @@ -1673,6 +1713,8 @@ class SemanticsObject {
if (isPlatformView) {
return SemanticRoleKind.platformView;
} else if (isHeading) {
// IMPORTANT: because headings also cover certain kinds of headers, the
// `heading` role has precedence over the `header` role.
return SemanticRoleKind.heading;
} else if (isTextField) {
return SemanticRoleKind.textField;
Expand All @@ -1690,6 +1732,8 @@ class SemanticsObject {
return SemanticRoleKind.route;
} else if (isLink) {
return SemanticRoleKind.link;
} else if (isHeader) {
return SemanticRoleKind.header;
} else {
return SemanticRoleKind.generic;
}
Expand All @@ -1707,6 +1751,7 @@ class SemanticsObject {
SemanticRoleKind.platformView => SemanticPlatformView(this),
SemanticRoleKind.link => SemanticLink(this),
SemanticRoleKind.heading => SemanticHeading(this),
SemanticRoleKind.header => SemanticHeader(this),
SemanticRoleKind.generic => GenericRole(this),
};
}
Expand Down
36 changes: 24 additions & 12 deletions lib/web_ui/test/engine/semantics/semantics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ class MockSemanticsEnabler implements SemanticsEnabler {
}

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

owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '''
<sem role="heading">Header of the page</sem>
''');
expectSemanticsTree(owner(), '<h2>Header of the page</span></h2>');

semantics().semanticsEnabled = false;
});

// This is a useless case, but we should at least not crash if it happens.
test('renders an empty unlabeled header', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;

final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
flags: 0 | ui.SemanticsFlag.isHeader.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);

owner().updateSemantics(builder.build());
expectSemanticsTree(owner(), '<header></header>');

semantics().semanticsEnabled = false;
});

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

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

semantics().semanticsEnabled = false;
Expand Down