[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 https://github.com/flutter/flutter/issues/152268
diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter
index 3b68db6..078e1ee 100644
--- a/ci/licenses_golden/licenses_flutter
+++ b/ci/licenses_golden/licenses_flutter
@@ -43700,6 +43700,7 @@
 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
@@ -46578,6 +46579,7 @@
 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 f50b7cf..d249100 100644
--- a/lib/web_ui/lib/src/engine.dart
+++ b/lib/web_ui/lib/src/engine.dart
@@ -147,6 +147,7 @@
 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 6bfca5a..036face 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 0000000..9de9e3d
--- /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 `<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;
+}
diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart
index 9ef9705..37071e4 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 'accessibility.dart';
 import 'checkable.dart';
 import 'focusable.dart';
+import 'header.dart';
 import 'heading.dart';
 import 'image.dart';
 import 'incrementable.dart';
@@ -396,14 +397,17 @@
   /// 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 @@
       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 @@
     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 @@
   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 @@
       return SemanticRoleKind.route;
     } else if (isLink) {
       return SemanticRoleKind.link;
+    } else if (isHeader) {
+      return SemanticRoleKind.header;
     } else {
       return SemanticRoleKind.generic;
     }
@@ -1696,6 +1710,7 @@
       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 c0b6ace..363d998 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 @@
 }
 
 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 @@
 
     owner().updateSemantics(builder.build());
     expectSemanticsTree(owner(), '''
-<sem role="heading">Header of the page</sem>
+<header><span>Header of the page</span></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;
@@ -788,7 +782,7 @@
 
     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;