diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index d17f5081d6281..e3e9054dd2e04 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -43702,6 +43702,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 @@ -46582,6 +46583,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 9ef9705573f6c..37071e44cea9d 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 @@ -677,13 +681,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 @@ -691,9 +693,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'); @@ -1261,11 +1260,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) && @@ -1679,6 +1691,8 @@ class SemanticsObject { return SemanticRoleKind.route; } else if (isLink) { return SemanticRoleKind.link; + } else if (isHeader) { + return SemanticRoleKind.header; } else { return SemanticRoleKind.generic; } @@ -1696,6 +1710,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 c0b6ace64334a..363d9989a6871 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -736,7 +736,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; @@ -752,19 +752,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; @@ -788,7 +782,7 @@ void _testHeader() { owner().updateSemantics(builder.build()); expectSemanticsTree(owner(), ''' - +
'''); semantics().semanticsEnabled = false;