From fdcf682a89a0d7a38c1f1f88d999e07594046031 Mon Sep 17 00:00:00 2001 From: Yegor Jbanov Date: Mon, 21 Oct 2024 13:57:05 -0700 Subject: [PATCH 1/3] Reapply "[web:a11y] make header a proper
(#55747)" (#55993) This reverts commit d302cc96e8b04aa359a26a778ea1e7e62f0cf15f. --- ci/licenses_golden/licenses_flutter | 2 + lib/web_ui/lib/src/engine.dart | 1 + lib/web_ui/lib/src/engine/semantics.dart | 1 + .../lib/src/engine/semantics/header.dart | 44 +++++++++++++++++++ .../lib/src/engine/semantics/semantics.dart | 35 ++++++++++----- .../test/engine/semantics/semantics_test.dart | 14 ++---- 6 files changed, 77 insertions(+), 20 deletions(-) create mode 100644 lib/web_ui/lib/src/engine/semantics/header.dart diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index e35f4f66585ac..c465d740edc4c 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -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 diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index f50b7cf78c73e..d2491003efc86 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.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'; diff --git a/lib/web_ui/lib/src/engine/semantics.dart b/lib/web_ui/lib/src/engine/semantics.dart index 6bfca5a58571a..036faceca171b 100644 --- a/lib/web_ui/lib/src/engine/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics.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'; diff --git a/lib/web_ui/lib/src/engine/semantics/header.dart b/lib/web_ui/lib/src/engine/semantics/header.dart new file mode 100644 index 0000000000000..9de9e3d4d271f --- /dev/null +++ b/lib/web_ui/lib/src/engine/semantics/header.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 `
` 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
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
DOM text
causes the browser to + // generate two a11y nodes, one for the
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
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; +} diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index b3f59d1292ecb..447cbad750f0b 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -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'); @@ -1272,11 +1271,24 @@ class SemanticsObject { bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField); /// Whether this object represents a heading element. + /// + /// Typically, a heading is a prominent piece of text that describes what the + /// rest of the screen or page is about. + /// + /// Not to be confused with [isHeader]. 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 represents a header. + /// + /// A header is a group of widgets that introduce the content of the screen + /// or a page. + /// + /// Not to be confused with [isHeading]. + bool get isHeader => hasFlag(ui.SemanticsFlag.isHeader); + /// Whether this object needs screen readers attention right away. bool get isLiveRegion => hasFlag(ui.SemanticsFlag.isLiveRegion) && @@ -1690,6 +1702,8 @@ class SemanticsObject { return SemanticRoleKind.route; } else if (isLink) { return SemanticRoleKind.link; + } else if (isHeader) { + return SemanticRoleKind.header; } else { return SemanticRoleKind.generic; } @@ -1707,6 +1721,7 @@ class SemanticsObject { SemanticRoleKind.platformView => SemanticPlatformView(this), SemanticRoleKind.link => SemanticLink(this), SemanticRoleKind.heading => SemanticHeading(this), + SemanticRoleKind.header => SemanticHeader(this), SemanticRoleKind.generic => GenericRole(this), }; } diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 8a24eb38fb3c3..6759141e744ba 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -742,7 +742,7 @@ class MockSemanticsEnabler implements SemanticsEnabler { } void _testHeader() { - test('renders heading role for headers', () { + test('renders a header with a label and uses a sized span for label', () { semantics() ..debugOverrideTimestampFunction(() => _testTime) ..semanticsEnabled = true; @@ -758,19 +758,13 @@ void _testHeader() { owner().updateSemantics(builder.build()); expectSemanticsTree(owner(), ''' -Header of the page +
Header of the page
'''); 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; @@ -794,7 +788,7 @@ void _testHeader() { owner().updateSemantics(builder.build()); expectSemanticsTree(owner(), ''' - +
'''); semantics().semanticsEnabled = false; From 1cf381c13ae7a6b362301fc8efbf1e3264b21793 Mon Sep 17 00:00:00 2001 From: Yegor Jbanov Date: Fri, 1 Nov 2024 16:53:29 -0700 Subject: [PATCH 2/3] empty header is a heading --- .../lib/src/engine/semantics/semantics.dart | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 447cbad750f0b..5aeac5aaba6e8 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -1126,6 +1126,8 @@ class SemanticsObject { int get headingLevel => _headingLevel; int _headingLevel = 0; + int get effectiveHeadingLevel => isHeader ? 2 : headingLevel; + static const int _headingLevelIndex = 1 << 24; /// Whether the [headingLevel] field has been updated but has not been @@ -1270,23 +1272,37 @@ class SemanticsObject { /// Whether this object represents an editable text field. bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField); - /// Whether this object represents a heading element. + /// 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. /// - /// Typically, a heading is a prominent piece of text that describes what the - /// rest of the screen or page is about. + /// Labeled empty headers are treated as headings too. + /// + /// See also: /// - /// Not to be confused with [isHeader]. - bool get isHeading => headingLevel != 0; + /// * [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 an interactive link. bool get isLink => hasFlag(ui.SemanticsFlag.isLink); /// Whether this object represents a header. /// - /// A header is a group of widgets that introduce the content of the screen - /// or a page. + /// 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: /// - /// Not to be confused with [isHeading]. + /// * [isHeading], which determines whether this node represents a heading. bool get isHeader => hasFlag(ui.SemanticsFlag.isHeader); /// Whether this object needs screen readers attention right away. @@ -1685,6 +1701,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; From a1d5bdf4fc75f7d67de31a1cc483a21453d1be4c Mon Sep 17 00:00:00 2001 From: Yegor Jbanov Date: Wed, 6 Nov 2024 11:29:49 -0800 Subject: [PATCH 3/3] empty labeled header is a heading --- .../lib/src/engine/semantics/heading.dart | 2 +- .../lib/src/engine/semantics/semantics.dart | 78 +++++++++++-------- .../test/engine/semantics/semantics_test.dart | 26 ++++++- 3 files changed, 68 insertions(+), 38 deletions(-) diff --git a/lib/web_ui/lib/src/engine/semantics/heading.dart b/lib/web_ui/lib/src/engine/semantics/heading.dart index 3f00837db34b3..051b6829c2508 100644 --- a/lib/web_ui/lib/src/engine/semantics/heading.dart +++ b/lib/web_ui/lib/src/engine/semantics/heading.dart @@ -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 tags, which // affects the size of the element. As the element size is fully defined diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 5aeac5aaba6e8..44a929e797fc5 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -1122,11 +1122,23 @@ 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; - int get effectiveHeadingLevel => isHeader ? 2 : headingLevel; + /// 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; @@ -1137,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; @@ -1272,39 +1314,9 @@ class SemanticsObject { /// Whether this object represents an editable text field. bool get isTextField => hasFlag(ui.SemanticsFlag.isTextField); - /// 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 an interactive link. bool get isLink => hasFlag(ui.SemanticsFlag.isLink); - /// 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); - /// Whether this object needs screen readers attention right away. bool get isLiveRegion => hasFlag(ui.SemanticsFlag.isLiveRegion) && diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 6759141e744ba..5975b9f6a2581 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -742,7 +742,7 @@ class MockSemanticsEnabler implements SemanticsEnabler { } void _testHeader() { - test('renders a header with a label and uses a sized span for label', () { + 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,9 +757,27 @@ void _testHeader() { ); owner().updateSemantics(builder.build()); - expectSemanticsTree(owner(), ''' -
Header of the page
-'''); + expectSemanticsTree(owner(), '

Header of the page

'); + + 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(), '
'); semantics().semanticsEnabled = false; });