diff --git a/accessible/base/nsAccessibilityService.h b/accessible/base/nsAccessibilityService.h
index 9a5472ea5e14..a34d3cdc504e 100644
--- a/accessible/base/nsAccessibilityService.h
+++ b/accessible/base/nsAccessibilityService.h
@@ -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
diff --git a/accessible/interfaces/nsIAccessibleEvent.idl b/accessible/interfaces/nsIAccessibleEvent.idl
index 9f340893c409..d97e79517abc 100644
--- a/accessible/interfaces/nsIAccessibleEvent.idl
+++ b/accessible/interfaces/nsIAccessibleEvent.idl
@@ -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
diff --git a/accessible/mac/DocAccessibleWrap.mm b/accessible/mac/DocAccessibleWrap.mm
index 41fe3c8d124b..4f4572311cbf 100644
--- a/accessible/mac/DocAccessibleWrap.mm
+++ b/accessible/mac/DocAccessibleWrap.mm
@@ -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;
diff --git a/accessible/mac/MOXAccessibleProtocol.h b/accessible/mac/MOXAccessibleProtocol.h
index 8bae8671a609..da3713929069 100644
--- a/accessible/mac/MOXAccessibleProtocol.h
+++ b/accessible/mac/MOXAccessibleProtocol.h
@@ -109,6 +109,9 @@
// AXEnabled
- (NSNumber* _Nullable)moxEnabled;
+// AXErrorMessageElements
+- (NSArray* _Nullable)moxErrorMessageElements;
+
// AXFocused
- (NSNumber* _Nullable)moxFocused;
diff --git a/accessible/mac/Platform.mm b/accessible/mac/Platform.mm
index ed37298df363..c847a548823e 100644
--- a/accessible/mac/Platform.mm
+++ b/accessible/mac/Platform.mm
@@ -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;
}
diff --git a/accessible/mac/mozAccessible.h b/accessible/mac/mozAccessible.h
index d89ffd31fbc9..b5797872c678 100644
--- a/accessible/mac/mozAccessible.h
+++ b/accessible/mac/mozAccessible.h
@@ -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;
diff --git a/accessible/mac/mozAccessible.mm b/accessible/mac/mozAccessible.mm
index b9695f2b2bd5..cf2300bea919 100644
--- a/accessible/mac/mozAccessible.mm
+++ b/accessible/mac/mozAccessible.mm
@@ -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;
+ }
+ }
}
}
diff --git a/accessible/tests/browser/mac/browser.toml b/accessible/tests/browser/mac/browser.toml
index b19fab173866..4a9d306b235f 100644
--- a/accessible/tests/browser/mac/browser.toml
+++ b/accessible/tests/browser/mac/browser.toml
@@ -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"]
diff --git a/accessible/tests/browser/mac/browser_aria_errormessage.js b/accessible/tests/browser/mac/browser_aria_errormessage.js
new file mode 100644
index 000000000000..05acb789dde4
--- /dev/null
+++ b/accessible/tests/browser/mac/browser_aria_errormessage.js
@@ -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(
+ `
+
+
+
+
Field validation failed
+ `,
+ (_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(
+ `
+
+
Field must contain special characters
+
Field must contain more than 10 characters
+ `,
+ (_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(
+ `
+
+
+
+
Field must contain special characters
+
Field must contain more than 10 characters
+ `,
+ (_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(
+ `
+
+
Field validation failed
+ `,
+ 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(
+ `
+
+
Field validation failed
+ `,
+ 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(
+ `
+
+