diff --git a/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/Libraries/Components/TextInput/RCTTextInputViewConfig.js index 6f693295320eb4..e9a580777a3036 100644 --- a/Libraries/Components/TextInput/RCTTextInputViewConfig.js +++ b/Libraries/Components/TextInput/RCTTextInputViewConfig.js @@ -107,6 +107,8 @@ const RCTTextInputViewConfig = { allowFontScaling: true, fontStyle: true, textTransform: true, + accessibilityErrorMessage: true, + accessibilityInvalid: true, textAlign: true, fontFamily: true, lineHeight: true, diff --git a/Libraries/Text/TextInput/RCTBaseTextInputViewManager.m b/Libraries/Text/TextInput/RCTBaseTextInputViewManager.m index 47da2cefee5926..468859553881a0 100644 --- a/Libraries/Text/TextInput/RCTBaseTextInputViewManager.m +++ b/Libraries/Text/TextInput/RCTBaseTextInputViewManager.m @@ -36,6 +36,7 @@ @implementation RCTBaseTextInputViewManager { RCT_REMAP_VIEW_PROPERTY(autoCorrect, backedTextInputView.autocorrectionType, UITextAutocorrectionType) RCT_REMAP_VIEW_PROPERTY(contextMenuHidden, backedTextInputView.contextMenuHidden, BOOL) RCT_REMAP_VIEW_PROPERTY(editable, backedTextInputView.editable, BOOL) +RCT_REMAP_VIEW_PROPERTY(accessibilityErrorMessage, backedTextInputView.accessibilityErrorMessage, NSString) RCT_REMAP_VIEW_PROPERTY(enablesReturnKeyAutomatically, backedTextInputView.enablesReturnKeyAutomatically, BOOL) RCT_REMAP_VIEW_PROPERTY(keyboardAppearance, backedTextInputView.keyboardAppearance, UIKeyboardAppearance) RCT_REMAP_VIEW_PROPERTY(placeholder, backedTextInputView.placeholder, NSString) diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 156a913c435d6c..f09d776d416598 100644 --- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -55,6 +55,13 @@ @implementation RCTTextInputComponentView { */ BOOL _comingFromJS; BOOL _didMoveToWindow; + + /* + * A flag that triggers the accessibilityElement.accessibilityValue update and VoiceOver announcement + * to avoid duplicated announcements of accessibilityErrorMessage more info https://bit.ly/3yfUXD8 + */ + BOOL _triggerAccessibilityAnnouncement; + BOOL _skipNextAccessibilityAnnouncement; } #pragma mark - UIView overrides @@ -71,6 +78,8 @@ - (instancetype)initWithFrame:(CGRect)frame _ignoreNextTextInputCall = NO; _comingFromJS = NO; _didMoveToWindow = NO; + _triggerAccessibilityAnnouncement = NO; + _skipNextAccessibilityAnnouncement = NO; [self addSubview:_backedTextInputView]; } @@ -133,6 +142,15 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _backedTextInputView.editable = newTextInputProps.traits.editable; } + NSString *newAccessibilityErrorMessage = RCTNSStringFromString(newTextInputProps.accessibilityErrorMessage); + if (newTextInputProps.text != oldTextInputProps.text && [newAccessibilityErrorMessage length] == 0) { + _backedTextInputView.accessibilityValue = RCTNSStringFromString(newTextInputProps.text); + } + + if (newTextInputProps.accessibilityErrorMessage != oldTextInputProps.accessibilityErrorMessage) { + [self _setAccessibilityValueWithError:newAccessibilityErrorMessage text:RCTNSStringFromString(newTextInputProps.text)]; + } + if (newTextInputProps.traits.enablesReturnKeyAutomatically != oldTextInputProps.traits.enablesReturnKeyAutomatically) { _backedTextInputView.enablesReturnKeyAutomatically = newTextInputProps.traits.enablesReturnKeyAutomatically; @@ -236,6 +254,15 @@ - (void)updateState:(State::Shared const &)state oldState:(State::Shared const & } } +- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask +{ + [super finalizeUpdates:updateMask]; + if (_triggerAccessibilityAnnouncement) { + [self announceForAccessibility:_backedTextInputView.accessibilityValue]; + _triggerAccessibilityAnnouncement = NO; + } +} + - (void)updateLayoutMetrics:(LayoutMetrics const &)layoutMetrics oldLayoutMetrics:(LayoutMetrics const &)oldLayoutMetrics { @@ -594,6 +621,16 @@ - (void)_setAttributedString:(NSAttributedString *)attributedString UITextRange *selectedRange = _backedTextInputView.selectedTextRange; NSInteger oldTextLength = _backedTextInputView.attributedText.string.length; _backedTextInputView.attributedText = attributedString; + + if (_triggerAccessibilityAnnouncement && [_backedTextInputView.accessibilityValue length] != 0) { + [self announceForAccessibility:_backedTextInputView.accessibilityValue]; + _triggerAccessibilityAnnouncement = NO; + _skipNextAccessibilityAnnouncement = YES; + } else if (_skipNextAccessibilityAnnouncement) { + NSString *lastChar = [attributedString.string substringFromIndex:[attributedString.string length] - 1]; + [self announceForAccessibility:lastChar]; + _skipNextAccessibilityAnnouncement = NO; + } if (selectedRange.empty) { // Maintaining a cursor position relative to the end of the old text. NSInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument @@ -619,6 +656,17 @@ - (void)_setMultiline:(BOOL)multiline [self addSubview:_backedTextInputView]; } +- (void)_setAccessibilityValueWithError:(NSString *)error text:(NSString *)text +{ + if ([error length] != 0) { + _triggerAccessibilityAnnouncement = YES; + _backedTextInputView.accessibilityValue = [NSString stringWithFormat: @"%@ %@", text, error]; + } else { + _backedTextInputView.accessibilityValue = text; + _triggerAccessibilityAnnouncement = NO; + } +} + - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldText { // When the dictation is running we can't update the attributed text on the backed up text view diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h index 154fec78372c77..6e75c3a7550b22 100644 --- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h +++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h @@ -80,6 +80,7 @@ NS_ASSUME_NONNULL_BEGIN oldLayoutMetrics:(facebook::react::LayoutMetrics const &)oldLayoutMetrics NS_REQUIRES_SUPER; - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask NS_REQUIRES_SUPER; - (void)prepareForRecycle NS_REQUIRES_SUPER; +- (void)announceForAccessibility:(NSString *)announcement; /* * This is a fragment of temporary workaround that we need only temporary and will get rid of soon. diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 9619fe743aae85..11c77cf1dd9fe9 100644 --- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -412,6 +412,13 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask [self invalidateLayer]; } +- (void)announceForAccessibility:(NSString*)announcement +{ + if ([announcement length] != 0) { + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement); + } +} + - (void)prepareForRecycle { [super prepareForRecycle]; diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index 21a70337d7ac9c..1267cd54a6c445 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -121,6 +121,7 @@ @property (nonatomic, copy) NSArray *accessibilityActions; @property (nonatomic, copy) NSDictionary *accessibilityValueInternal; @property (nonatomic, copy) NSString *accessibilityLanguage; +@property (nonatomic, copy) NSString *accessibilityErrorMessage; /** * Used in debugging to get a description of the view hierarchy rooted at diff --git a/React/Views/UIView+React.m b/React/Views/UIView+React.m index 94ad951e7179d7..7fc6240597439e 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -320,6 +320,17 @@ - (NSString *)accessibilityLanguage return objc_getAssociatedObject(self, _cmd); } +- (NSString *)accessibilityErrorMessage +{ + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setAccessibilityErrorMessage:(NSString *)accessibilityErrorMessage +{ + objc_setAssociatedObject( + self, @selector(accessibilityErrorMessage), accessibilityErrorMessage, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + - (void)setAccessibilityLanguage:(NSString *)accessibilityLanguage { objc_setAssociatedObject( diff --git a/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.cpp b/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.cpp index dad0ea7f8ad803..da879a2daa97bf 100644 --- a/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.cpp +++ b/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.cpp @@ -87,6 +87,12 @@ TextInputProps::TextInputProps( "selection", sourceProps.selection, std::optional())), + accessibilityErrorMessage(convertRawProp( + context, + rawProps, + "accessibilityErrorMessage", + sourceProps.accessibilityErrorMessage, + {})), inputAccessoryViewID(convertRawProp( context, rawProps, diff --git a/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.h b/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.h index bede7451767b36..9b8b77338cfe83 100644 --- a/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.h +++ b/ReactCommon/react/renderer/components/textinput/iostextinput/react/renderer/components/iostextinput/TextInputProps.h @@ -68,6 +68,8 @@ class TextInputProps final : public ViewProps, public BaseTextProps { std::string const inputAccessoryViewID{}; + std::string accessibilityErrorMessage{}; + bool onKeyPressSync{false}; bool onChangeSync{false};