[web] scale semantic text elements to match the desired focus ring size (flutter/engine#52586)
Due to https://g-issues.chromium.org/issues/40875151?pli=1&authuser=0 and a lack of an ARIA role for plain text nodes (after the removal of `role="text"` in WebKit recently), 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. A workaround implemented in this PR is to match the size of the text exactly to the desired focus ring size. This is done by measuring the size of the text in the DOM, then scaling it both vertically and horizontally to match the size requested by the framework for the corresponding semantics node. To avoid serious performance penalty, the following optimizations were included: - Nodes that are satisfiable by just an `aria-label` do not need this workaround, and are skipped. - Nodes that must use DOM text (e.g. links, buttons) but have ARIA roles that size them based on the element, do not need this workaround, and are skipped. - Nodes that do need the workaround are first measured in a single batch, incurring only one page reflow. Then they are all updated in a single batch without taking any further DOM measurements. This ensures that no matter how many text spans are rendered, only one reflow is needed to measure them all. - Nodes that need the workaround cache the previous label and size, and if they do not change, the size is not updated. Other changes: - Rename `LeafLabelRepresentation` to `LabelRepresentation`, because labels also apply to non-leaf nodes (e.g. `aria-label` may be applied to a container). - Rename `labelRepresentation` to `preferredLabelRepresentation`, because a particular label representation cannot be guaranteed. A node that currently looks like a leaf text node may turn out to be an empty container, and after adding children to it must switch from using DOM text to an `aria-label`. Therefore, role manager only specify a preference, but `LabelAndValue` ultimately decides which representation is usable. - Introduce `void initState()` in `PrimaryRoleManager` to be used for one-time initialization of state and DOM structure after all objects that are in a one-to-one relationship with each other create all the references needed to establish that relationship (`PrimaryRoleManager`, `SemanticsObject`, `element`, `owner`, etc). This is not available at the time the constructors are called. Fixes https://github.com/flutter/flutter/issues/146774.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -55,7 +55,7 @@ class Checkable extends PrimaryRoleManager {
|
||||
super.withBasics(
|
||||
PrimaryRole.checkable,
|
||||
semanticsObject,
|
||||
labelRepresentation: LeafLabelRepresentation.ariaLabel,
|
||||
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
|
||||
) {
|
||||
addTappable();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class Incrementable extends PrimaryRoleManager {
|
||||
// the one being focused on, but the internal `<input>` element.
|
||||
addLiveRegion();
|
||||
addRouteName();
|
||||
addLabelAndValue(labelRepresentation: LeafLabelRepresentation.ariaLabel);
|
||||
addLabelAndValue(preferredRepresentation: LabelRepresentation.ariaLabel);
|
||||
|
||||
append(_element);
|
||||
_element.type = 'range';
|
||||
|
||||
@@ -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:
|
||||
///
|
||||
/// <flt-semantics aria-label="Hello, World!"></flt-semantics>
|
||||
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:
|
||||
///
|
||||
/// <flt-semantics>Hello, World!</flt-semantics>
|
||||
///
|
||||
/// 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 `<span>` child element.
|
||||
///
|
||||
/// The span element is scaled to match the size of the semantic node.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// <flt-semantics>
|
||||
/// <span style="transform: scale(2, 2)">Hello, World!</span>
|
||||
/// </flt-semantics>
|
||||
///
|
||||
/// 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:
|
||||
//
|
||||
// <flt-semantics>
|
||||
// <span>Here goes the label</span>
|
||||
// </flt-semantics>
|
||||
//
|
||||
// The target of the focus should be the <span>, not the <flt-semantics>.
|
||||
// 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 <flt-semantics> to reach the <span> 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;
|
||||
LabelRepresentationBehavior? _representation;
|
||||
|
||||
void _updateLabel(String label) {
|
||||
if (label == _oldLabel) {
|
||||
return;
|
||||
}
|
||||
_oldLabel = label;
|
||||
|
||||
final bool needsDomText = labelRepresentation == LeafLabelRepresentation.domText && !semanticsObject.hasChildren;
|
||||
|
||||
_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;
|
||||
/// 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;
|
||||
|
||||
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({
|
||||
|
||||
@@ -10,7 +10,7 @@ class Link extends PrimaryRoleManager {
|
||||
Link(SemanticsObject semanticsObject) : super.withBasics(
|
||||
PrimaryRole.link,
|
||||
semanticsObject,
|
||||
labelRepresentation: LeafLabelRepresentation.domText,
|
||||
preferredLabelRepresentation: LabelRepresentation.domText,
|
||||
) {
|
||||
addTappable();
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class PlatformViewRoleManager extends PrimaryRoleManager {
|
||||
: super.withBasics(
|
||||
PrimaryRole.platformView,
|
||||
semanticsObject,
|
||||
labelRepresentation: LeafLabelRepresentation.ariaLabel,
|
||||
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
|
||||
);
|
||||
|
||||
@override
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 <pre> 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<html.Node> _cleanUpNodeList(html.NodeList nodeList) {
|
||||
final List<html.Node> cleanNodes = <html.Node>[];
|
||||
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. <div> <div> </div> </div>
|
||||
// | | |
|
||||
// ignore | |
|
||||
// | |
|
||||
// compare |
|
||||
// ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanNodes.add(node);
|
||||
}
|
||||
return cleanNodes;
|
||||
}
|
||||
|
||||
void matchChildren(_Breadcrumbs parent, List<String> mismatches, html.Element element, html.Element pattern) {
|
||||
if (element.children.length != pattern.children.length) {
|
||||
final List<html.Node> actualChildNodes = _cleanUpNodeList(element.nodes);
|
||||
final List<html.Node> 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}.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,53 @@ Specifically:
|
||||
);
|
||||
});
|
||||
|
||||
test('trivial equal text content', () {
|
||||
expectDom(
|
||||
'<div>hello</div>',
|
||||
hasHtml('<div>hello</div>'),
|
||||
);
|
||||
});
|
||||
|
||||
test('trivial unequal text content', () {
|
||||
expectDom(
|
||||
'<div>hello</div>',
|
||||
expectMismatch(
|
||||
hasHtml('<div>world</div>'),
|
||||
'''
|
||||
The following DOM structure did not match the expected pattern:
|
||||
<div>hello</div>
|
||||
|
||||
Specifically:
|
||||
- @div: expected text content "world", but found "hello".''',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('white space between elements', () {
|
||||
expectDom(
|
||||
'<a> <b> </b> </a>',
|
||||
hasHtml('<a><b> </b></a>'),
|
||||
);
|
||||
|
||||
expectDom(
|
||||
'<a><b> </b></a>',
|
||||
hasHtml('<a> <b> </b> </a>'),
|
||||
);
|
||||
|
||||
expectDom(
|
||||
'<a><b> </b></a>',
|
||||
expectMismatch(
|
||||
hasHtml('<a><b> </b></a>'),
|
||||
'''
|
||||
The following DOM structure did not match the expected pattern:
|
||||
<a><b> </b></a>
|
||||
|
||||
Specifically:
|
||||
- @a > b: expected text content " ", but found " ".''',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('trivial equal attributes', () {
|
||||
expectDom(
|
||||
'<div id="hello"></div>',
|
||||
@@ -192,7 +239,7 @@ The following DOM structure did not match the expected pattern:
|
||||
<div><span></span><p></p></div>
|
||||
|
||||
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:
|
||||
<div><span></span><waldo></waldo><p></p></div>
|
||||
|
||||
Specifically:
|
||||
- @div: expected 2 children, but found 3.''',
|
||||
- @div: expected 2 child nodes, but found 3.''',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ void _testRoleManagerLifecycle() {
|
||||
);
|
||||
tester.apply();
|
||||
|
||||
tester.expectSemantics('<sem role="button" style="$rootSemanticStyle"></sem>');
|
||||
tester.expectSemantics('<sem role="button"></sem>');
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
expect(node.primaryRole?.role, PrimaryRole.button);
|
||||
@@ -146,7 +146,7 @@ void _testRoleManagerLifecycle() {
|
||||
);
|
||||
tester.apply();
|
||||
|
||||
tester.expectSemantics('<sem role="button" style="$rootSemanticStyle">a label</sem>');
|
||||
tester.expectSemantics('<sem role="button">a label</sem>');
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
expect(node.primaryRole?.role, PrimaryRole.button);
|
||||
@@ -320,6 +320,30 @@ void _testEngineSemanticsOwner() {
|
||||
expect(copy.reduceMotion, true);
|
||||
});
|
||||
|
||||
test('makes the semantic DOM tree invisible', () {
|
||||
semantics()
|
||||
..debugOverrideTimestampFunction(() => _testTime)
|
||||
..semanticsEnabled = true;
|
||||
|
||||
final SemanticsTester tester = SemanticsTester(owner());
|
||||
tester.updateNode(
|
||||
id: 0,
|
||||
label: 'I am root',
|
||||
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
|
||||
);
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'''
|
||||
<sem style="filter: opacity(0%); color: rgba(0, 0, 0, 0)">
|
||||
<span>I am root</span>
|
||||
</sem>''',
|
||||
);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
|
||||
void renderSemantics({String? label, String? tooltip, Set<ui.SemanticsFlag> flags = const <ui.SemanticsFlag>{}}) {
|
||||
int flagValues = 0;
|
||||
for (final ui.SemanticsFlag flag in flags) {
|
||||
@@ -363,9 +387,9 @@ void _testEngineSemanticsOwner() {
|
||||
expect(tree[1]!.label, 'Hello');
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text">Hello</sem>
|
||||
<sem><span>Hello</span></sem>
|
||||
</sem-c>
|
||||
</sem>''');
|
||||
|
||||
@@ -373,9 +397,9 @@ void _testEngineSemanticsOwner() {
|
||||
renderLabel('World');
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text">World</sem>
|
||||
<sem><span>World</span></sem>
|
||||
</sem-c>
|
||||
</sem>''');
|
||||
|
||||
@@ -383,9 +407,9 @@ void _testEngineSemanticsOwner() {
|
||||
renderLabel('');
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text"></sem>
|
||||
<sem></sem>
|
||||
</sem-c>
|
||||
</sem>''');
|
||||
|
||||
@@ -406,9 +430,9 @@ void _testEngineSemanticsOwner() {
|
||||
final DomElement existingParent = tree[1]!.element.parent!;
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text">Hello</sem>
|
||||
<sem><span>Hello</span></sem>
|
||||
</sem-c>
|
||||
</sem>''');
|
||||
|
||||
@@ -421,7 +445,7 @@ void _testEngineSemanticsOwner() {
|
||||
expect(tree[1]!.label, 'Hello');
|
||||
expect(tree[1]!.element.tagName.toLowerCase(), 'a');
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<a style="display: block;">Hello</a>
|
||||
</sem-c>
|
||||
@@ -445,9 +469,9 @@ void _testEngineSemanticsOwner() {
|
||||
expect(tree[1]!.tooltip, 'tooltip');
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem>tooltip</sem>
|
||||
<sem><span>tooltip</span></sem>
|
||||
</sem-c>
|
||||
</sem>''');
|
||||
|
||||
@@ -455,9 +479,9 @@ void _testEngineSemanticsOwner() {
|
||||
renderSemantics(label: 'Hello', tooltip: 'tooltip');
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text">tooltip\nHello</sem>
|
||||
<sem><span>tooltip\nHello</span></sem>
|
||||
</sem-c>
|
||||
</sem>''');
|
||||
|
||||
@@ -465,9 +489,9 @@ void _testEngineSemanticsOwner() {
|
||||
renderSemantics();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text"></sem>
|
||||
<sem></sem>
|
||||
</sem-c>
|
||||
</sem>''');
|
||||
|
||||
@@ -660,7 +684,7 @@ void _testHeader() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="heading" style="$rootSemanticStyle">Header of the page</sem>
|
||||
<sem role="heading">Header of the page</sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -696,7 +720,7 @@ void _testHeader() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="group" aria-label="Header of the page" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
|
||||
<sem role="group" aria-label="Header of the page"><sem-c><sem></sem></sem-c></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -764,7 +788,7 @@ void _testText() {
|
||||
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'''<sem role="text" style="$rootSemanticStyle">plain text</sem>''',
|
||||
'''<sem><span>plain text</span></sem>''',
|
||||
);
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
@@ -797,7 +821,7 @@ void _testText() {
|
||||
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'''<sem flt-tappable="" role="text" style="$rootSemanticStyle">tappable text</sem>''',
|
||||
'''<sem flt-tappable=""><span>tappable text</span></sem>''',
|
||||
);
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
@@ -925,7 +949,7 @@ void _testContainer() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem></sem>
|
||||
</sem-c>
|
||||
@@ -976,7 +1000,7 @@ void _testContainer() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem></sem>
|
||||
</sem-c>
|
||||
@@ -1021,7 +1045,7 @@ void _testContainer() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem></sem>
|
||||
</sem-c>
|
||||
@@ -1072,7 +1096,7 @@ void _testContainer() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 4"></sem>
|
||||
<sem style="z-index: 2"></sem>
|
||||
@@ -1092,7 +1116,7 @@ void _testContainer() {
|
||||
);
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 4"></sem>
|
||||
<sem style="z-index: 3"></sem>
|
||||
@@ -1112,7 +1136,7 @@ void _testContainer() {
|
||||
);
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 1"></sem>
|
||||
<sem style="z-index: 3"></sem>
|
||||
@@ -1132,7 +1156,7 @@ void _testContainer() {
|
||||
);
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 2"></sem>
|
||||
<sem style="z-index: 4"></sem>
|
||||
@@ -1163,7 +1187,7 @@ void _testContainer() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 2"></sem>
|
||||
<sem style="z-index: 1"></sem>
|
||||
@@ -1215,7 +1239,7 @@ void _testContainer() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 2">
|
||||
<sem-c>
|
||||
@@ -1252,7 +1276,7 @@ void _testContainer() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 2">
|
||||
<sem-c>
|
||||
@@ -1287,7 +1311,7 @@ void _testVerticalScrolling() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle; touch-action: none; overflow-y: scroll">
|
||||
<sem style="touch-action: none; overflow-y: scroll">
|
||||
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
|
||||
</sem>''');
|
||||
|
||||
@@ -1319,7 +1343,7 @@ void _testVerticalScrolling() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle; touch-action: none; overflow-y: scroll">
|
||||
<sem style="touch-action: none; overflow-y: scroll">
|
||||
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
|
||||
<sem-c>
|
||||
<sem></sem>
|
||||
@@ -1376,7 +1400,7 @@ void _testVerticalScrolling() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle; touch-action: none; overflow-y: scroll">
|
||||
<sem style="touch-action: none; overflow-y: scroll">
|
||||
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
|
||||
<sem-c>
|
||||
<sem style="z-index: 3"></sem>
|
||||
@@ -1440,7 +1464,7 @@ void _testHorizontalScrolling() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle; touch-action: none; overflow-x: scroll">
|
||||
<sem style="touch-action: none; overflow-x: scroll">
|
||||
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
|
||||
</sem>''');
|
||||
|
||||
@@ -1470,7 +1494,7 @@ void _testHorizontalScrolling() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle; touch-action: none; overflow-x: scroll">
|
||||
<sem style="touch-action: none; overflow-x: scroll">
|
||||
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
|
||||
<sem-c>
|
||||
<sem></sem>
|
||||
@@ -1527,7 +1551,7 @@ void _testHorizontalScrolling() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle; touch-action: none; overflow-x: scroll">
|
||||
<sem style="touch-action: none; overflow-x: scroll">
|
||||
<flt-semantics-scroll-overflow></flt-semantics-scroll-overflow>
|
||||
<sem-c>
|
||||
<sem style="z-index: 3"></sem>
|
||||
@@ -1591,7 +1615,7 @@ void _testIncrementables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="1">
|
||||
</sem>''');
|
||||
|
||||
@@ -1624,7 +1648,7 @@ void _testIncrementables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="1">
|
||||
</sem>''');
|
||||
|
||||
@@ -1657,7 +1681,7 @@ void _testIncrementables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="0">
|
||||
</sem>''');
|
||||
|
||||
@@ -1692,7 +1716,7 @@ void _testIncrementables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="0">
|
||||
</sem>''');
|
||||
|
||||
@@ -1770,7 +1794,7 @@ void _testTextField() {
|
||||
owner().updateSemantics(builder.build());
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<input />
|
||||
</sem>''');
|
||||
|
||||
@@ -1856,7 +1880,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem aria-label="test label" flt-tappable role="switch" aria-checked="true" style="$rootSemanticStyle"></sem>
|
||||
<sem aria-label="test label" flt-tappable role="switch" aria-checked="true"></sem>
|
||||
''');
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
@@ -1889,7 +1913,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="switch" aria-disabled="true" aria-checked="true" style="$rootSemanticStyle"></sem>
|
||||
<sem role="switch" aria-disabled="true" aria-checked="true"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -1914,7 +1938,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="switch" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
|
||||
<sem role="switch" flt-tappable aria-checked="false"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -1940,7 +1964,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="checkbox" flt-tappable aria-checked="true" style="$rootSemanticStyle"></sem>
|
||||
<sem role="checkbox" flt-tappable aria-checked="true"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -1965,7 +1989,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="checkbox" aria-disabled="true" aria-checked="true" style="$rootSemanticStyle"></sem>
|
||||
<sem role="checkbox" aria-disabled="true" aria-checked="true"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -1990,7 +2014,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="checkbox" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
|
||||
<sem role="checkbox" flt-tappable aria-checked="false"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2017,7 +2041,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="radio" flt-tappable aria-checked="true" style="$rootSemanticStyle"></sem>
|
||||
<sem role="radio" flt-tappable aria-checked="true"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2043,7 +2067,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="radio" aria-disabled="true" aria-checked="true" style="$rootSemanticStyle"></sem>
|
||||
<sem role="radio" aria-disabled="true" aria-checked="true"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2069,7 +2093,7 @@ void _testCheckables() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="radio" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
|
||||
<sem role="radio" flt-tappable aria-checked="false"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2154,7 +2178,7 @@ void _testTappable() {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>
|
||||
<sem role="button" flt-tappable></sem>
|
||||
''');
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
@@ -2186,7 +2210,7 @@ void _testTappable() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>
|
||||
<sem role="button" aria-disabled="true"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2213,25 +2237,25 @@ void _testTappable() {
|
||||
updateTappable(enabled: false);
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>'
|
||||
'<sem role="button" aria-disabled="true"></sem>'
|
||||
);
|
||||
|
||||
updateTappable(enabled: true);
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>',
|
||||
'<sem role="button" flt-tappable></sem>',
|
||||
);
|
||||
|
||||
updateTappable(enabled: false);
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>',
|
||||
'<sem role="button" aria-disabled="true"></sem>',
|
||||
);
|
||||
|
||||
updateTappable(enabled: true);
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>',
|
||||
'<sem role="button" flt-tappable></sem>',
|
||||
);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2349,7 +2373,7 @@ void _testTappable() {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem flt-tappable role="button" style="$rootSemanticStyle">
|
||||
<sem flt-tappable role="button">
|
||||
<sem-c>
|
||||
<sem flt-tappable role="button"></sem>
|
||||
</sem-c>
|
||||
@@ -2411,7 +2435,7 @@ void _testImage() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="img" aria-label="Test Image Label" style="$rootSemanticStyle"></sem>
|
||||
<sem role="img" aria-label="Test Image Label"></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2441,9 +2465,8 @@ void _testImage() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem-img role="img" aria-label="Test Image Label">
|
||||
</sem-img>
|
||||
<sem>
|
||||
<sem-img role="img" aria-label="Test Image Label"></sem-img>
|
||||
<sem-c>
|
||||
<sem></sem>
|
||||
</sem-c>
|
||||
@@ -2468,7 +2491,7 @@ void _testImage() {
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem role="img" style="$rootSemanticStyle"></sem>',
|
||||
'<sem role="img"></sem>',
|
||||
);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2497,9 +2520,8 @@ void _testImage() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem-img role="img">
|
||||
</sem-img>
|
||||
<sem>
|
||||
<sem-img role="img"></sem-img>
|
||||
<sem-c>
|
||||
<sem></sem>
|
||||
</sem-c>
|
||||
@@ -2632,7 +2654,7 @@ void _testPlatformView() {
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem aria-owns="flt-pv-5" style="$rootSemanticStyle"></sem>',
|
||||
'<sem aria-owns="flt-pv-5"></sem>',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2647,7 +2669,7 @@ void _testPlatformView() {
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem aria-owns="flt-pv-42" style="$rootSemanticStyle"></sem>',
|
||||
'<sem aria-owns="flt-pv-42"></sem>',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2669,7 +2691,7 @@ void _testPlatformView() {
|
||||
|
||||
expectSemanticsTree(
|
||||
owner(),
|
||||
'<sem aria-owns="flt-pv-5" style="$rootSemanticStyle"></sem>',
|
||||
'<sem aria-owns="flt-pv-5"></sem>',
|
||||
);
|
||||
final DomElement element = owner().semanticsHost.querySelector('flt-semantics')!;
|
||||
expect(element.style.pointerEvents, 'none');
|
||||
@@ -2751,7 +2773,7 @@ void _testPlatformView() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem style="z-index: 3"></sem>
|
||||
<sem style="z-index: 2" aria-owns="flt-pv-0"></sem>
|
||||
@@ -2855,7 +2877,7 @@ void _testGroup() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="group" aria-label="this is a label for a group of elements" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
|
||||
<sem role="group" aria-label="this is a label for a group of elements"><sem-c><sem></sem></sem-c></sem>
|
||||
''');
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -2887,7 +2909,7 @@ void _testDialog() {
|
||||
|
||||
owner().updateSemantics(builder.build());
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="dialog" aria-label="this is a dialog label" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
|
||||
<sem role="dialog" aria-label="this is a dialog label"><sem-c><sem></sem></sem-c></sem>
|
||||
''');
|
||||
|
||||
expect(
|
||||
@@ -2932,7 +2954,7 @@ void _testDialog() {
|
||||
|
||||
// But still sets the dialog role.
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="dialog" aria-label="" style="$rootSemanticStyle"><sem-c><sem></sem></sem-c></sem>
|
||||
<sem role="dialog" aria-label=""><sem-c><sem></sem></sem-c></sem>
|
||||
''');
|
||||
|
||||
expect(
|
||||
@@ -2970,11 +2992,11 @@ void _testDialog() {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="dialog" aria-describedby="flt-semantic-node-2" style="$rootSemanticStyle">
|
||||
<sem role="dialog" aria-describedby="flt-semantic-node-2">
|
||||
<sem-c>
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text">$label</sem>
|
||||
<sem><span>$label</span></sem>
|
||||
</sem-c>
|
||||
</sem>
|
||||
</sem-c>
|
||||
@@ -3019,7 +3041,7 @@ void _testDialog() {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle"></sem>
|
||||
<sem></sem>
|
||||
''');
|
||||
|
||||
expect(
|
||||
@@ -3059,11 +3081,11 @@ void _testDialog() {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text">Hello</sem>
|
||||
<sem><span>Hello</span></sem>
|
||||
</sem-c>
|
||||
</sem>
|
||||
</sem-c>
|
||||
@@ -3255,9 +3277,24 @@ void _testDialog() {
|
||||
expect(capturedActions, isEmpty);
|
||||
|
||||
// However, the element should have gotten the focus.
|
||||
final DomElement element = owner().debugSemanticsTree![2]!.element;
|
||||
expect(element.tabIndex, -1);
|
||||
expect(domDocument.activeElement, element);
|
||||
|
||||
tester.expectSemantics('''
|
||||
<flt-semantics>
|
||||
<flt-semantics-container>
|
||||
<flt-semantics>
|
||||
<flt-semantics-container>
|
||||
<flt-semantics id="flt-semantic-node-2">
|
||||
<span tabindex="-1">Heading</span>
|
||||
</flt-semantics>
|
||||
<flt-semantics role="button" tabindex="0" flt-tappable="">Click me!</flt-semantics>
|
||||
</flt-semantics-container>
|
||||
</flt-semantics>
|
||||
</flt-semantics-container>
|
||||
</flt-semantics>''');
|
||||
|
||||
final DomElement span = owner().debugSemanticsTree![2]!.element.querySelectorAll('span').single;
|
||||
expect(span.tabIndex, -1);
|
||||
expect(domDocument.activeElement, span);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
@@ -3420,9 +3457,9 @@ void _testFocusable() {
|
||||
}
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<sem-c>
|
||||
<sem role="text">focusable text</sem>
|
||||
<sem><span>focusable text</span></sem>
|
||||
</sem-c>
|
||||
</sem>
|
||||
''');
|
||||
|
||||
@@ -13,11 +13,6 @@ import 'package:ui/ui.dart' as ui;
|
||||
|
||||
import '../../common/matchers.dart';
|
||||
|
||||
/// CSS style applied to the root of the semantics tree.
|
||||
// TODO(yjbanov): this should be handled internally by [expectSemanticsTree].
|
||||
// No need for every test to inject it.
|
||||
const String rootSemanticStyle = 'filter: opacity(0%); color: rgba(0, 0, 0, 0)';
|
||||
|
||||
/// A convenience wrapper of the semantics API for building and inspecting the
|
||||
/// semantics tree in unit tests.
|
||||
class SemanticsTester {
|
||||
|
||||
@@ -14,7 +14,6 @@ import '../../common/rendering.dart';
|
||||
import '../../common/test_initialization.dart';
|
||||
import 'semantics_tester.dart';
|
||||
|
||||
const String _rootStyle = 'style="filter: opacity(0%); color: rgba(0, 0, 0, 0)"';
|
||||
DateTime _testTime = DateTime(2023, 2, 17);
|
||||
EngineSemantics semantics() => EngineSemantics.instance;
|
||||
EngineSemanticsOwner owner() => EnginePlatformDispatcher.instance.implicitView!.semantics;
|
||||
@@ -46,7 +45,7 @@ Future<void> testMain() async {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="text" $_rootStyle>Hello</sem>'''
|
||||
<sem><span>Hello</span></sem>'''
|
||||
);
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
@@ -58,7 +57,7 @@ Future<void> testMain() async {
|
||||
);
|
||||
}
|
||||
|
||||
// Change label - expect both <span> and aria-label to be updated.
|
||||
// Change label - expect the <span> to be updated.
|
||||
{
|
||||
final SemanticsTester tester = SemanticsTester(owner());
|
||||
tester.updateNode(
|
||||
@@ -70,7 +69,7 @@ Future<void> testMain() async {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="text" $_rootStyle>World</sem>'''
|
||||
<sem><span>World</span></sem>'''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +84,7 @@ Future<void> testMain() async {
|
||||
);
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '<sem role="text" $_rootStyle></sem>');
|
||||
expectSemanticsTree(owner(), '<sem></sem>');
|
||||
}
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
@@ -114,9 +113,9 @@ Future<void> testMain() async {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem aria-label="I am a parent" role="group" $_rootStyle>
|
||||
<sem aria-label="I am a parent" role="group">
|
||||
<sem-c>
|
||||
<sem role="text">I am a child</sem>
|
||||
<sem><span>I am a child</span></sem>
|
||||
</sem-c>
|
||||
</sem>'''
|
||||
);
|
||||
@@ -141,7 +140,7 @@ Future<void> testMain() async {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="text" $_rootStyle>I am a leaf</sem>'''
|
||||
<sem><span>I am a leaf</span></sem>'''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,9 +164,9 @@ Future<void> testMain() async {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem aria-label="I am a parent" role="group" $_rootStyle>
|
||||
<sem aria-label="I am a parent" role="group">
|
||||
<sem-c>
|
||||
<sem role="text">I am a child</sem>
|
||||
<sem><span>I am a child</span></sem>
|
||||
</sem-c>
|
||||
</sem>'''
|
||||
);
|
||||
@@ -185,10 +184,105 @@ Future<void> testMain() async {
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem role="text" $_rootStyle>I am a leaf again</sem>'''
|
||||
<sem><span>I am a leaf again</span></sem>'''
|
||||
);
|
||||
}
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
|
||||
test('focusAsRouteDefault focuses on <span> when sized span is used', () async {
|
||||
semantics()
|
||||
..debugOverrideTimestampFunction(() => _testTime)
|
||||
..semanticsEnabled = true;
|
||||
|
||||
final SemanticsTester tester = SemanticsTester(owner());
|
||||
tester.updateNode(
|
||||
id: 0,
|
||||
label: 'Hello',
|
||||
transform: Matrix4.identity().toFloat64(),
|
||||
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
|
||||
);
|
||||
tester.apply();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem><span>Hello</span></sem>'''
|
||||
);
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
final DomElement span = node.element.querySelector('span')!;
|
||||
|
||||
expect(span.getAttribute('tabindex'), isNull);
|
||||
node.primaryRole!.focusAsRouteDefault();
|
||||
expect(span.getAttribute('tabindex'), '-1');
|
||||
expect(domDocument.activeElement, span);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
|
||||
test('focusAsRouteDefault focuses on <flt-semantics> when DOM text is used', () async {
|
||||
semantics()
|
||||
..debugOverrideTimestampFunction(() => _testTime)
|
||||
..semanticsEnabled = true;
|
||||
|
||||
final SemanticsTester tester = SemanticsTester(owner());
|
||||
tester.updateNode(
|
||||
id: 0,
|
||||
label: 'Hello',
|
||||
transform: Matrix4.identity().toFloat64(),
|
||||
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
|
||||
);
|
||||
tester.apply();
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
|
||||
// Set DOM text as preferred representation
|
||||
final LabelAndValue lav = node.primaryRole!.labelAndValue!;
|
||||
lav.preferredRepresentation = LabelRepresentation.domText;
|
||||
lav.update();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem>Hello</sem>'''
|
||||
);
|
||||
|
||||
expect(node.element.getAttribute('tabindex'), isNull);
|
||||
node.primaryRole!.focusAsRouteDefault();
|
||||
expect(node.element.getAttribute('tabindex'), '-1');
|
||||
expect(domDocument.activeElement, node.element);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
|
||||
test('focusAsRouteDefault focuses on <flt-semantics> when aria-label is used', () async {
|
||||
semantics()
|
||||
..debugOverrideTimestampFunction(() => _testTime)
|
||||
..semanticsEnabled = true;
|
||||
|
||||
final SemanticsTester tester = SemanticsTester(owner());
|
||||
tester.updateNode(
|
||||
id: 0,
|
||||
label: 'Hello',
|
||||
transform: Matrix4.identity().toFloat64(),
|
||||
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
|
||||
);
|
||||
tester.apply();
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
|
||||
// Set DOM text as preferred representation
|
||||
final LabelAndValue lav = node.primaryRole!.labelAndValue!;
|
||||
lav.preferredRepresentation = LabelRepresentation.ariaLabel;
|
||||
lav.update();
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem aria-label="Hello"></sem>'''
|
||||
);
|
||||
|
||||
expect(node.element.getAttribute('tabindex'), isNull);
|
||||
node.primaryRole!.focusAsRouteDefault();
|
||||
expect(node.element.getAttribute('tabindex'), '-1');
|
||||
expect(domDocument.activeElement, node.element);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
}
|
||||
@@ -93,7 +93,7 @@ void testMain() {
|
||||
createTextFieldSemantics(value: 'hello');
|
||||
|
||||
expectSemanticsTree(owner(), '''
|
||||
<sem style="$rootSemanticStyle">
|
||||
<sem>
|
||||
<input />
|
||||
</sem>''');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user