Bug 1625915 - Expose validation error message via AXValidationError. r=morgan

Differential Revision: https://phabricator.services.mozilla.com/D214679
This commit is contained in:
Mikhail Galanin
2025-05-22 17:09:49 +00:00
committed by mreschenberg@mozilla.com
parent 2ea61132e6
commit 921153b34f
10 changed files with 336 additions and 2 deletions

View File

@@ -551,6 +551,7 @@ static const char kEventTypeNames[][40] = {
"live region removed", // EVENT_LIVE_REGION_REMOVED
"inner reorder", // EVENT_INNER_REORDER
"live region changed", // EVENT_LIVE_REGION_CHANGED
"errormessage changed", // EVENT_ERRORMESSAGE_CHANGED
};
#endif

View File

@@ -218,10 +218,15 @@ interface nsIAccessibleEvent : nsISupports
*/
const unsigned long EVENT_LIVE_REGION_CHANGED = 0x0029;
/**
* A value of aria-errormessage was changed.
*/
const unsigned long EVENT_ERRORMESSAGE_CHANGED = 0x002a;
/**
* Help make sure event map does not get out-of-line.
*/
const unsigned long EVENT_LAST_ENTRY = 0x002a;
const unsigned long EVENT_LAST_ENTRY = 0x002b;
/**
* The type of event, based on the enumerated event values

View File

@@ -33,6 +33,16 @@ void DocAccessibleWrap::AttributeChanged(dom::Element* aElement,
const nsAttrValue* aOldValue) {
DocAccessible::AttributeChanged(aElement, aNameSpaceID, aAttribute, aModType,
aOldValue);
if (aAttribute == nsGkAtoms::aria_errormessage) {
LocalAccessible* accessible =
mContent != aElement ? GetAccessible(aElement) : this;
if (!accessible) {
return;
}
FireDelayedEvent(nsIAccessibleEvent::EVENT_ERRORMESSAGE_CHANGED,
accessible);
}
if (aAttribute == nsGkAtoms::aria_live) {
LocalAccessible* accessible =
mContent != aElement ? GetAccessible(aElement) : this;

View File

@@ -109,6 +109,9 @@
// AXEnabled
- (NSNumber* _Nullable)moxEnabled;
// AXErrorMessageElements
- (NSArray* _Nullable)moxErrorMessageElements;
// AXFocused
- (NSNumber* _Nullable)moxFocused;

View File

@@ -101,7 +101,8 @@ void PlatformEvent(Accessible* aTarget, uint32_t aEventType) {
aEventType != nsIAccessibleEvent::EVENT_LIVE_REGION_REMOVED &&
aEventType != nsIAccessibleEvent::EVENT_LIVE_REGION_CHANGED &&
aEventType != nsIAccessibleEvent::EVENT_NAME_CHANGE &&
aEventType != nsIAccessibleEvent::EVENT_OBJECT_ATTRIBUTE_CHANGED) {
aEventType != nsIAccessibleEvent::EVENT_OBJECT_ATTRIBUTE_CHANGED &&
aEventType != nsIAccessibleEvent::EVENT_ERRORMESSAGE_CHANGED) {
return;
}

View File

@@ -93,6 +93,8 @@ enum CheckedState {
inContainer:(mozilla::a11y::Accessible*)container
at:(int32_t)start;
- (void)maybePostValidationErrorChanged;
// internal method to retrieve a child at a given index.
- (id)childAt:(uint32_t)i;
@@ -188,6 +190,9 @@ enum CheckedState {
// override
- (NSString*)moxInvalid;
// override
- (NSString*)moxErrorMessageElements;
// override
- (NSNumber*)moxFocused;

View File

@@ -602,6 +602,17 @@ struct RoleDescrComparator {
return ([self stateWithMask:states::INVALID] != 0) ? @"true" : @"false";
}
- (NSArray*)moxErrorMessageElements {
if (![[self moxInvalid] isEqualToString:@"false"]) {
NSArray* relations = [self getRelationsByType:RelationType::ERRORMSG];
if ([relations count] > 0) {
return relations;
}
}
return nil;
}
- (NSNumber*)moxFocused {
return @([self stateWithMask:states::FOCUSED] != 0);
}
@@ -966,6 +977,7 @@ struct RoleDescrComparator {
inserted:(BOOL)isInserted
inContainer:(Accessible*)container
at:(int32_t)start {
[self maybePostValidationErrorChanged];
}
- (void)handleAccessibleEvent:(uint32_t)eventType {
@@ -1027,6 +1039,32 @@ struct RoleDescrComparator {
MOZ_ASSERT(mIsLiveRegion);
[self moxPostNotification:@"AXLiveRegionChanged"];
break;
case nsIAccessibleEvent::EVENT_ERRORMESSAGE_CHANGED: {
// aria-errormessage was changed. If aria-invalid != "true", it means that
// VoiceOver should (a) expose a new message or (b) remove an
// old message
if (![[self moxInvalid] isEqualToString:@"false"]) {
[self moxPostNotification:@"AXValidationErrorChanged"];
}
break;
}
}
}
- (void)maybePostValidationErrorChanged {
NSArray* relations =
[self getRelationsByType:(mozilla::a11y::RelationType::ERRORMSG_FOR)];
if ([relations count] > 0) {
// only fire AXValidationErrorChanged if related node is not
// `aria-invalid="false"`
for (uint32_t relIdx = 0; relIdx <= [relations count]; relIdx++) {
NSString* invalidStr = [relations[relIdx] moxInvalid];
if (![invalidStr isEqualToString:@"false"]) {
[self moxPostNotification:@"AXValidationErrorChanged"];
break;
}
}
}
}

View File

@@ -25,6 +25,8 @@ https_first_disabled = true
["browser_aria_current.js"]
skip-if = ["os == 'mac' && os_version == '15.30' && arch == 'aarch64' && opt"] # Bug 1802555
["browser_aria_errormessage.js"]
["browser_aria_expanded.js"]
["browser_aria_haspopup.js"]

View File

@@ -0,0 +1,268 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* This test verifies that an input with aria-invalid="true/grammar/spelling" exposes the MOX accessible for
* its error message via AXErrorMessageElements.
*/
addAccessibleTask(
`
<input id="input-invalid" aria-invalid="true" aria-errormessage="error-msg">
<input id="input-invalid-grammar" aria-invalid="grammar" aria-errormessage="error-msg">
<input id="input-invalid-spelling" aria-invalid="spelling" aria-errormessage="error-msg">
<div id="error-msg">Field validation failed</div>
`,
(_browser, accDoc) => {
const messagesInvalid = getNativeInterface(accDoc, "input-invalid")
.getAttributeValue("AXErrorMessageElements")
.map(e => e.getAttributeValue("AXDOMIdentifier"));
is(
messagesInvalid.length,
1,
"Only one element referenced via 'aria-errormessage'"
);
is(
messagesInvalid[0],
"error-msg",
"input#input-invalid refers 'error-msg' in the 'aria-errormessage'"
);
const messagesInvalidGrammar = getNativeInterface(
accDoc,
"input-invalid-grammar"
)
.getAttributeValue("AXErrorMessageElements")
.map(e => e.getAttributeValue("AXDOMIdentifier"));
is(
messagesInvalidGrammar.length,
1,
"Only one element referenced via 'aria-errormessage'"
);
is(
messagesInvalidGrammar[0],
"error-msg",
"input#input-invalid-grammar refers 'error-msg' in the 'aria-errormessage'"
);
const messagesInvalidSpelling = getNativeInterface(
accDoc,
"input-invalid-spelling"
)
.getAttributeValue("AXErrorMessageElements")
.map(e => e.getAttributeValue("AXDOMIdentifier"));
is(
messagesInvalidSpelling.length,
1,
"Only one element referenced via 'aria-errormessage'"
);
is(
messagesInvalidSpelling[0],
"error-msg",
"input#input-invalid-spelling refers 'error-msg' in the 'aria-errormessage'"
);
}
);
/**
* This test verifies that an input with aria-invalid=true exposes all the MOX accessibles defined through `aria-errormessage`
* via AXErrorMessageElements
*/
addAccessibleTask(
`
<label for="input">Field with error</label><input id="input" aria-invalid="true" aria-errormessage="error-msg-specialchar error-msg-10charlong">
<div id="error-msg-specialchar">Field must contain special characters</div>
<div id="error-msg-10charlong">Field must contain more than 10 characters</div>
`,
(_browser, accDoc) => {
let input = getNativeInterface(accDoc, "input");
const errorMessageList = input.getAttributeValue("AXErrorMessageElements");
let messages = errorMessageList.map(e =>
e.getAttributeValue("AXDOMIdentifier")
);
messages.sort();
is(
messages.length,
2,
"input#input references two elements via 'aria-errormessage'"
);
is(
messages[0],
"error-msg-10charlong",
"We expect all elements listed in 'aria-errormessage'"
);
is(
messages[1],
"error-msg-specialchar",
"We expect all elements listed in 'aria-errormessage'"
);
}
);
/**
* When aria-invalid is set to "false", attribute is missing or without a value, AXErrorMessageElements should
* not return associated error messages.
* This test verifies that in this cases, AXErrorMessageElements returns `null`.
*/
addAccessibleTask(
`
<label for="input">Field with error</label><input id="input-invalid-false" aria-invalid="false" aria-errormessage="error-msg-specialchar error-msg-10charlong">
<label for="input">Field with error</label><input id="input-invalid-missing" aria-errormessage="error-msg-specialchar error-msg-10charlong">
<label for="input">Field with error</label><input id="input-invalid-spelling-error" aria-invalid aria-errormessage="error-msg-specialchar error-msg-10charlong">
<div id="error-msg-specialchar">Field must contain special characters</div>
<div id="error-msg-10charlong">Field must contain more than 10 characters</div>
`,
(_browser, accDoc) => {
const errorsForInvalidFalse = getNativeInterface(
accDoc,
"input-invalid-false"
).getAttributeValue("AXErrorMessageElements");
is(
errorsForInvalidFalse,
null,
"When aria-invalid is 'false', [AXErrorMessageElements] should return null"
);
const errorsForInvalidMissing = getNativeInterface(
accDoc,
"input-invalid-missing"
).getAttributeValue("AXErrorMessageElements");
is(
errorsForInvalidMissing,
null,
"When aria-invalid is missing, [AXErrorMessageElements] should return null"
);
const errorsForSpellingError = getNativeInterface(
accDoc,
"input-invalid-spelling-error"
).getAttributeValue("AXErrorMessageElements");
is(
errorsForSpellingError,
null,
"When aria-invalid is provided without value, [AXErrorMessageElements] should return null"
);
}
);
/**
* This test modifies the innerText of an associated error message and verifies the correct event AXValidationErrorChagned is fired.
*/
addAccessibleTask(
`
<label for="input">Field with error</label><input id="input" aria-invalid="true" aria-errormessage="error-msg">
<div id="error-msg">Field validation failed</div>
`,
async (browser, _accDoc) => {
let validationErrorChanged = waitForMacEvent("AXValidationErrorChanged");
await SpecialPowers.spawn(browser, [], () => {
content.document.getElementById("error-msg").innerText =
"new error message";
});
await validationErrorChanged;
info("validationErrorChanged: event has arrived");
}
);
/**
* This test modifies the inner tree of an associated error message and verifies the correct event AXValidationErrorChagned is fired.
*/
addAccessibleTask(
`
<label for="input">Field with error</label><input id="input" aria-invalid="true" aria-errormessage="error-msg">
<div id="error-msg">Field validation failed <span id="inner-error-msg"></span></div>
`,
async (browser, _accDoc) => {
let validationErrorChanged = waitForMacEvent("AXValidationErrorChanged");
info("validationErrorChanged: changing inner element");
await SpecialPowers.spawn(browser, [], () => {
content.document.getElementById("inner-error-msg").innerText =
"detailed error message";
});
await validationErrorChanged;
info("validationErrorChanged: event has arrived");
}
);
/**
* When the value of `aria-errormessage` is changed, AXValidationErrorChanged should be triggered.
* The test removes the element id from `aria-errormessage` and checks that:
* - the event was fired
* - AXErrorMessageElements does not return error messages
*
* Then, the test inserts element id back to `aria-errormessage` and checks that:
* - the event AXValidationErrorChanged was fired
* - AXErrorMessageElements contain our error message
*/
addAccessibleTask(
`
<label for="input">Field with error</label><input id="input" aria-invalid="true" aria-errormessage="error-msg">
<div id="error-msg">Field validation failed</div>
`,
async (browser, accDoc) => {
let validationErrorChanged = waitForMacEvent("AXValidationErrorChanged");
info("validationErrorChanged: removing reference to error");
await SpecialPowers.spawn(browser, [], () => {
content.document
.getElementById("input")
.setAttribute("aria-errormessage", "");
});
await validationErrorChanged;
info("validationErrorChanged: event has arrived");
let validationErrors = getNativeInterface(
accDoc,
"input"
).getAttributeValue("AXErrorMessageElements");
is(
validationErrors,
null,
"We have removed reference to error message, AXErrorMessageElements should now contain nothing"
);
info("validationErrorChanged: adding the reference back");
validationErrorChanged = waitForMacEvent("AXValidationErrorChanged");
await SpecialPowers.spawn(browser, [], () => {
content.document
.getElementById("input")
.setAttribute("aria-errormessage", "error-msg");
});
await validationErrorChanged;
validationErrors = getNativeInterface(accDoc, "input")
.getAttributeValue("AXErrorMessageElements")
.map(e => e.getAttributeValue("AXDOMIdentifier"));
info("validation errors: " + JSON.stringify(validationErrors));
is(
validationErrors.length,
1,
"Reference to 'error-msg' was returned back"
);
is(
validationErrors[0],
"error-msg",
"Reference to 'error-msg' was returned back"
);
}
);

View File

@@ -54,5 +54,6 @@ static const uint32_t gWinEventMap[] = {
kEVENT_WIN_UNKNOWN, // nsIAccessibleEvent::EVENT_LIVE_REGION_REMOVED
kEVENT_WIN_UNKNOWN, // nsIAccessibleEvent::EVENT_INNER_REORDER
kEVENT_WIN_UNKNOWN, // nsIAccessibleEvent::EVENT_LIVE_REGION_CHANGED
kEVENT_WIN_UNKNOWN, // nsIAccessibleEvent::EVENT_ERRORMESSAGE_CHANGED
// clang-format on
};