Skip to content

Commit fbbe0f9

Browse files
authored
[a11y] add SemanticsValidationResult (#165935)
Add `SemanticsValidationResult` to semantics that maps onto `aria-invalid`. Fixes flutter/flutter#162142
1 parent 10d2631 commit fbbe0f9

30 files changed

+620
-23
lines changed

engine/src/flutter/lib/ui/semantics.dart

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ class SemanticsAction {
4646
static const int _kSetTextIndex = 1 << 21;
4747
static const int _kFocusIndex = 1 << 22;
4848
static const int _kScrollToOffsetIndex = 1 << 23;
49-
// READ THIS: if you add an action here, you MUST update the
50-
// numSemanticsActions value in testing/dart/semantics_test.dart and
51-
// lib/web_ui/test/engine/semantics/semantics_api_test.dart, or tests
52-
// will fail.
49+
// READ THIS:
50+
// - The maximum supported bit index on the web (in JS mode) is 1 << 31.
51+
// - If you add an action here, you MUST update the numSemanticsActions value
52+
// in testing/dart/semantics_test.dart and
53+
// lib/web_ui/test/engine/semantics/semantics_api_test.dart, or tests will
54+
// fail.
5355

5456
/// The equivalent of a user briefly tapping the screen with the finger
5557
/// without moving it.
@@ -555,6 +557,7 @@ class SemanticsFlag {
555557
static const int _kIsRequiredIndex = 1 << 30;
556558
// READ THIS: if you add a flag here, you MUST update the following:
557559
//
560+
// - The maximum supported bit index on the web (in JS mode) is 1 << 31.
558561
// - Add an appropriately named and documented `static const SemanticsFlag`
559562
// field to this class.
560563
// - Add the new flag to `_kFlagById` in this file.
@@ -936,6 +939,30 @@ class SemanticsFlag {
936939
String toString() => 'SemanticsFlag.$name';
937940
}
938941

942+
/// The validation result of a form field.
943+
///
944+
/// The type, shape, and correctness of the value is specific to the kind of
945+
/// form field used. For example, a phone number text field may check that the
946+
/// value is a properly formatted phone number, and/or that the phone number has
947+
/// the right area code. A group of radio buttons may validate that the user
948+
/// selected at least one radio option.
949+
enum SemanticsValidationResult {
950+
/// The node has no validation information attached to it.
951+
///
952+
/// This is the default value. Most semantics nodes do not contain validation
953+
/// information. Typically, only nodes that are part of an input form - text
954+
/// fields, checkboxes, radio buttons, dropdowns - are validated and attach
955+
/// validation results to their corresponding semantics nodes.
956+
none,
957+
958+
/// The entered value is valid, and no error should be displayed to the user.
959+
valid,
960+
961+
/// The entered value is invalid, and an error message should be communicated
962+
/// to the user.
963+
invalid,
964+
}
965+
939966
// When adding a new StringAttribute, the classes in these files must be
940967
// updated as well.
941968
// * engine/src/flutter/lib/web_ui/lib/semantics.dart
@@ -1154,10 +1181,18 @@ abstract class SemanticsUpdateBuilder {
11541181
/// The `role` describes the role of this node. Defaults to
11551182
/// [SemanticsRole.none] if not set.
11561183
///
1184+
/// If `validationResult` is not null, indicates the result of validating a
1185+
/// form field. If null, indicates that the node is not being validated, or
1186+
/// that the result is unknown. Form fields that validate user input but do
1187+
/// not use this argument should use other ways to communicate validation
1188+
/// errors to the user, such as embedding validation error text in the label.
1189+
///
11571190
/// See also:
11581191
///
11591192
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role
11601193
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-level
1194+
/// * [SemanticsValidationResult], that describes possible values for the
1195+
/// `validationResult` argument.
11611196
void updateNode({
11621197
required int id,
11631198
required int flags,
@@ -1196,6 +1231,7 @@ abstract class SemanticsUpdateBuilder {
11961231
String linkUrl = '',
11971232
SemanticsRole role = SemanticsRole.none,
11981233
required List<String>? controlsNodes,
1234+
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
11991235
});
12001236

12011237
/// Update the custom semantics action associated with the given `id`.
@@ -1273,6 +1309,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
12731309
String linkUrl = '',
12741310
SemanticsRole role = SemanticsRole.none,
12751311
required List<String>? controlsNodes,
1312+
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
12761313
}) {
12771314
assert(_matrix4IsValid(transform));
12781315
assert(
@@ -1320,6 +1357,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
13201357
linkUrl,
13211358
role.index,
13221359
controlsNodes,
1360+
validationResult.index,
13231361
);
13241362
}
13251363

@@ -1366,6 +1404,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
13661404
Handle,
13671405
Int32,
13681406
Handle,
1407+
Int32,
13691408
)
13701409
>(symbol: 'SemanticsUpdateBuilder::updateNode')
13711410
external void _updateNode(
@@ -1409,6 +1448,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
14091448
String linkUrl,
14101449
int role,
14111450
List<String>? controlsNodes,
1451+
int validationResultIndex,
14121452
);
14131453

14141454
@override

engine/src/flutter/lib/ui/semantics/semantics_node.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ enum class SemanticsRole : int32_t {
100100
kAlert = 28,
101101
};
102102

103+
/// C/C++ representation of `SemanticsValidationResult` defined in
104+
/// `lib/ui/semantics.dart`.
105+
///\warning This must match the `SemanticsValidationResult` enum in
106+
/// `lib/ui/semantics.dart`.
107+
/// See also:
108+
/// - file://./../../../lib/ui/semantics.dart
109+
enum class SemanticsValidationResult : int32_t {
110+
kNone = 0,
111+
kValid = 1,
112+
kInvalid = 2,
113+
};
114+
103115
/// C/C++ representation of `SemanticsFlags` defined in
104116
/// `lib/ui/semantics.dart`.
105117
///\warning This must match the `SemanticsFlags` enum in
@@ -194,6 +206,7 @@ struct SemanticsNode {
194206

195207
std::string linkUrl;
196208
SemanticsRole role;
209+
SemanticsValidationResult validationResult = SemanticsValidationResult::kNone;
197210
};
198211

199212
// Contains semantic nodes that need to be updated.

engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ void SemanticsUpdateBuilder::updateNode(
7070
int headingLevel,
7171
std::string linkUrl,
7272
int role,
73-
const std::vector<std::string>& controlsNodes) {
73+
const std::vector<std::string>& controlsNodes,
74+
int validationResult) {
7475
FML_CHECK(scrollChildren == 0 ||
7576
(scrollChildren > 0 && childrenInHitTestOrder.data()))
7677
<< "Semantics update contained scrollChildren but did not have "
@@ -124,6 +125,8 @@ void SemanticsUpdateBuilder::updateNode(
124125
node.headingLevel = headingLevel;
125126
node.linkUrl = std::move(linkUrl);
126127
node.role = static_cast<SemanticsRole>(role);
128+
node.validationResult =
129+
static_cast<SemanticsValidationResult>(validationResult);
127130

128131
nodes_[id] = node;
129132
}

engine/src/flutter/lib/ui/semantics/semantics_update_builder.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ class SemanticsUpdateBuilder
6969
int headingLevel,
7070
std::string linkUrl,
7171
int role,
72-
const std::vector<std::string>& controlsNodes);
72+
const std::vector<std::string>& controlsNodes,
73+
int validationResult);
7374

7475
void updateCustomAction(int id,
7576
std::string label,

engine/src/flutter/lib/web_ui/lib/semantics.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ class SemanticsFlag {
162162
static const int _kHasSelectedStateIndex = 1 << 28;
163163
static const int _kHasRequiredStateIndex = 1 << 29;
164164
static const int _kIsRequiredIndex = 1 << 30;
165+
// WARNING: JavaScript can only go up to 32 bits!
165166

166167
static const SemanticsFlag hasCheckedState = SemanticsFlag._(
167168
_kHasCheckedStateIndex,
@@ -343,6 +344,8 @@ class LocaleStringAttribute extends StringAttribute {
343344
}
344345
}
345346

347+
enum SemanticsValidationResult { none, valid, invalid }
348+
346349
class SemanticsUpdateBuilder {
347350
SemanticsUpdateBuilder();
348351

@@ -385,6 +388,7 @@ class SemanticsUpdateBuilder {
385388
String? linkUrl,
386389
SemanticsRole role = SemanticsRole.none,
387390
required List<String>? controlsNodes,
391+
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
388392
}) {
389393
if (transform.length != 16) {
390394
throw ArgumentError('transform argument must have 16 entries.');
@@ -428,6 +432,7 @@ class SemanticsUpdateBuilder {
428432
linkUrl: linkUrl,
429433
role: role,
430434
controlsNodes: controlsNodes,
435+
validationResult: validationResult,
431436
),
432437
);
433438
}

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ class SemanticIncrementable extends SemanticRole {
103103
/// tree should be updated.
104104
bool _pendingResync = false;
105105

106+
@override
107+
void updateValidationResult() {
108+
SemanticRole.updateAriaInvalid(_element, semanticsObject.validationResult);
109+
}
110+
106111
@override
107112
void update() {
108113
super.update();

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ class SemanticsNodeUpdate {
246246
this.linkUrl,
247247
required this.role,
248248
required this.controlsNodes,
249+
required this.validationResult,
249250
});
250251

251252
/// See [ui.SemanticsUpdateBuilder.updateNode].
@@ -358,6 +359,9 @@ class SemanticsNodeUpdate {
358359

359360
/// See [ui.SemanticsUpdateBuilder.updateNode].
360361
final List<String>? controlsNodes;
362+
363+
/// See [ui.SemanticsUpdateBuilder.updateNode].
364+
final ui.SemanticsValidationResult validationResult;
361365
}
362366

363367
/// Identifies [SemanticRole] implementations.
@@ -722,6 +726,10 @@ abstract class SemanticRole {
722726
/// the object.
723727
@mustCallSuper
724728
void update() {
729+
if (semanticsObject.isValidationResultDirty) {
730+
updateValidationResult();
731+
}
732+
725733
final List<SemanticBehavior>? behaviors = _behaviors;
726734
if (behaviors == null) {
727735
return;
@@ -767,6 +775,40 @@ abstract class SemanticRole {
767775
removeAttribute('aria-controls');
768776
}
769777

778+
/// Applies the current [SemanticsObject.validationResult] to the DOM managed
779+
/// by this role.
780+
///
781+
/// The default implementation applies the `aria-invalid` attribute to the
782+
/// root [SemanticsObject.element]. Specific role implementations may prefer
783+
/// to apply it to different elements, depending on their use-case. For
784+
/// example, a text field may want to apply it on the underlying `<input>`
785+
/// element.
786+
void updateValidationResult() {
787+
updateAriaInvalid(semanticsObject.element, semanticsObject.validationResult);
788+
}
789+
790+
/// Converts [validationResult] to its ARIA value and sets it as the `aria-invalid`
791+
/// attribute of the given [element].
792+
///
793+
/// If [validationResult] is null, removes the `aria-invalid` attribute from
794+
/// the element.
795+
static void updateAriaInvalid(DomElement element, ui.SemanticsValidationResult validationResult) {
796+
switch (validationResult) {
797+
case ui.SemanticsValidationResult.none:
798+
element.removeAttribute('aria-invalid');
799+
case ui.SemanticsValidationResult.valid:
800+
// 'false' may seem counter-intuitive for a "valid" result, but it's
801+
// because the ARIA attribute is `aria-invalid`, so its value is
802+
// reversed.
803+
element.setAttribute('aria-invalid', 'false');
804+
case ui.SemanticsValidationResult.invalid:
805+
// 'true' may seem counter-intuitive for an "invalid" result, but it's
806+
// because the ARIA attribute is `aria-invalid`, so its value is
807+
// reversed.
808+
element.setAttribute('aria-invalid', 'true');
809+
}
810+
}
811+
770812
/// Whether this role was disposed of.
771813
bool get isDisposed => _isDisposed;
772814
bool _isDisposed = false;
@@ -1353,6 +1395,18 @@ class SemanticsObject {
13531395
_dirtyFields |= _linkUrlIndex;
13541396
}
13551397

1398+
/// The result of validating a form field, if the form field is being
1399+
/// validated, and null otherwise.
1400+
ui.SemanticsValidationResult get validationResult => _validationResult;
1401+
ui.SemanticsValidationResult _validationResult = ui.SemanticsValidationResult.none;
1402+
1403+
static const int _validationResultIndex = 1 << 27;
1404+
1405+
bool get isValidationResultDirty => _isDirty(_validationResultIndex);
1406+
void _markValidationResultDirty() {
1407+
_dirtyFields |= _validationResultIndex;
1408+
}
1409+
13561410
/// A unique permanent identifier of the semantics node in the tree.
13571411
final int id;
13581412

@@ -1651,6 +1705,11 @@ class SemanticsObject {
16511705
_markLinkUrlDirty();
16521706
}
16531707

1708+
if (_validationResult != update.validationResult) {
1709+
_validationResult = update.validationResult;
1710+
_markValidationResultDirty();
1711+
}
1712+
16541713
role = update.role;
16551714

16561715
if (!unorderedListEqual<String>(controlsNodes, update.controlsNodes)) {

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ class SemanticTextField extends SemanticRole {
213213
/// different from the host [element].
214214
late final DomHTMLElement editableElement;
215215

216+
@override
217+
void updateValidationResult() {
218+
SemanticRole.updateAriaInvalid(editableElement, semanticsObject.validationResult);
219+
}
220+
216221
@override
217222
bool focusAsRouteDefault() {
218223
editableElement.focusWithoutScroll();

engine/src/flutter/lib/web_ui/test/common/matchers.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,13 +324,19 @@ class HtmlPatternMatcher extends Matcher {
324324
html.Element pattern,
325325
) {
326326
for (final MapEntry<Object, String> attribute in pattern.attributes.entries) {
327-
final String expectedName = attribute.key as String;
327+
final (expectedName, expectMissing) = _parseExpectedAttributeName(attribute.key as String);
328328
final String expectedValue = attribute.value;
329329
final _Breadcrumbs breadcrumb = parent.attribute(expectedName);
330330

331331
if (expectedName == 'style') {
332332
// Style is a complex attribute that deserves a special comparison algorithm.
333333
_matchStyle(parent, mismatches, element, pattern);
334+
} else if (expectMissing) {
335+
if (element.attributes.containsKey(expectedName)) {
336+
mismatches.add(
337+
'$breadcrumb: expected attribute $expectedName="${element.attributes[expectedName]}" to be missing but it was present.',
338+
);
339+
}
334340
} else {
335341
if (!element.attributes.containsKey(expectedName)) {
336342
mismatches.add('$breadcrumb: attribute $expectedName="$expectedValue" missing.');
@@ -347,6 +353,13 @@ class HtmlPatternMatcher extends Matcher {
347353
}
348354
}
349355

356+
(String name, bool expectMissing) _parseExpectedAttributeName(String attributeName) {
357+
if (attributeName.endsWith('--missing')) {
358+
return (attributeName.substring(0, attributeName.indexOf('--missing')), true);
359+
}
360+
return (attributeName, false);
361+
}
362+
350363
static Map<String, String> parseStyle(html.Element element) {
351364
final Map<String, String> result = <String, String>{};
352365

0 commit comments

Comments
 (0)