From 921153b34fc451daeca1dfbeecc8e11789a2b7a0 Mon Sep 17 00:00:00 2001 From: Mikhail Galanin Date: Thu, 22 May 2025 17:09:49 +0000 Subject: [PATCH] Bug 1625915 - Expose validation error message via AXValidationError. r=morgan Differential Revision: https://phabricator.services.mozilla.com/D214679 --- accessible/base/nsAccessibilityService.h | 1 + accessible/interfaces/nsIAccessibleEvent.idl | 7 +- accessible/mac/DocAccessibleWrap.mm | 10 + accessible/mac/MOXAccessibleProtocol.h | 3 + accessible/mac/Platform.mm | 3 +- accessible/mac/mozAccessible.h | 5 + accessible/mac/mozAccessible.mm | 38 +++ accessible/tests/browser/mac/browser.toml | 2 + .../browser/mac/browser_aria_errormessage.js | 268 ++++++++++++++++++ accessible/windows/msaa/nsEventMap.h | 1 + 10 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 accessible/tests/browser/mac/browser_aria_errormessage.js 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( + ` + +
Field validation failed
+ `, + 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" + ); + } +); diff --git a/accessible/windows/msaa/nsEventMap.h b/accessible/windows/msaa/nsEventMap.h index 88addb6f8649..5e1baf715560 100644 --- a/accessible/windows/msaa/nsEventMap.h +++ b/accessible/windows/msaa/nsEventMap.h @@ -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 };