[a11y] add SemanticsValidationResult (#165935)

Add `SemanticsValidationResult` to semantics that maps onto
`aria-invalid`.

Fixes https://github.com/flutter/flutter/issues/162142
This commit is contained in:
Yegor
2025-04-02 15:00:19 -07:00
committed by GitHub
parent 10d2631548
commit fbbe0f9e7a
30 changed files with 620 additions and 23 deletions

View File

@@ -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<String>? 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<String>? 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<String>? controlsNodes,
int validationResultIndex,
);
@override

View File

@@ -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.

View File

@@ -70,7 +70,8 @@ void SemanticsUpdateBuilder::updateNode(
int headingLevel,
std::string linkUrl,
int role,
const std::vector<std::string>& controlsNodes) {
const std::vector<std::string>& 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<SemanticsRole>(role);
node.validationResult =
static_cast<SemanticsValidationResult>(validationResult);
nodes_[id] = node;
}

View File

@@ -69,7 +69,8 @@ class SemanticsUpdateBuilder
int headingLevel,
std::string linkUrl,
int role,
const std::vector<std::string>& controlsNodes);
const std::vector<std::string>& controlsNodes,
int validationResult);
void updateCustomAction(int id,
std::string label,

View File

@@ -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<String>? 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,
),
);
}

View File

@@ -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();

View File

@@ -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<String>? 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<SemanticBehavior>? 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 `<input>`
/// 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<String>(controlsNodes, update.controlsNodes)) {

View File

@@ -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();

View File

@@ -324,13 +324,19 @@ class HtmlPatternMatcher extends Matcher {
html.Element pattern,
) {
for (final MapEntry<Object, String> 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<String, String> parseStyle(html.Element element) {
final Map<String, String> result = <String, String>{};

View File

@@ -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: <SemanticsNodeUpdate>[
// 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('''
<sem id="flt-semantic-node-0" aria-invalid--missing>
<sem id="flt-semantic-node-1" aria-invalid--missing></sem>
<sem id="flt-semantic-node-2" aria-invalid="false"></sem>
<sem id="flt-semantic-node-3" aria-invalid="true"></sem>
</sem>''');
// Shift all values, observe that the values changed accordingly
tester.updateNode(
id: 0,
children: <SemanticsNodeUpdate>[
// 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('''
<sem id="flt-semantic-node-0" aria-invalid--missing>
<sem id="flt-semantic-node-1" aria-invalid="false"></sem>
<sem id="flt-semantic-node-2" aria-invalid="true"></sem>
<sem id="flt-semantic-node-3" aria-invalid--missing></sem>
</sem>''');
});
}
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
void updateNode(

View File

@@ -121,6 +121,7 @@ class SemanticsTester {
String? linkUrl,
ui.SemanticsRole? role,
List<String>? 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;