diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart
index 465d66703a631..d008d5ae1c271 100644
--- a/lib/web_ui/lib/src/engine/dom.dart
+++ b/lib/web_ui/lib/src/engine/dom.dart
@@ -589,6 +589,14 @@ extension DomElementExtension on DomElement {
external JSNumber get _clientWidth;
double get clientWidth => _clientWidth.toDartDouble;
+ @JS('offsetHeight')
+ external JSNumber get _offsetHeight;
+ double get offsetHeight => _offsetHeight.toDartDouble;
+
+ @JS('offsetWidth')
+ external JSNumber get _offsetWidth;
+ double get offsetWidth => _offsetWidth.toDartDouble;
+
@JS('id')
external JSString get _id;
String get id => _id.toDart;
diff --git a/lib/web_ui/lib/src/engine/semantics/checkable.dart b/lib/web_ui/lib/src/engine/semantics/checkable.dart
index cf37b2423b59c..30b7928a131cb 100644
--- a/lib/web_ui/lib/src/engine/semantics/checkable.dart
+++ b/lib/web_ui/lib/src/engine/semantics/checkable.dart
@@ -55,7 +55,7 @@ class Checkable extends PrimaryRoleManager {
super.withBasics(
PrimaryRole.checkable,
semanticsObject,
- labelRepresentation: LeafLabelRepresentation.ariaLabel,
+ preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
addTappable();
}
diff --git a/lib/web_ui/lib/src/engine/semantics/incrementable.dart b/lib/web_ui/lib/src/engine/semantics/incrementable.dart
index 5a4abbbfd041b..12d4468cd2047 100644
--- a/lib/web_ui/lib/src/engine/semantics/incrementable.dart
+++ b/lib/web_ui/lib/src/engine/semantics/incrementable.dart
@@ -28,7 +28,7 @@ class Incrementable extends PrimaryRoleManager {
// the one being focused on, but the internal `` element.
addLiveRegion();
addRouteName();
- addLabelAndValue(labelRepresentation: LeafLabelRepresentation.ariaLabel);
+ addLabelAndValue(preferredRepresentation: LabelRepresentation.ariaLabel);
append(_element);
_element.type = 'range';
diff --git a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
index 1d8045bf70a85..9601074785b21 100644
--- a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
+++ b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'package:ui/ui.dart' as ui;
+
import '../dom.dart';
import 'semantics.dart';
@@ -14,12 +16,413 @@ import 'semantics.dart';
/// respect the `aria-label` without a [DomText] node. Crawlers typically do not
/// need this information, as they primarily scan visible text, which is
/// communicated in semantics as leaf text and heading nodes.
-enum LeafLabelRepresentation {
+enum LabelRepresentation {
/// Represents the label as an `aria-label` attribute.
+ ///
+ /// This representation is the most efficient as all it does is pass a string
+ /// to the browser that does not incur any DOM costs.
+ ///
+ /// The drawback of this representation is that it is not compatible with most
+ /// web crawlers, and for some ARIA roles (including the implicit "generic"
+ /// role) JAWS on Windows. However, this role is still the most common, as it
+ /// applies to all container nodes, and many ARIA roles (e.g. checkboxes,
+ /// radios, scrollables, sliders).
ariaLabel,
/// Represents the label as a [DomText] node.
+ ///
+ /// This is the second fastest way to represent a label in the DOM. It has a
+ /// small cost because the browser lays out the text (in addition to Flutter
+ /// having already done it).
+ ///
+ /// This representation is compatible with most web crawlers, and it is the
+ /// best option for certain ARIA roles, such as buttons, links, and headings.
domText,
+
+ /// Represents the label as a sized span.
+ ///
+ /// This representation is the costliest as it uses an extra element that
+ /// need to be laid out to compute the right size. It is compatible with most
+ /// web crawlers, and it is the best options for certain ARIA roles, such as
+ /// the implicit "generic" role used for plain text (not headings).
+ sizedSpan;
+
+ /// Creates the behavior for this label representation.
+ LabelRepresentationBehavior createBehavior(PrimaryRoleManager owner) {
+ return switch (this) {
+ ariaLabel => AriaLabelRepresentation._(owner),
+ domText => DomTextRepresentation._(owner),
+ sizedSpan => SizedSpanRepresentation._(owner),
+ };
+ }
+}
+
+/// Provides a DOM behavior for a [LabelRepresentation].
+abstract final class LabelRepresentationBehavior {
+ LabelRepresentationBehavior(this.kind, this.owner);
+
+ final LabelRepresentation kind;
+
+ /// The role manager that this label representation is attached to.
+ final PrimaryRoleManager owner;
+
+ /// Convenience getter for the corresponding semantics object.
+ SemanticsObject get semanticsObject => owner.semanticsObject;
+
+ /// Updates the label displayed to the user.
+ void update(String label);
+
+ /// Removes the DOM associated with this label.
+ ///
+ /// This can happen when the representation is changed from one type to
+ /// another.
+ void cleanUp();
+
+ /// The element that gets focus when [focusAsRouteDefault] is called.
+ ///
+ /// Each label behavior decides which element should be focused on based on
+ /// its own bespoke DOM structure.
+ DomElement get focusTarget;
+
+ /// Move the accessibility focus to the element the carries the label assuming
+ /// the node is not [Focusable].
+ ///
+ /// Since normally, plain text is not focusable (e.g. it doesn't have explicit
+ /// or implicit `tabindex`), `tabindex` must be added artificially.
+ ///
+ /// Plain text nodes should not be focusable via keyboard or mouse. They are
+ /// only focusable for the purposes of focusing the screen reader. To achieve
+ /// this the -1 value is used.
+ ///
+ /// See also:
+ ///
+ /// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
+ void focusAsRouteDefault() {
+ focusTarget.tabIndex = -1;
+ focusTarget.focus();
+ }
+}
+
+/// Sets the label as `aria-label`.
+///
+/// Example:
+///
+///
+final class AriaLabelRepresentation extends LabelRepresentationBehavior {
+ AriaLabelRepresentation._(PrimaryRoleManager owner) : super(LabelRepresentation.ariaLabel, owner);
+
+ String? _previousLabel;
+
+ @override
+ void update(String label) {
+ if (label == _previousLabel) {
+ return;
+ }
+ owner.setAttribute('aria-label', label);
+ }
+
+ @override
+ void cleanUp() {
+ owner.removeAttribute('aria-label');
+ }
+
+ // ARIA label does not introduce extra DOM elements, so focus should go to the
+ // semantic node's host element.
+ @override
+ DomElement get focusTarget => owner.element;
+}
+
+/// Sets the label as text inside the DOM element.
+///
+/// Example:
+///
+/// Hello, World!
+///
+/// This representation is used when the ARIA role of the element already sizes
+/// the element and therefore no extra sizing assistance is needed. If there is
+/// no ARIA role set, or the role does not size the element, then the
+/// [SizedSpanRepresentation] representation can be used.
+final class DomTextRepresentation extends LabelRepresentationBehavior {
+ DomTextRepresentation._(PrimaryRoleManager owner) : super(LabelRepresentation.domText, owner);
+
+ DomText? _domText;
+ String? _previousLabel;
+
+ @override
+ void update(String label) {
+ if (label == _previousLabel) {
+ return;
+ }
+
+ _domText?.remove();
+ final DomText domText = domDocument.createTextNode(label);
+ _domText = domText;
+ semanticsObject.element.appendChild(domText);
+ }
+
+ @override
+ void cleanUp() {
+ _domText?.remove();
+ }
+
+ // DOM text does not introduce extra DOM elements, so focus should go to the
+ // semantic node's host element.
+ @override
+ DomElement get focusTarget => owner.element;
+}
+
+/// A span queue for a size update.
+typedef _QueuedSizeUpdate = ({
+ // The span to be sized.
+ SizedSpanRepresentation representation,
+
+ // The desired size.
+ ui.Size targetSize,
+});
+
+/// The size of a span as measured in the DOM.
+typedef _Measurement = ({
+ // The span that was measured.
+ SizedSpanRepresentation representation,
+
+ // The measured size of the DOM element before the size adjustment.
+ ui.Size domSize,
+
+ // The size of the element that the screen reader should observe after the
+ // size adjustment.
+ ui.Size targetSize,
+});
+
+/// Sets the label as the text of a `` child element.
+///
+/// The span element is scaled to match the size of the semantic node.
+///
+/// Example:
+///
+///
+/// Hello, World!
+///
+///
+/// Text scaling is used to control the size of the screen reader focus ring.
+/// This is used for plain text nodes (e.g. paragraphs of text).
+///
+/// ## Why use scaling rather than another method?
+///
+/// Due to https://g-issues.chromium.org/issues/40875151?pli=1&authuser=0 and a
+/// lack of an ARIA role for plain text nodes (expecially after the removal of
+/// ARIA role "text" in WebKit, starting with Safari 17), there is no way to
+/// customize the size of the screen reader focus ring for a plain text element.
+/// The focus ring always tightly hugs the text itself. The following approaches
+/// were tried, and all failed:
+///
+/// * `text-align` + dummy text to force text align to span the width of the
+/// element. This does affect the screen reader focus size, but this method is
+/// limited to width only. There's no way to control the height. Also, using
+/// dummy text at the end feels extremely hacky, and risks failing due to
+/// proprietary screen reader behaviors - they may not consistency react to
+/// the dummy text (e.g. some may read it out loud).
+/// * The following methods did not have the desired effect:
+/// - Different `display` values.
+/// - Adding visual/layout features to the element: border, outline, padding,
+/// box-sizing, text-shadow.
+/// * `role="text"` was used previously and worked, but only in Safari pre-17.
+/// * `role="group"` sizes the element correctly, but breaks the message to the
+/// user (reads "empty group", requires multi-step traversal).
+/// * Adding `aria-hidden` contents to the element. This results in "group"
+/// behavior.
+/// * Use an existing non-text role, e.g. "heading". Sizes correctly, but breaks
+/// the message (reads "heading").
+final class SizedSpanRepresentation extends LabelRepresentationBehavior {
+ SizedSpanRepresentation._(PrimaryRoleManager owner) : super(LabelRepresentation.sizedSpan, owner) {
+ _domText.style
+ // `inline-block` is needed for two reasons:
+ // - It supports measuring the true size of the text. Pure `block` would
+ // disassociate the size of the text from the size of the element.
+ // - It supports the `transform` and `transform-origin` properties. Pure
+ // `inline` does not support them.
+ ..display = 'inline-block'
+
+ // Do not wrap text based on parent constraints. Instead, to fit in the
+ // parent's box the text will be scaled.
+ ..whiteSpace = 'nowrap'
+
+ // The origin of the coordinate system is the top-left corner of the
+ // parent element.
+ ..transformOrigin = '0 0 0';
+ semanticsObject.element.appendChild(_domText);
+ }
+
+ final DomElement _domText = domDocument.createElement('span');
+ String? _previousLabel;
+ ui.Size? _previousSize;
+
+ @override
+ void update(String label) {
+ final ui.Size? size = semanticsObject.rect?.size;
+ final bool labelChanged = label != _previousLabel;
+ final bool sizeChanged = size != _previousSize;
+
+ // Label must be updated before sizing because the size depends on text
+ // content.
+ if (labelChanged) {
+ _domText.text = label;
+ }
+
+ // This code makes the assumption that the DOM size of the element depends
+ // solely on the text of the label. This is because text in the semantics
+ // tree is unstyled. If this ever changes, this assumption will no longer
+ // hold, and this code will need to be updated.
+ if (labelChanged || sizeChanged) {
+ _updateSize(size);
+ }
+
+ // Remember the last used data to shut off unnecessary updates.
+ _previousLabel = label;
+ _previousSize = size;
+ }
+
+ // Scales the text span (if any), such that the text matches the size of the
+ // node. This is important because screen reader focus sizes itself tightly
+ // around the text. Frequently, Flutter wants the focus to be different from
+ // the text itself. For example, when you focus on a card widget containing a
+ // piece of text, it is desirable that the focus covers the whole card, and
+ // not just the text inside.
+ //
+ // The scaling may cause the text to become distorted, but that doesn't matter
+ // because the semantic DOM is invisible.
+ //
+ // See: https://github.com/flutter/flutter/issues/146774
+ void _updateSize(ui.Size? size) {
+ if (size == null) {
+ // There's no size to match => remove whatever stale sizing information was there.
+ // Note, it is not necessary to always reset the transform before measuring,
+ // as transform does not affect the offset size of the element. We do not
+ // reset it unnecessarily to reduce the cost of setting properties
+ // unnecessarily.
+ _domText.style.transform = '';
+ return;
+ }
+
+ if (_resizeQueue == null) {
+ _resizeQueue = <_QueuedSizeUpdate>[];
+
+ // Perform the adjustment in a post-update callback because the DOM layout
+ // can only be performed when the elements are attached to the document,
+ // but at this point the DOM tree is not yet finalized, and the element
+ // corresponding to the semantic node may still be detached.
+ semanticsObject.owner.addOneTimePostUpdateCallback(_updateSizes);
+ }
+ _resizeQueue!.add((
+ representation: this,
+ targetSize: size,
+ ));
+ }
+
+ @override
+ void cleanUp() {
+ _domText.remove();
+ }
+
+ static List<_QueuedSizeUpdate>? _resizeQueue;
+
+ static void _updateSizes() {
+ final List<_QueuedSizeUpdate>? queue = _resizeQueue;
+
+ // Eagerly reset the queue before doing any work. This ensures that if there
+ // is an unexpected error while processing the queue, we don't end up in a
+ // cycle that grows the queue indefinitely. Worst case, some text nodes end
+ // up incorrectly sized, but that's a smaller problem compared to running
+ // out of memory.
+ _resizeQueue = null;
+
+ assert(
+ queue != null && queue.isNotEmpty,
+ '_updateSizes was called with an empty _resizeQueue. This should never '
+ 'happend. If it does, please file an issue at '
+ 'https://github.com/flutter/flutter/issues/new/choose',
+ );
+
+ if (queue == null || queue.isEmpty) {
+ // This should not happen, but if it does (e.g. something else fails and
+ // caused the post-update callback to be called with an empty queue), do
+ // not crash.
+ return;
+ }
+
+ final List<_Measurement> measurements = <_Measurement>[];
+
+ // Step 1: set `display` to `inline` so that the measurement measures the
+ // true size of the text. Update all spans in a batch so that the
+ // measurement can be done without changing CSS properties that
+ // trigger reflow.
+ for (final _QueuedSizeUpdate update in queue) {
+ update.representation._domText.style.display = 'inline';
+ }
+
+ // Step 2: measure all spans in a single batch prior to updating their CSS
+ // styles. This way, all measurements are taken with a single reflow.
+ // Interleaving measurements with updates, will cause the browser to
+ // reflow the page between measurements.
+ for (final _QueuedSizeUpdate update in queue) {
+ // Both clientWidth/Height and offsetWidth/Height provide a good
+ // approximation for the purposes of sizing the focus ring of the text,
+ // since there's no borders or scrollbars. The `offset` variant was chosen
+ // mostly because it rounds the value to `int`, so the value is less
+ // volatile and therefore would need fewer updates.
+ //
+ // getBoundingClientRect() was considered and rejected, because it provides
+ // the rect in screen coordinates but this scale adjustment needs to be
+ // local.
+ final double domWidth = update.representation._domText.offsetWidth;
+ final double domHeight = update.representation._domText.offsetHeight;
+ measurements.add((
+ representation: update.representation,
+ domSize: ui.Size(domWidth, domHeight),
+ targetSize: update.targetSize,
+ ));
+ }
+
+ // Step 3: update all spans at a batch without taking any further DOM
+ // measurements, which avoids additional reflows.
+ for (final _Measurement measurement in measurements) {
+ final SizedSpanRepresentation representation = measurement.representation;
+ final double domWidth = measurement.domSize.width;
+ final double domHeight = measurement.domSize.height;
+ final ui.Size targetSize = measurement.targetSize;
+
+ // Reset back to `inline-block` (it was set to `inline` in Step 1).
+ representation._domText.style.display = 'inline-block';
+
+ if (domWidth < 1 && domHeight < 1) {
+ // Don't bother dealing with divisions by tiny numbers. This probably means
+ // the label is empty or doesn't contain anything that would be visible to
+ // the user.
+ representation._domText.style.transform = '';
+ } else {
+ final double scaleX = targetSize.width / domWidth;
+ final double scaleY = targetSize.height / domHeight;
+ representation._domText.style.transform = 'scale($scaleX, $scaleY)';
+ }
+ }
+
+ assert(_resizeQueue == null, '_resizeQueue must be empty after it is processed.');
+ }
+
+ // The structure of the sized span label looks like this:
+ //
+ //
+ // Here goes the label
+ //
+ //
+ // The target of the focus should be the , not the .
+ // Otherwise the browser will report the node as two separate nodes to the
+ // screen reader. It would require the user to make an additional navigation
+ // action to "step over" the to reach the where the
+ // text is. However, logically this DOM structure is just "one thing" as far
+ // as the user is concerned, so both `tabindex` and the text of the label
+ // should go on the same element.
+ @override
+ DomElement get focusTarget => _domText;
}
/// Renders [SemanticsObject.label] and/or [SemanticsObject.value] to the semantics DOM.
@@ -28,46 +431,50 @@ enum LeafLabelRepresentation {
/// interactive controls. In such case the value is reported via that element's
/// `value` attribute rather than rendering it separately.
class LabelAndValue extends RoleManager {
- LabelAndValue(SemanticsObject semanticsObject, PrimaryRoleManager owner, { required this.labelRepresentation })
+ LabelAndValue(SemanticsObject semanticsObject, PrimaryRoleManager owner, { required this.preferredRepresentation })
: super(Role.labelAndValue, semanticsObject, owner);
- /// Configures the representation of the label in the DOM.
- final LeafLabelRepresentation labelRepresentation;
+ /// The preferred representation of the label in the DOM.
+ ///
+ /// This value may be changed. Calling [update] after changing it will apply
+ /// the new preference.
+ ///
+ /// If the node contains children, [LabelRepresentation.ariaLabel] is used
+ /// instead.
+ LabelRepresentation preferredRepresentation;
@override
void update() {
final String? computedLabel = _computeLabel();
if (computedLabel == null) {
- _oldLabel = null;
_cleanUpDom();
return;
}
- _updateLabel(computedLabel);
+ _getEffectiveRepresentation().update(computedLabel);
}
- DomText? _domText;
- String? _oldLabel;
-
- void _updateLabel(String label) {
- if (label == _oldLabel) {
- return;
- }
- _oldLabel = label;
+ LabelRepresentationBehavior? _representation;
- final bool needsDomText = labelRepresentation == LeafLabelRepresentation.domText && !semanticsObject.hasChildren;
+ /// Return the representation that should be used based on the current
+ /// parameters of the semantic node.
+ ///
+ /// If the node has children always use an `aria-label`. Using extra child
+ /// nodes to represent the label will cause layout shifts and confuse the
+ /// screen reader. If the are no children, use the representation preferred
+ /// by the primary role manager.
+ LabelRepresentationBehavior _getEffectiveRepresentation() {
+ final LabelRepresentation effectiveRepresentation = semanticsObject.hasChildren
+ ? LabelRepresentation.ariaLabel
+ : preferredRepresentation;
- _domText?.remove();
- if (needsDomText) {
- owner.removeAttribute('aria-label');
- final DomText domText = domDocument.createTextNode(label);
- _domText = domText;
- semanticsObject.element.appendChild(domText);
- } else {
- owner.setAttribute('aria-label', label);
- _domText = null;
+ LabelRepresentationBehavior? representation = _representation;
+ if (representation == null || representation.kind != effectiveRepresentation) {
+ representation?.cleanUp();
+ _representation = representation = effectiveRepresentation.createBehavior(owner);
}
+ return representation;
}
/// Computes the final label to be assigned to the node.
@@ -88,8 +495,7 @@ class LabelAndValue extends RoleManager {
}
void _cleanUpDom() {
- owner.removeAttribute('aria-label');
- _domText?.remove();
+ _representation?.cleanUp();
}
@override
@@ -97,6 +503,18 @@ class LabelAndValue extends RoleManager {
super.dispose();
_cleanUpDom();
}
+
+ /// Moves the focus to the element that carries the semantic label.
+ ///
+ /// Typically a node would be [Focusable] and focus request would be satisfied
+ /// by transfering focus through the normal focusability features. However,
+ /// sometimes accessibility focus needs to be moved to a non-focusable node,
+ /// such as the title of a dialog. This method handles that situation.
+ /// Different label representations use different DOM structures, so the
+ /// actual work is delegated to [LabelRepresentationBehavior].
+ void focusAsRouteDefault() {
+ _getEffectiveRepresentation().focusAsRouteDefault();
+ }
}
String? computeDomSemanticsLabel({
diff --git a/lib/web_ui/lib/src/engine/semantics/link.dart b/lib/web_ui/lib/src/engine/semantics/link.dart
index d9f93078f005f..87a2a47a0759c 100644
--- a/lib/web_ui/lib/src/engine/semantics/link.dart
+++ b/lib/web_ui/lib/src/engine/semantics/link.dart
@@ -10,7 +10,7 @@ class Link extends PrimaryRoleManager {
Link(SemanticsObject semanticsObject) : super.withBasics(
PrimaryRole.link,
semanticsObject,
- labelRepresentation: LeafLabelRepresentation.domText,
+ preferredLabelRepresentation: LabelRepresentation.domText,
) {
addTappable();
}
diff --git a/lib/web_ui/lib/src/engine/semantics/platform_view.dart b/lib/web_ui/lib/src/engine/semantics/platform_view.dart
index 029a7dd71f63d..89b61507a7942 100644
--- a/lib/web_ui/lib/src/engine/semantics/platform_view.dart
+++ b/lib/web_ui/lib/src/engine/semantics/platform_view.dart
@@ -25,7 +25,7 @@ class PlatformViewRoleManager extends PrimaryRoleManager {
: super.withBasics(
PrimaryRole.platformView,
semanticsObject,
- labelRepresentation: LeafLabelRepresentation.ariaLabel,
+ preferredLabelRepresentation: LabelRepresentation.ariaLabel,
);
@override
diff --git a/lib/web_ui/lib/src/engine/semantics/scrollable.dart b/lib/web_ui/lib/src/engine/semantics/scrollable.dart
index bb42bbc822dec..45699c0fa15b6 100644
--- a/lib/web_ui/lib/src/engine/semantics/scrollable.dart
+++ b/lib/web_ui/lib/src/engine/semantics/scrollable.dart
@@ -27,15 +27,8 @@ class Scrollable extends PrimaryRoleManager {
: super.withBasics(
PrimaryRole.scrollable,
semanticsObject,
- labelRepresentation: LeafLabelRepresentation.ariaLabel,
- ) {
- _scrollOverflowElement.style
- ..position = 'absolute'
- ..transformOrigin = '0 0 0'
- // Ignore pointer events since this is a dummy element.
- ..pointerEvents = 'none';
- append(_scrollOverflowElement);
- }
+ preferredLabelRepresentation: LabelRepresentation.ariaLabel,
+ );
/// Disables browser-driven scrolling in the presence of pointer events.
GestureModeCallback? _gestureModeListener;
@@ -97,6 +90,20 @@ class Scrollable extends PrimaryRoleManager {
}
}
+ @override
+ void initState() {
+ // Scrolling is controlled by setting overflow-y/overflow-x to 'scroll`. The
+ // default overflow = "visible" needs to be unset.
+ semanticsObject.element.style.overflow = '';
+
+ _scrollOverflowElement.style
+ ..position = 'absolute'
+ ..transformOrigin = '0 0 0'
+ // Ignore pointer events since this is a dummy element.
+ ..pointerEvents = 'none';
+ append(_scrollOverflowElement);
+ }
+
@override
void update() {
super.update();
diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart
index a62f99eef1e41..505531afcc080 100644
--- a/lib/web_ui/lib/src/engine/semantics/semantics.dart
+++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart
@@ -434,12 +434,12 @@ abstract class PrimaryRoleManager {
///
/// If `labelRepresentation` is true, configures the [LabelAndValue] role with
/// [LabelAndValue.labelRepresentation] set to true.
- PrimaryRoleManager.withBasics(this.role, this.semanticsObject, { required LeafLabelRepresentation labelRepresentation }) {
+ PrimaryRoleManager.withBasics(this.role, this.semanticsObject, { required LabelRepresentation preferredLabelRepresentation }) {
element = _initElement(createElement(), semanticsObject);
addFocusManagement();
addLiveRegion();
addRouteName();
- addLabelAndValue(labelRepresentation: labelRepresentation);
+ addLabelAndValue(preferredRepresentation: preferredLabelRepresentation);
}
/// Initializes a blank role for a [semanticsObject].
@@ -475,7 +475,9 @@ abstract class PrimaryRoleManager {
static DomElement _initElement(DomElement element, SemanticsObject semanticsObject) {
// DOM nodes created for semantics objects are positioned absolutely using
// transforms.
- element.style.position = 'absolute';
+ element.style
+ ..position = 'absolute'
+ ..overflow = 'visible';
element.setAttribute('id', 'flt-semantic-node-${semanticsObject.id}');
// The root node has some properties that other nodes do not.
@@ -502,6 +504,20 @@ abstract class PrimaryRoleManager {
return element;
}
+ /// A lifecycle method called after the DOM [element] for this role manager
+ /// is initialized, and the association with the corresponding
+ /// [SemanticsObject] established.
+ ///
+ /// Override this method to implement expensive one-time initialization of a
+ /// role manager's state. It is more efficient to do such work in this method
+ /// compared to [update], because [update] can be called many times during the
+ /// lifecycle of the semantic node.
+ ///
+ /// It is safe to access [element], [semanticsObject], [secondaryRoleManagers]
+ /// and all helper methods that access these fields, such as [append],
+ /// [focusable], etc.
+ void initState() {}
+
/// Sets the `role` ARIA attribute.
void setAriaRole(String ariaRoleName) {
setAttribute('role', ariaRoleName);
@@ -541,9 +557,13 @@ abstract class PrimaryRoleManager {
addSecondaryRole(RouteName(semanticsObject, this));
}
+ /// Convenience getter for the [LabelAndValue] role manager, if any.
+ LabelAndValue? get labelAndValue => _labelAndValue;
+ LabelAndValue? _labelAndValue;
+
/// Adds generic label features.
- void addLabelAndValue({ required LeafLabelRepresentation labelRepresentation }) {
- addSecondaryRole(LabelAndValue(semanticsObject, this, labelRepresentation: labelRepresentation));
+ void addLabelAndValue({ required LabelRepresentation preferredRepresentation }) {
+ addSecondaryRole(_labelAndValue = LabelAndValue(semanticsObject, this, preferredRepresentation: preferredRepresentation));
}
/// Adds generic functionality for handling taps and clicks.
@@ -624,7 +644,10 @@ final class GenericRole extends PrimaryRoleManager {
GenericRole(SemanticsObject semanticsObject) : super.withBasics(
PrimaryRole.generic,
semanticsObject,
- labelRepresentation: LeafLabelRepresentation.domText,
+ // Prefer sized span because if this is a leaf it is frequently a Text widget.
+ // But if it turns out to be a container, then LabelAndValue will automatically
+ // switch to `aria-label`.
+ preferredLabelRepresentation: LabelRepresentation.sizedSpan,
) {
// Typically a tappable widget would have a more specific role, such as
// "link", "button", "checkbox", etc. However, there are situations when a
@@ -639,42 +662,40 @@ final class GenericRole extends PrimaryRoleManager {
@override
void update() {
- super.update();
-
if (!semanticsObject.hasLabel) {
// The node didn't get a more specific role, and it has no label. It is
// likely that this node is simply there for positioning its children and
// has no other role for the screen reader to be aware of. In this case,
// the element does not need a `role` attribute at all.
+ super.update();
return;
}
- // Assign one of three roles to the element: heading, group, text.
+ // Assign one of three roles to the element: group, heading, 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.
- // - "text" is used by default.
- //
- // As of October 24, 2022, "text" only has effect on Safari. Other browsers
- // ignore it. Setting role="text" prevents Safari from treating the element
- // as a "group" or "empty group". Other browsers still announce it as
- // "group" or "empty group". However, other options considered produced even
- // worse results, such as:
- //
- // - Ignore the size of the element and size the focus ring to the text
- // content, which is wrong. The HTML text size is irrelevant because
- // Flutter renders into canvas, so the focus ring looks wrong.
- // - Read out the same label multiple times.
+ // - 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 {
- setAriaRole('text');
+ labelAndValue!.preferredRepresentation = LabelRepresentation.sizedSpan;
+ removeAttribute('role');
}
+
+ // Call super.update last so the role is established before applying
+ // specific behaviors.
+ super.update();
}
@override
@@ -694,18 +715,9 @@ final class GenericRole extends PrimaryRoleManager {
return false;
}
- // Case 3: current node is visual/informational. Move just the
- // accessibility focus.
-
- // Plain text nodes should not be focusable via keyboard or mouse. They are
- // only focusable for the purposes of focusing the screen reader. To achieve
- // this the -1 value is used.
- //
- // See also:
- //
- // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
- element.tabIndex = -1;
- element.focus();
+ // Case 3: current node is visual/informational. Move just the accessibility
+ // focus.
+ labelAndValue!.focusAsRouteDefault();
return true;
}
}
@@ -1645,6 +1657,7 @@ class SemanticsObject {
if (currentPrimaryRole == null) {
currentPrimaryRole = _createPrimaryRole(roleId);
primaryRole = currentPrimaryRole;
+ currentPrimaryRole.initState();
currentPrimaryRole.update();
}
diff --git a/lib/web_ui/lib/src/engine/semantics/tappable.dart b/lib/web_ui/lib/src/engine/semantics/tappable.dart
index f99faab8f55cf..d5f58d6a6cf94 100644
--- a/lib/web_ui/lib/src/engine/semantics/tappable.dart
+++ b/lib/web_ui/lib/src/engine/semantics/tappable.dart
@@ -10,7 +10,7 @@ class Button extends PrimaryRoleManager {
Button(SemanticsObject semanticsObject) : super.withBasics(
PrimaryRole.button,
semanticsObject,
- labelRepresentation: LeafLabelRepresentation.domText,
+ preferredLabelRepresentation: LabelRepresentation.domText,
) {
addTappable();
setAriaRole('button');
diff --git a/lib/web_ui/test/common/matchers.dart b/lib/web_ui/test/common/matchers.dart
index a94f5b480ba59..2f1c19ce20f90 100644
--- a/lib/web_ui/test/common/matchers.dart
+++ b/lib/web_ui/test/common/matchers.dart
@@ -398,18 +398,68 @@ class HtmlPatternMatcher extends Matcher {
}
}
+ // Removes nodes that are not interesting for comparison purposes.
+ //
+ // In particular, removes non-leaf white space Text nodes between elements, as
+ // these are typically not interesting to test for. It's strictly not correct
+ // to ignore it entirely. For example, in the presence of a
tag or CSS
+ // `white-space: pre` white space does matter, but Flutter Web doesn't use
+ // them, at least not in tests, so it's OK to ignore.
+ List _cleanUpNodeList(html.NodeList nodeList) {
+ final List cleanNodes = [];
+ for (int i = 0; i < nodeList.length; i++) {
+ final html.Node node = nodeList[i];
+ assert(
+ node is html.Element || node is html.Text,
+ 'Unsupported node type ${node.runtimeType}. Only Element and Text nodes are supported',
+ );
+
+ final bool hasSiblings = nodeList.length > 1;
+ final bool isWhitespace = node is html.Text && node.data.trim().isEmpty;
+
+ if (hasSiblings && isWhitespace) {
+ // Ignore white space between elements, e.g.
+ // | | |
+ // ignore | |
+ // | |
+ // compare |
+ // ignore
+ continue;
+ }
+
+ cleanNodes.add(node);
+ }
+ return cleanNodes;
+ }
+
void matchChildren(_Breadcrumbs parent, List mismatches, html.Element element, html.Element pattern) {
- if (element.children.length != pattern.children.length) {
+ final List actualChildNodes = _cleanUpNodeList(element.nodes);
+ final List expectedChildNodes = _cleanUpNodeList(pattern.nodes);
+
+ if (actualChildNodes.length != expectedChildNodes.length) {
mismatches.add(
- '$parent: expected ${pattern.children.length} children, but found ${element.children.length}.'
+ '$parent: expected ${expectedChildNodes.length} child nodes, but found ${actualChildNodes.length}.'
);
return;
}
- for (int i = 0; i < pattern.children.length; i++) {
- final html.Element expectedChild = pattern.children[i];
- final html.Element actualChild = element.children[i];
- matchElements(parent, mismatches, actualChild, expectedChild);
+ for (int i = 0; i < expectedChildNodes.length; i++) {
+ final html.Node expectedChild = expectedChildNodes[i];
+ final html.Node actualChild = actualChildNodes[i];
+
+ if (expectedChild is html.Element && actualChild is html.Element) {
+ matchElements(parent, mismatches, actualChild, expectedChild);
+ } else if (expectedChild is html.Text && actualChild is html.Text) {
+ if (expectedChild.data != actualChild.data) {
+ mismatches.add(
+ '$parent: expected text content "${expectedChild.data}", but found "${actualChild.data}".'
+ );
+ }
+ } else {
+ mismatches.add(
+ '$parent: expected child type ${expectedChild.runtimeType}, but found ${actualChild.runtimeType}.'
+ );
+ }
}
}
diff --git a/lib/web_ui/test/engine/matchers_test.dart b/lib/web_ui/test/engine/matchers_test.dart
index 4c2ce65ac6e8b..b305d92553c79 100644
--- a/lib/web_ui/test/engine/matchers_test.dart
+++ b/lib/web_ui/test/engine/matchers_test.dart
@@ -44,6 +44,53 @@ Specifically:
);
});
+ test('trivial equal text content', () {
+ expectDom(
+ '
'),
+ '''
+The following DOM structure did not match the expected pattern:
+
hello
+
+Specifically:
+ - @div: expected text content "world", but found "hello".''',
+ ),
+ );
+ });
+
+ test('white space between elements', () {
+ expectDom(
+ '',
+ hasHtml(''),
+ );
+
+ expectDom(
+ '',
+ hasHtml(''),
+ );
+
+ expectDom(
+ '',
+ expectMismatch(
+ hasHtml(''),
+ '''
+The following DOM structure did not match the expected pattern:
+
+
+Specifically:
+ - @a > b: expected text content " ", but found " ".''',
+ ),
+ );
+ });
+
test('trivial equal attributes', () {
expectDom(
'',
@@ -192,7 +239,7 @@ The following DOM structure did not match the expected pattern:
Specifically:
- - @div: expected 3 children, but found 2.''',
+ - @div: expected 3 child nodes, but found 2.''',
),
);
});
@@ -207,7 +254,7 @@ The following DOM structure did not match the expected pattern: