[web:a11y] make header a <header> when non-empty and heading when empty (flutter/engine#55996)
This relands the [reverted](https://github.com/flutter/engine/pull/55993) [original PR](https://github.com/flutter/engine/pull/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 https://github.com/flutter/flutter/issues/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 https://github.com/flutter/flutter/issues/152268
This commit is contained in:
parent
2edc85cc0f
commit
c799bc6196
@ -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
|
||||
@ -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
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
@ -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
|
||||
|
@ -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';
|
||||
@ -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
|
||||
@ -688,13 +692,11 @@ 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
|
||||
@ -702,9 +704,6 @@ final class GenericRole extends SemanticRole {
|
||||
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');
|
||||
@ -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
|
||||
@ -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;
|
||||
@ -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.
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
@ -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;
|
||||
});
|
||||
|
||||
// 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', () {
|
||||
// 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;
|
||||
});
|
||||
|
||||
test('renders a header with children and uses aria-label', () {
|
||||
semantics()
|
||||
..debugOverrideTimestampFunction(() => _testTime)
|
||||
..semanticsEnabled = true;
|
||||
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user