From fbbe0f9e7a09c433c6cf07a847a1d8923a876ee7 Mon Sep 17 00:00:00 2001 From: Yegor Date: Wed, 2 Apr 2025 15:00:19 -0700 Subject: [PATCH] [a11y] add SemanticsValidationResult (#165935) Add `SemanticsValidationResult` to semantics that maps onto `aria-invalid`. Fixes https://github.com/flutter/flutter/issues/162142 --- engine/src/flutter/lib/ui/semantics.dart | 48 ++++++- .../flutter/lib/ui/semantics/semantics_node.h | 13 ++ .../ui/semantics/semantics_update_builder.cc | 5 +- .../ui/semantics/semantics_update_builder.h | 3 +- .../src/flutter/lib/web_ui/lib/semantics.dart | 5 + .../src/engine/semantics/incrementable.dart | 5 + .../lib/src/engine/semantics/semantics.dart | 59 +++++++++ .../lib/src/engine/semantics/text_field.dart | 5 + .../lib/web_ui/test/common/matchers.dart | 15 ++- .../test/engine/semantics/semantics_test.dart | 55 ++++++++ .../engine/semantics/semantics_tester.dart | 2 + .../flutter/lib/src/material/data_table.dart | 1 - .../lib/src/rendering/custom_paint.dart | 6 + .../flutter/lib/src/rendering/proxy_box.dart | 3 + packages/flutter/lib/src/rendering/table.dart | 1 - .../flutter/lib/src/semantics/semantics.dart | 97 +++++++++++++- packages/flutter/lib/src/widgets/basic.dart | 6 +- packages/flutter/lib/src/widgets/form.dart | 10 +- packages/flutter/lib/src/widgets/table.dart | 1 - .../test/material/data_table_test.dart | 1 - .../test/material/date_picker_test.dart | 1 + .../input_date_picker_form_field_test.dart | 2 + .../test/semantics/semantics_update_test.dart | 3 +- .../test/widgets/custom_painter_test.dart | 88 +++++++++++++ packages/flutter/test/widgets/form_test.dart | 49 +++++++ .../flutter/test/widgets/semantics_test.dart | 121 ++++++++++++++++++ .../test/widgets/semantics_tester.dart | 19 ++- packages/flutter/test/widgets/table_test.dart | 1 - packages/flutter_test/lib/src/matchers.dart | 12 ++ packages/flutter_test/test/matchers_test.dart | 6 + 30 files changed, 620 insertions(+), 23 deletions(-) diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index ba274549de..e69ed58887 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -46,10 +46,12 @@ class SemanticsAction { static const int _kSetTextIndex = 1 << 21; static const int _kFocusIndex = 1 << 22; static const int _kScrollToOffsetIndex = 1 << 23; - // READ THIS: if you add an action here, you MUST update the - // numSemanticsActions value in testing/dart/semantics_test.dart and - // lib/web_ui/test/engine/semantics/semantics_api_test.dart, or tests - // will fail. + // READ THIS: + // - The maximum supported bit index on the web (in JS mode) is 1 << 31. + // - If you add an action here, you MUST update the numSemanticsActions value + // in testing/dart/semantics_test.dart and + // lib/web_ui/test/engine/semantics/semantics_api_test.dart, or tests will + // fail. /// The equivalent of a user briefly tapping the screen with the finger /// without moving it. @@ -555,6 +557,7 @@ class SemanticsFlag { static const int _kIsRequiredIndex = 1 << 30; // READ THIS: if you add a flag here, you MUST update the following: // + // - The maximum supported bit index on the web (in JS mode) is 1 << 31. // - Add an appropriately named and documented `static const SemanticsFlag` // field to this class. // - Add the new flag to `_kFlagById` in this file. @@ -936,6 +939,30 @@ class SemanticsFlag { String toString() => 'SemanticsFlag.$name'; } +/// The validation result of a form field. +/// +/// The type, shape, and correctness of the value is specific to the kind of +/// form field used. For example, a phone number text field may check that the +/// value is a properly formatted phone number, and/or that the phone number has +/// the right area code. A group of radio buttons may validate that the user +/// selected at least one radio option. +enum SemanticsValidationResult { + /// The node has no validation information attached to it. + /// + /// This is the default value. Most semantics nodes do not contain validation + /// information. Typically, only nodes that are part of an input form - text + /// fields, checkboxes, radio buttons, dropdowns - are validated and attach + /// validation results to their corresponding semantics nodes. + none, + + /// The entered value is valid, and no error should be displayed to the user. + valid, + + /// The entered value is invalid, and an error message should be communicated + /// to the user. + invalid, +} + // When adding a new StringAttribute, the classes in these files must be // updated as well. // * engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -1154,10 +1181,18 @@ abstract class SemanticsUpdateBuilder { /// The `role` describes the role of this node. Defaults to /// [SemanticsRole.none] if not set. /// + /// If `validationResult` is not null, indicates the result of validating a + /// form field. If null, indicates that the node is not being validated, or + /// that the result is unknown. Form fields that validate user input but do + /// not use this argument should use other ways to communicate validation + /// errors to the user, such as embedding validation error text in the label. + /// /// See also: /// /// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role /// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-level + /// * [SemanticsValidationResult], that describes possible values for the + /// `validationResult` argument. void updateNode({ required int id, required int flags, @@ -1196,6 +1231,7 @@ abstract class SemanticsUpdateBuilder { String linkUrl = '', SemanticsRole role = SemanticsRole.none, required List? controlsNodes, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, }); /// Update the custom semantics action associated with the given `id`. @@ -1273,6 +1309,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 String linkUrl = '', SemanticsRole role = SemanticsRole.none, required List? controlsNodes, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, }) { assert(_matrix4IsValid(transform)); assert( @@ -1320,6 +1357,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 linkUrl, role.index, controlsNodes, + validationResult.index, ); } @@ -1366,6 +1404,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 Handle, Int32, Handle, + Int32, ) >(symbol: 'SemanticsUpdateBuilder::updateNode') external void _updateNode( @@ -1409,6 +1448,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 String linkUrl, int role, List? controlsNodes, + int validationResultIndex, ); @override diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h index 29d759f9fd..53f1c12ec5 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_node.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h @@ -100,6 +100,18 @@ enum class SemanticsRole : int32_t { kAlert = 28, }; +/// C/C++ representation of `SemanticsValidationResult` defined in +/// `lib/ui/semantics.dart`. +///\warning This must match the `SemanticsValidationResult` enum in +/// `lib/ui/semantics.dart`. +/// See also: +/// - file://./../../../lib/ui/semantics.dart +enum class SemanticsValidationResult : int32_t { + kNone = 0, + kValid = 1, + kInvalid = 2, +}; + /// C/C++ representation of `SemanticsFlags` defined in /// `lib/ui/semantics.dart`. ///\warning This must match the `SemanticsFlags` enum in @@ -194,6 +206,7 @@ struct SemanticsNode { std::string linkUrl; SemanticsRole role; + SemanticsValidationResult validationResult = SemanticsValidationResult::kNone; }; // Contains semantic nodes that need to be updated. diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc index 5c63c9f6e6..ca8240c139 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc @@ -70,7 +70,8 @@ void SemanticsUpdateBuilder::updateNode( int headingLevel, std::string linkUrl, int role, - const std::vector& controlsNodes) { + const std::vector& controlsNodes, + int validationResult) { FML_CHECK(scrollChildren == 0 || (scrollChildren > 0 && childrenInHitTestOrder.data())) << "Semantics update contained scrollChildren but did not have " @@ -124,6 +125,8 @@ void SemanticsUpdateBuilder::updateNode( node.headingLevel = headingLevel; node.linkUrl = std::move(linkUrl); node.role = static_cast(role); + node.validationResult = + static_cast(validationResult); nodes_[id] = node; } diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h index 8704196e80..88139bcdcd 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h @@ -69,7 +69,8 @@ class SemanticsUpdateBuilder int headingLevel, std::string linkUrl, int role, - const std::vector& controlsNodes); + const std::vector& controlsNodes, + int validationResult); void updateCustomAction(int id, std::string label, diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index f5b4ffce41..8e8c677c86 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -162,6 +162,7 @@ class SemanticsFlag { static const int _kHasSelectedStateIndex = 1 << 28; static const int _kHasRequiredStateIndex = 1 << 29; static const int _kIsRequiredIndex = 1 << 30; + // WARNING: JavaScript can only go up to 32 bits! static const SemanticsFlag hasCheckedState = SemanticsFlag._( _kHasCheckedStateIndex, @@ -343,6 +344,8 @@ class LocaleStringAttribute extends StringAttribute { } } +enum SemanticsValidationResult { none, valid, invalid } + class SemanticsUpdateBuilder { SemanticsUpdateBuilder(); @@ -385,6 +388,7 @@ class SemanticsUpdateBuilder { String? linkUrl, SemanticsRole role = SemanticsRole.none, required List? controlsNodes, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, }) { if (transform.length != 16) { throw ArgumentError('transform argument must have 16 entries.'); @@ -428,6 +432,7 @@ class SemanticsUpdateBuilder { linkUrl: linkUrl, role: role, controlsNodes: controlsNodes, + validationResult: validationResult, ), ); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart index cecff590d5..e6034e1893 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart @@ -103,6 +103,11 @@ class SemanticIncrementable extends SemanticRole { /// tree should be updated. bool _pendingResync = false; + @override + void updateValidationResult() { + SemanticRole.updateAriaInvalid(_element, semanticsObject.validationResult); + } + @override void update() { super.update(); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index fd14c1ee60..8a5edc0800 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -246,6 +246,7 @@ class SemanticsNodeUpdate { this.linkUrl, required this.role, required this.controlsNodes, + required this.validationResult, }); /// See [ui.SemanticsUpdateBuilder.updateNode]. @@ -358,6 +359,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final List? controlsNodes; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final ui.SemanticsValidationResult validationResult; } /// Identifies [SemanticRole] implementations. @@ -722,6 +726,10 @@ abstract class SemanticRole { /// the object. @mustCallSuper void update() { + if (semanticsObject.isValidationResultDirty) { + updateValidationResult(); + } + final List? behaviors = _behaviors; if (behaviors == null) { return; @@ -767,6 +775,40 @@ abstract class SemanticRole { removeAttribute('aria-controls'); } + /// Applies the current [SemanticsObject.validationResult] to the DOM managed + /// by this role. + /// + /// The default implementation applies the `aria-invalid` attribute to the + /// root [SemanticsObject.element]. Specific role implementations may prefer + /// to apply it to different elements, depending on their use-case. For + /// example, a text field may want to apply it on the underlying `` + /// element. + void updateValidationResult() { + updateAriaInvalid(semanticsObject.element, semanticsObject.validationResult); + } + + /// Converts [validationResult] to its ARIA value and sets it as the `aria-invalid` + /// attribute of the given [element]. + /// + /// If [validationResult] is null, removes the `aria-invalid` attribute from + /// the element. + static void updateAriaInvalid(DomElement element, ui.SemanticsValidationResult validationResult) { + switch (validationResult) { + case ui.SemanticsValidationResult.none: + element.removeAttribute('aria-invalid'); + case ui.SemanticsValidationResult.valid: + // 'false' may seem counter-intuitive for a "valid" result, but it's + // because the ARIA attribute is `aria-invalid`, so its value is + // reversed. + element.setAttribute('aria-invalid', 'false'); + case ui.SemanticsValidationResult.invalid: + // 'true' may seem counter-intuitive for an "invalid" result, but it's + // because the ARIA attribute is `aria-invalid`, so its value is + // reversed. + element.setAttribute('aria-invalid', 'true'); + } + } + /// Whether this role was disposed of. bool get isDisposed => _isDisposed; bool _isDisposed = false; @@ -1353,6 +1395,18 @@ class SemanticsObject { _dirtyFields |= _linkUrlIndex; } + /// The result of validating a form field, if the form field is being + /// validated, and null otherwise. + ui.SemanticsValidationResult get validationResult => _validationResult; + ui.SemanticsValidationResult _validationResult = ui.SemanticsValidationResult.none; + + static const int _validationResultIndex = 1 << 27; + + bool get isValidationResultDirty => _isDirty(_validationResultIndex); + void _markValidationResultDirty() { + _dirtyFields |= _validationResultIndex; + } + /// A unique permanent identifier of the semantics node in the tree. final int id; @@ -1651,6 +1705,11 @@ class SemanticsObject { _markLinkUrlDirty(); } + if (_validationResult != update.validationResult) { + _validationResult = update.validationResult; + _markValidationResultDirty(); + } + role = update.role; if (!unorderedListEqual(controlsNodes, update.controlsNodes)) { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart index 8aedd26231..7bd9492984 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -213,6 +213,11 @@ class SemanticTextField extends SemanticRole { /// different from the host [element]. late final DomHTMLElement editableElement; + @override + void updateValidationResult() { + SemanticRole.updateAriaInvalid(editableElement, semanticsObject.validationResult); + } + @override bool focusAsRouteDefault() { editableElement.focusWithoutScroll(); diff --git a/engine/src/flutter/lib/web_ui/test/common/matchers.dart b/engine/src/flutter/lib/web_ui/test/common/matchers.dart index f94e305311..e7c8f82466 100644 --- a/engine/src/flutter/lib/web_ui/test/common/matchers.dart +++ b/engine/src/flutter/lib/web_ui/test/common/matchers.dart @@ -324,13 +324,19 @@ class HtmlPatternMatcher extends Matcher { html.Element pattern, ) { for (final MapEntry attribute in pattern.attributes.entries) { - final String expectedName = attribute.key as String; + final (expectedName, expectMissing) = _parseExpectedAttributeName(attribute.key as String); final String expectedValue = attribute.value; final _Breadcrumbs breadcrumb = parent.attribute(expectedName); if (expectedName == 'style') { // Style is a complex attribute that deserves a special comparison algorithm. _matchStyle(parent, mismatches, element, pattern); + } else if (expectMissing) { + if (element.attributes.containsKey(expectedName)) { + mismatches.add( + '$breadcrumb: expected attribute $expectedName="${element.attributes[expectedName]}" to be missing but it was present.', + ); + } } else { if (!element.attributes.containsKey(expectedName)) { mismatches.add('$breadcrumb: attribute $expectedName="$expectedValue" missing.'); @@ -347,6 +353,13 @@ class HtmlPatternMatcher extends Matcher { } } + (String name, bool expectMissing) _parseExpectedAttributeName(String attributeName) { + if (attributeName.endsWith('--missing')) { + return (attributeName.substring(0, attributeName.indexOf('--missing')), true); + } + return (attributeName, false); + } + static Map parseStyle(html.Element element) { final Map result = {}; diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index 32ae0a392e..152da7b9e7 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -145,6 +145,9 @@ void runSemanticsTests() { group('requirable', () { _testRequirable(); }); + group('SemanticsValidationResult', () { + _testSemanticsValidationResult(); + }); } void _testSemanticRole() { @@ -4763,6 +4766,58 @@ void _testRequirable() { }); } +void _testSemanticsValidationResult() { + test('renders validation result', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + children: [ + // This node does not validate its contents and should not have an + // aria-invalid attribute at all. + tester.updateNode(id: 1), + // This node is valid. aria-invalid should be "false". + tester.updateNode(id: 2, validationResult: ui.SemanticsValidationResult.valid), + // This node is invalid. aria-invalid should be "true". + tester.updateNode(id: 3, validationResult: ui.SemanticsValidationResult.invalid), + ], + ); + tester.apply(); + + tester.expectSemantics(''' + + + + +'''); + + // Shift all values, observe that the values changed accordingly + tester.updateNode( + id: 0, + children: [ + // This node is valid. aria-invalid should be "false". + tester.updateNode(id: 1, validationResult: ui.SemanticsValidationResult.valid), + // This node is invalid. aria-invalid should be "true". + tester.updateNode(id: 2, validationResult: ui.SemanticsValidationResult.invalid), + // This node does not validate its contents and should not have an + // aria-invalid attribute at all. + tester.updateNode(id: 3), + ], + ); + tester.apply(); + + tester.expectSemantics(''' + + + + +'''); + }); +} + /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that /// supplies default values for semantics attributes. void updateNode( diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart index fceba273d7..7eb6aa4ef6 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -121,6 +121,7 @@ class SemanticsTester { String? linkUrl, ui.SemanticsRole? role, List? controlsNodes, + ui.SemanticsValidationResult validationResult = ui.SemanticsValidationResult.none, }) { // Flags if (hasCheckedState ?? false) { @@ -343,6 +344,7 @@ class SemanticsTester { linkUrl: linkUrl, role: role ?? ui.SemanticsRole.none, controlsNodes: controlsNodes, + validationResult: validationResult, ); _nodeUpdates.add(update); return update; diff --git a/packages/flutter/lib/src/material/data_table.dart b/packages/flutter/lib/src/material/data_table.dart index 1d84ea23d4..a4302eb938 100644 --- a/packages/flutter/lib/src/material/data_table.dart +++ b/packages/flutter/lib/src/material/data_table.dart @@ -7,7 +7,6 @@ library; import 'dart:math' as math; -import 'dart:ui' show SemanticsRole; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index 09197214fd..03bf08d014 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -912,6 +912,9 @@ class RenderCustomPaint extends RenderProxyBox { final SemanticsProperties properties = newSemantics.properties; final SemanticsConfiguration config = SemanticsConfiguration(); + if (properties.role != null) { + config.role = properties.role!; + } if (properties.sortKey != null) { config.sortKey = properties.sortKey; } @@ -1017,6 +1020,9 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.textDirection != null) { config.textDirection = properties.textDirection; } + if (config.validationResult != properties.validationResult) { + config.validationResult = properties.validationResult; + } if (properties.onTap != null) { config.onTap = properties.onTap; } diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 2341e8563a..dc2d67d40c 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -4546,6 +4546,9 @@ class RenderSemanticsAnnotations extends RenderProxyBox { if (_properties.controlsNodes != null) { config.controlsNodes = _properties.controlsNodes; } + if (config.validationResult != _properties.validationResult) { + config.validationResult = _properties.validationResult; + } // Registering _perform* as action handlers instead of the user provided // ones to ensure that changing a user provided handler from a non-null to diff --git a/packages/flutter/lib/src/rendering/table.dart b/packages/flutter/lib/src/rendering/table.dart index 1af2ddbd6f..3e6a1a3512 100644 --- a/packages/flutter/lib/src/rendering/table.dart +++ b/packages/flutter/lib/src/rendering/table.dart @@ -4,7 +4,6 @@ import 'dart:collection'; import 'dart:math' as math; -import 'dart:ui' show SemanticsRole; import 'package:flutter/foundation.dart'; import 'package:flutter/semantics.dart'; diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 28984939ad..1cc89130c9 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -19,6 +19,7 @@ import 'dart:ui' SemanticsRole, SemanticsUpdate, SemanticsUpdateBuilder, + SemanticsValidationResult, StringAttribute, TextDirection; @@ -32,7 +33,16 @@ import 'binding.dart' show SemanticsBinding; import 'semantics_event.dart'; export 'dart:ui' - show Offset, Rect, SemanticsAction, SemanticsFlag, StringAttribute, TextDirection, VoidCallback; + show + Offset, + Rect, + SemanticsAction, + SemanticsFlag, + SemanticsRole, + SemanticsValidationResult, + StringAttribute, + TextDirection, + VoidCallback; export 'package:flutter/foundation.dart' show @@ -726,6 +736,7 @@ class SemanticsData with Diagnosticable { required this.linkUrl, required this.role, required this.controlsNodes, + required this.validationResult, this.tags, this.transform, this.customSemanticsActionIds, @@ -991,6 +1002,9 @@ class SemanticsData with Diagnosticable { /// {@macro flutter.semantics.SemanticsProperties.controlsNodes} final Set? controlsNodes; + /// {@macro flutter.semantics.SemanticsProperties.validationResult} + final SemanticsValidationResult validationResult; + /// Whether [flags] contains the given flag. bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0; @@ -1051,6 +1065,18 @@ class SemanticsData with Diagnosticable { if (controlsNodes != null) { properties.add(IterableProperty('controls', controlsNodes, ifEmpty: null)); } + if (role != SemanticsRole.none) { + properties.add(EnumProperty('role', role, defaultValue: SemanticsRole.none)); + } + if (validationResult != SemanticsValidationResult.none) { + properties.add( + EnumProperty( + 'validationResult', + validationResult, + defaultValue: SemanticsValidationResult.none, + ), + ); + } } @override @@ -1083,6 +1109,7 @@ class SemanticsData with Diagnosticable { other.headingLevel == headingLevel && other.linkUrl == linkUrl && other.role == role && + other.validationResult == validationResult && _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds) && setEquals(controlsNodes, other.controlsNodes); } @@ -1118,6 +1145,7 @@ class SemanticsData with Diagnosticable { linkUrl, customSemanticsActionIds == null ? null : Object.hashAll(customSemanticsActionIds!), role, + validationResult, controlsNodes == null ? null : Object.hashAll(controlsNodes!), ), ); @@ -1289,6 +1317,7 @@ class SemanticsProperties extends DiagnosticableTree { this.customSemanticsActions, this.role, this.controlsNodes, + this.validationResult = SemanticsValidationResult.none, }) : assert( label == null || attributedLabel == null, 'Only one of label or attributedLabel should be provided', @@ -2097,7 +2126,7 @@ class SemanticsProperties extends DiagnosticableTree { /// A enum to describe what role the subtree represents. /// /// Setting the role for a widget subtree helps assistive technologies, such - /// as screen readers, understand and interact with the UI correctly. + /// as screen readers, to understand and interact with the UI correctly. /// /// Defaults to [SemanticsRole.none] if not set, which means the subtree does /// not represent any complex ui or controls. @@ -2117,6 +2146,21 @@ class SemanticsProperties extends DiagnosticableTree { /// {@endtemplate} final Set? controlsNodes; + /// {@template flutter.semantics.SemanticsProperties.validationResult} + /// Describes the validation result for a form field represented by this + /// widget. + /// + /// Providing a validation result helps assistive technologies, such as screen + /// readers, to communicate to the user whether they provided correct + /// information in a form. + /// + /// Defaults to [SemanticsValidationResult.none] if not set, which means no + /// validation information is available for the respective semantics node. + /// + /// For a list of available validation results, see [SemanticsValidationResult]. + /// {@endtemplate} + final SemanticsValidationResult validationResult; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -2155,6 +2199,13 @@ class SemanticsProperties extends DiagnosticableTree { properties.add(StringProperty('tooltip', tooltip, defaultValue: null)); properties.add(EnumProperty('textDirection', textDirection, defaultValue: null)); properties.add(EnumProperty('role', role, defaultValue: null)); + properties.add( + EnumProperty( + 'validationResult', + validationResult, + defaultValue: SemanticsValidationResult.none, + ), + ); properties.add(DiagnosticsProperty('sortKey', sortKey, defaultValue: null)); properties.add( DiagnosticsProperty( @@ -2744,7 +2795,8 @@ class SemanticsNode with DiagnosticableTreeMixin { _areUserActionsBlocked != config.isBlockingUserActions || _headingLevel != config._headingLevel || _linkUrl != config._linkUrl || - _role != config.role; + _role != config.role || + _validationResult != config.validationResult; } // TAGS, LABELS, ACTIONS @@ -3083,6 +3135,10 @@ class SemanticsNode with DiagnosticableTreeMixin { Set? get controlsNodes => _controlsNodes; Set? _controlsNodes = _kEmptyConfig.controlsNodes; + /// {@macro flutter.semantics.SemanticsProperties.validationResult} + SemanticsValidationResult get validationResult => _validationResult; + SemanticsValidationResult _validationResult = _kEmptyConfig.validationResult; + bool _canPerformAction(SemanticsAction action) => _actions.containsKey(action); static final SemanticsConfiguration _kEmptyConfig = SemanticsConfiguration(); @@ -3150,6 +3206,7 @@ class SemanticsNode with DiagnosticableTreeMixin { _linkUrl = config._linkUrl; _role = config._role; _controlsNodes = config._controlsNodes; + _validationResult = config._validationResult; _replaceChildren(childrenInInversePaintOrder ?? const []); if (mergeAllDescendantsIntoThisNodeValueChanged) { @@ -3200,6 +3257,7 @@ class SemanticsNode with DiagnosticableTreeMixin { Uri? linkUrl = _linkUrl; SemanticsRole role = _role; Set? controlsNodes = _controlsNodes; + SemanticsValidationResult validationResult = _validationResult; final Set customSemanticsActionIds = {}; for (final CustomSemanticsAction action in _customSemanticsActions.keys) { customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action)); @@ -3305,6 +3363,17 @@ class SemanticsNode with DiagnosticableTreeMixin { controlsNodes = {...controlsNodes!, ...node._controlsNodes!}; } + if (validationResult == SemanticsValidationResult.none) { + validationResult = node._validationResult; + } else if (validationResult == SemanticsValidationResult.valid) { + // When merging nodes, invalid validation result takes precedence. + // Otherwise, validation information could be lost. + if (node._validationResult != SemanticsValidationResult.none && + node._validationResult != SemanticsValidationResult.valid) { + validationResult = node._validationResult; + } + } + return true; }); } @@ -3339,6 +3408,7 @@ class SemanticsNode with DiagnosticableTreeMixin { linkUrl: linkUrl, role: role, controlsNodes: controlsNodes, + validationResult: validationResult, ); } @@ -3425,6 +3495,7 @@ class SemanticsNode with DiagnosticableTreeMixin { linkUrl: data.linkUrl?.toString() ?? '', role: data.role, controlsNodes: data.controlsNodes?.toList(), + validationResult: data.validationResult, ); _dirty = false; } @@ -5595,6 +5666,14 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } + /// {@macro flutter.semantics.SemanticsProperties.validationResult} + SemanticsValidationResult get validationResult => _validationResult; + SemanticsValidationResult _validationResult = SemanticsValidationResult.none; + set validationResult(SemanticsValidationResult value) { + _validationResult = value; + _hasBeenAnnotated = true; + } + // TAGS /// The set of tags that this configuration wants to add to all child @@ -5785,6 +5864,15 @@ class SemanticsConfiguration { _controlsNodes = {..._controlsNodes!, ...child._controlsNodes!}; } + if (child._validationResult != _validationResult) { + if (child._validationResult == SemanticsValidationResult.invalid) { + // Invalid result always takes precedence. + _validationResult = SemanticsValidationResult.invalid; + } else if (_validationResult == SemanticsValidationResult.none) { + _validationResult = child._validationResult; + } + } + _hasBeenAnnotated = hasBeenAnnotated || child.hasBeenAnnotated; } @@ -5827,7 +5915,8 @@ class SemanticsConfiguration { .._headingLevel = _headingLevel .._linkUrl = _linkUrl .._role = _role - .._controlsNodes = _controlsNodes; + .._controlsNodes = _controlsNodes + .._validationResult = _validationResult; } } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 040e7c7290..2fd93488ff 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -9,7 +9,7 @@ library; import 'dart:math' as math; -import 'dart:ui' as ui show Image, ImageFilter, SemanticsRole, TextHeightBehavior; +import 'dart:ui' as ui show Image, ImageFilter, TextHeightBehavior; import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; @@ -7382,8 +7382,9 @@ class Semantics extends SingleChildRenderObjectWidget { VoidCallback? onDidLoseAccessibilityFocus, VoidCallback? onFocus, Map? customSemanticsActions, - ui.SemanticsRole? role, + SemanticsRole? role, Set? controlsNodes, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, }) : this.fromProperties( key: key, child: child, @@ -7461,6 +7462,7 @@ class Semantics extends SingleChildRenderObjectWidget { : null, role: role, controlsNodes: controlsNodes, + validationResult: validationResult, ), ); diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index b915a9f8af..a0f28e8798 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -769,6 +769,12 @@ class FormFieldState extends State> with RestorationMixin { Form.maybeOf(context)?._register(this); + final Widget child = Semantics( + validationResult: + hasError ? SemanticsValidationResult.invalid : SemanticsValidationResult.valid, + child: widget.builder(this), + ); + if (Form.maybeOf(context)?.widget.autovalidateMode == AutovalidateMode.onUnfocus && widget.autovalidateMode != AutovalidateMode.always || widget.autovalidateMode == AutovalidateMode.onUnfocus) { @@ -783,11 +789,11 @@ class FormFieldState extends State> with RestorationMixin { } }, focusNode: _focusNode, - child: widget.builder(this), + child: child, ); } - return widget.builder(this); + return child; } } diff --git a/packages/flutter/lib/src/widgets/table.dart b/packages/flutter/lib/src/widgets/table.dart index a2cc5a8944..7fb371318a 100644 --- a/packages/flutter/lib/src/widgets/table.dart +++ b/packages/flutter/lib/src/widgets/table.dart @@ -7,7 +7,6 @@ library; import 'dart:collection'; -import 'dart:ui' show SemanticsRole; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; diff --git a/packages/flutter/test/material/data_table_test.dart b/packages/flutter/test/material/data_table_test.dart index 6316f17614..e09a817b05 100644 --- a/packages/flutter/test/material/data_table_test.dart +++ b/packages/flutter/test/material/data_table_test.dart @@ -6,7 +6,6 @@ library; import 'dart:math' as math; -import 'dart:ui' show SemanticsRole; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index d2de44a065..f500a132ea 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -1823,6 +1823,7 @@ void main() { hasPasteAction: true, hasMoveCursorBackwardByCharacterAction: true, hasMoveCursorBackwardByWordAction: true, + validationResult: SemanticsValidationResult.valid, ), ); diff --git a/packages/flutter/test/material/input_date_picker_form_field_test.dart b/packages/flutter/test/material/input_date_picker_form_field_test.dart index b57de89ad4..eb88af43fd 100644 --- a/packages/flutter/test/material/input_date_picker_form_field_test.dart +++ b/packages/flutter/test/material/input_date_picker_form_field_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -306,6 +307,7 @@ void main() { hasPasteAction: true, hasMoveCursorBackwardByCharacterAction: true, hasMoveCursorBackwardByWordAction: true, + validationResult: SemanticsValidationResult.valid, ), ); semantics.dispose(); diff --git a/packages/flutter/test/semantics/semantics_update_test.dart b/packages/flutter/test/semantics/semantics_update_test.dart index 1a139c105f..4ec0923e40 100644 --- a/packages/flutter/test/semantics/semantics_update_test.dart +++ b/packages/flutter/test/semantics/semantics_update_test.dart @@ -228,8 +228,9 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde required Int32List additionalActions, int headingLevel = 0, String? linkUrl, - ui.SemanticsRole role = ui.SemanticsRole.none, + SemanticsRole role = SemanticsRole.none, required List? controlsNodes, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, }) { // Makes sure we don't send the same id twice. assert(!observations.containsKey(id)); diff --git a/packages/flutter/test/widgets/custom_painter_test.dart b/packages/flutter/test/widgets/custom_painter_test.dart index 146c174cdb..7fe3f0ef15 100644 --- a/packages/flutter/test/widgets/custom_painter_test.dart +++ b/packages/flutter/test/widgets/custom_painter_test.dart @@ -288,6 +288,94 @@ void _defineTests() { semanticsTester.dispose(); }); + testWidgets('provides semantic role', (WidgetTester tester) async { + final SemanticsTester semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + CustomPaint( + foregroundPainter: _PainterWithSemantics( + semantics: const CustomPainterSemantics( + rect: Rect.fromLTRB(1.0, 1.0, 2.0, 2.0), + properties: SemanticsProperties( + role: SemanticsRole.table, + label: 'this is a table', + textDirection: TextDirection.rtl, + ), + ), + ), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: [ + TestSemantics( + id: 2, + role: SemanticsRole.table, + label: 'this is a table', + rect: const Rect.fromLTRB(1.0, 1.0, 2.0, 2.0), + ), + ], + ), + ], + ), + ), + ); + + semanticsTester.dispose(); + }); + + testWidgets('provides semantic validation result', (WidgetTester tester) async { + final SemanticsTester semanticsTester = SemanticsTester(tester); + + await tester.pumpWidget( + CustomPaint( + foregroundPainter: _PainterWithSemantics( + semantics: const CustomPainterSemantics( + rect: Rect.fromLTRB(1.0, 1.0, 2.0, 2.0), + properties: SemanticsProperties( + textField: true, + label: 'email address', + textDirection: TextDirection.ltr, + validationResult: SemanticsValidationResult.invalid, + ), + ), + ), + ), + ); + + expect( + semanticsTester, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: [ + TestSemantics( + id: 2, + flags: [SemanticsFlag.isTextField], + label: 'email address', + validationResult: SemanticsValidationResult.invalid, + rect: const Rect.fromLTRB(1.0, 1.0, 2.0, 2.0), + ), + ], + ), + ], + ), + ), + ); + + semanticsTester.dispose(); + }); + testWidgets('Can toggle semantics on, off, on without crash', (WidgetTester tester) async { await tester.pumpWidget( CustomPaint( diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index d3de1dc08d..3a47aaea98 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -1539,4 +1539,53 @@ void main() { expect(find.text('foo/error'), findsOneWidget); expect(find.text('bar/error'), findsOneWidget); }); + + testWidgets('FormField adds validation result to the semantics of the child', ( + WidgetTester tester, + ) async { + final GlobalKey formKey = GlobalKey(); + + String? errorText; + + Future pumpWidget() async { + formKey.currentState?.reset(); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + autovalidateMode: AutovalidateMode.always, + child: TextFormField(validator: (String? value) => errorText), + ), + ), + ), + ), + ), + ), + ); + + await tester.enterText(find.byType(TextFormField), 'Hello'); + await tester.pump(); + } + + // Test valid case + await pumpWidget(); + expect( + tester.getSemantics(find.byType(TextFormField).last), + containsSemantics(isTextField: true, validationResult: SemanticsValidationResult.valid), + ); + + // Test invalid case + errorText = 'Error'; + await pumpWidget(); + expect( + tester.getSemantics(find.byType(TextFormField).last), + containsSemantics(isTextField: true, validationResult: SemanticsValidationResult.invalid), + ); + }); } diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index fe78f45866..225050bead 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -1698,6 +1698,127 @@ void main() { semantics.dispose(); }); + + testWidgets('RenderSemanticsAnnotations provides validation result', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + Future pumpValidationResult(SemanticsValidationResult result) async { + final ValueKey key = ValueKey('validation-$result'); + await tester.pumpWidget( + Semantics( + key: key, + validationResult: result, + child: Text('Validation result $result', textDirection: TextDirection.ltr), + ), + ); + final RenderSemanticsAnnotations object = tester.renderObject( + find.byKey(key), + ); + final SemanticsConfiguration config = SemanticsConfiguration(); + object.describeSemanticsConfiguration(config); + return config; + } + + final SemanticsConfiguration noneResult = await pumpValidationResult( + SemanticsValidationResult.none, + ); + expect(noneResult.validationResult, SemanticsValidationResult.none); + + final SemanticsConfiguration validResult = await pumpValidationResult( + SemanticsValidationResult.valid, + ); + expect(validResult.validationResult, SemanticsValidationResult.valid); + + final SemanticsConfiguration invalidResult = await pumpValidationResult( + SemanticsValidationResult.invalid, + ); + expect(invalidResult.validationResult, SemanticsValidationResult.invalid); + + semantics.dispose(); + }); + + testWidgets('validation result precedence', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + Future expectValidationResult({ + required SemanticsValidationResult outer, + required SemanticsValidationResult inner, + required SemanticsValidationResult expected, + }) async { + const ValueKey key = ValueKey('validated-widget'); + await tester.pumpWidget( + Semantics( + validationResult: outer, + child: Semantics( + validationResult: inner, + child: Text( + key: key, + 'Outer = $outer; inner = $inner', + textDirection: TextDirection.ltr, + ), + ), + ), + ); + final SemanticsNode result = tester.getSemantics(find.byKey(key)); + expect( + result, + containsSemantics(label: 'Outer = $outer; inner = $inner', validationResult: expected), + ); + } + + // Outer is none + await expectValidationResult( + outer: SemanticsValidationResult.none, + inner: SemanticsValidationResult.none, + expected: SemanticsValidationResult.none, + ); + await expectValidationResult( + outer: SemanticsValidationResult.none, + inner: SemanticsValidationResult.valid, + expected: SemanticsValidationResult.valid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.none, + inner: SemanticsValidationResult.invalid, + expected: SemanticsValidationResult.invalid, + ); + + // Outer is valid + await expectValidationResult( + outer: SemanticsValidationResult.valid, + inner: SemanticsValidationResult.none, + expected: SemanticsValidationResult.valid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.valid, + inner: SemanticsValidationResult.valid, + expected: SemanticsValidationResult.valid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.valid, + inner: SemanticsValidationResult.invalid, + expected: SemanticsValidationResult.invalid, + ); + + // Outer is invalid + await expectValidationResult( + outer: SemanticsValidationResult.invalid, + inner: SemanticsValidationResult.none, + expected: SemanticsValidationResult.invalid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.invalid, + inner: SemanticsValidationResult.valid, + expected: SemanticsValidationResult.invalid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.invalid, + inner: SemanticsValidationResult.invalid, + expected: SemanticsValidationResult.invalid, + ); + + semantics.dispose(); + }); } class CustomSortKey extends OrdinalSortKey { diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index 2d0d0cbbe2..1c6eefd5d2 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui'; - import 'package:flutter/foundation.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; @@ -56,6 +54,7 @@ class TestSemantics { this.scrollChildren, Iterable? tags, this.role = SemanticsRole.none, + this.validationResult = SemanticsValidationResult.none, }) : assert(flags is int || flags is List), assert(actions is int || actions is List), tags = tags?.toSet() ?? {}; @@ -80,6 +79,7 @@ class TestSemantics { this.scrollChildren, Iterable? tags, this.role = SemanticsRole.none, + this.validationResult = SemanticsValidationResult.none, }) : id = 0, assert(flags is int || flags is List), assert(actions is int || actions is List), @@ -120,6 +120,7 @@ class TestSemantics { this.scrollChildren, Iterable? tags, this.role = SemanticsRole.none, + this.validationResult = SemanticsValidationResult.none, }) : assert(flags is int || flags is List), assert(actions is int || actions is List), transform = _applyRootChildScale(transform), @@ -232,6 +233,14 @@ class TestSemantics { final TextSelection? textSelection; + /// The validation result for this node, if any. + /// + /// See also: + /// + /// * [SemanticsValidationResult], which is the enum listing possible values + /// for this field. + final SemanticsValidationResult validationResult; + static Matrix4 _applyRootChildScale(Matrix4? transform) { final Matrix4 result = Matrix4.diagonal3Values(3.0, 3.0, 1.0); if (transform != null) { @@ -392,6 +401,12 @@ class TestSemantics { return fail('expected node id $id to have role $role but found role ${node.role}'); } + if (validationResult != node.validationResult) { + return fail( + 'expected node id $id to have validationResult $validationResult but found validationResult ${node.validationResult}', + ); + } + if (children.isEmpty) { return true; } diff --git a/packages/flutter/test/widgets/table_test.dart b/packages/flutter/test/widgets/table_test.dart index bcb94332a9..98e61b4d50 100644 --- a/packages/flutter/test/widgets/table_test.dart +++ b/packages/flutter/test/widgets/table_test.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui' show SemanticsRole; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 4372458165..558a49a063 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -685,6 +685,7 @@ Matcher matchesSemantics({ int? platformViewId, int? maxValueLength, int? currentValueLength, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, // Flags // bool hasCheckedState = false, bool isChecked = false, @@ -768,6 +769,7 @@ Matcher matchesSemantics({ customActions: customActions, maxValueLength: maxValueLength, currentValueLength: currentValueLength, + validationResult: validationResult, // Flags hasCheckedState: hasCheckedState, isChecked: isChecked, @@ -879,6 +881,7 @@ Matcher containsSemantics({ int? platformViewId, int? maxValueLength, int? currentValueLength, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, // Flags bool? hasCheckedState, bool? isChecked, @@ -962,6 +965,7 @@ Matcher containsSemantics({ customActions: customActions, maxValueLength: maxValueLength, currentValueLength: currentValueLength, + validationResult: validationResult, // Flags hasCheckedState: hasCheckedState, isChecked: isChecked, @@ -2398,6 +2402,7 @@ class _MatchesSemanticsData extends Matcher { required this.platformViewId, required this.maxValueLength, required this.currentValueLength, + required this.validationResult, // Flags required bool? hasCheckedState, required bool? isChecked, @@ -2552,6 +2557,7 @@ class _MatchesSemanticsData extends Matcher { final int? maxValueLength; final int? currentValueLength; final List? children; + final SemanticsValidationResult validationResult; /// There are three possible states for these two maps: /// @@ -2665,6 +2671,9 @@ class _MatchesSemanticsData extends Matcher { if (hintOverrides != null) { description.add(' with custom hints: $hintOverrides'); } + if (validationResult != SemanticsValidationResult.none) { + description.add(' with validation result: $validationResult'); + } if (children != null) { description.add(' with children:\n '); final List<_MatchesSemanticsData> childMatches = children!.cast<_MatchesSemanticsData>(); @@ -2801,6 +2810,9 @@ class _MatchesSemanticsData extends Matcher { if (maxValueLength != null && maxValueLength != data.maxValueLength) { return failWithDescription(matchState, 'maxValueLength was: ${data.maxValueLength}'); } + if (validationResult != data.validationResult) { + return failWithDescription(matchState, 'validationResult was: ${data.validationResult}'); + } if (actions.isNotEmpty) { final List unexpectedActions = []; final List missingActions = []; diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 8aba5e9ff4..35b16b59d9 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -731,6 +731,7 @@ void main() { linkUrl: Uri(path: 'l'), role: ui.SemanticsRole.none, controlsNodes: null, + validationResult: SemanticsValidationResult.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1034,6 +1035,7 @@ void main() { linkUrl: Uri(path: 'l'), role: ui.SemanticsRole.none, controlsNodes: null, + validationResult: SemanticsValidationResult.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1133,6 +1135,7 @@ void main() { linkUrl: null, role: ui.SemanticsRole.none, controlsNodes: null, + validationResult: SemanticsValidationResult.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1239,6 +1242,7 @@ void main() { linkUrl: null, role: ui.SemanticsRole.none, controlsNodes: null, + validationResult: SemanticsValidationResult.none, ); final _FakeSemanticsNode emptyNode = _FakeSemanticsNode(emptyData); @@ -1271,6 +1275,7 @@ void main() { linkUrl: Uri(path: 'l'), role: ui.SemanticsRole.none, controlsNodes: null, + validationResult: SemanticsValidationResult.none, ); final _FakeSemanticsNode fullNode = _FakeSemanticsNode(fullData); @@ -1359,6 +1364,7 @@ void main() { linkUrl: null, role: ui.SemanticsRole.none, controlsNodes: null, + validationResult: SemanticsValidationResult.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data);