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/Components/TextInput/TextInput.d.ts b/Libraries/Components/TextInput/TextInput.d.ts index 50d02c13f08152..4fdfd3c1cfbc6a 100644 --- a/Libraries/Components/TextInput/TextInput.d.ts +++ b/Libraries/Components/TextInput/TextInput.d.ts @@ -521,6 +521,14 @@ export interface TextInputProps TextInputIOSProps, TextInputAndroidProps, AccessibilityProps { + /** + * String to be read by screenreaders to indicate an error state. The acceptable parameters + * of accessibilityErrorMessage is a string. Setting accessibilityInvalid to true activates + * the error message. Setting accessibilityInvalid to false removes the error message. + */ + accessibilityErrorMessage?: string | undefined; + accessibilityInvalid?: boolean | undefined; + /** * Specifies whether fonts should scale to respect Text Size accessibility settings. * The default is `true`. diff --git a/Libraries/Components/TextInput/TextInput.flow.js b/Libraries/Components/TextInput/TextInput.flow.js index 57259190f1a449..fd1d3acee722c1 100644 --- a/Libraries/Components/TextInput/TextInput.flow.js +++ b/Libraries/Components/TextInput/TextInput.flow.js @@ -523,6 +523,14 @@ export type Props = $ReadOnly<{| ...IOSProps, ...AndroidProps, + /** + * String to be read by screenreaders to indicate an error state. The acceptable parameters + * of accessibilityErrorMessage is a string. Setting accessibilityInvalid to true activates + * the error message. Setting accessibilityInvalid to false removes the error message. + */ + accessibilityErrorMessage?: ?Stringish, + accessibilityInvalid?: ?boolean, + /** * Can tell `TextInput` to automatically capitalize certain characters. * diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index ec337de164ef8a..9e7cad98a9248f 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -561,6 +561,14 @@ export type Props = $ReadOnly<{| ...IOSProps, ...AndroidProps, + /** + * String to be read by screenreaders to indicate an error state. The acceptable parameters + * of accessibilityErrorMessage is a string. Setting accessibilityInvalid to true activates + * the error message. Setting accessibilityInvalid to false removes the error message. + */ + accessibilityErrorMessage?: ?Stringish, + accessibilityInvalid?: ?boolean, + /** * Can tell `TextInput` to automatically capitalize certain characters. * @@ -1365,6 +1373,12 @@ function InternalTextInput(props: Props): React.Node { } const accessible = props.accessible !== false; + + const accessibilityErrorMessage = + props.accessibilityInvalid === true + ? props.accessibilityErrorMessage + : null; + const focusable = props.focusable !== false; const config = React.useMemo( @@ -1439,6 +1453,7 @@ function InternalTextInput(props: Props): React.Node { ref={ref} {...otherProps} {...eventHandlers} + accessibilityErrorMessage={accessibilityErrorMessage} accessibilityState={_accessibilityState} accessible={accessible} submitBehavior={submitBehavior} @@ -1490,6 +1505,7 @@ function InternalTextInput(props: Props): React.Node { ref={ref} {...otherProps} {...eventHandlers} + accessibilityErrorMessage={accessibilityErrorMessage} accessibilityState={_accessibilityState} accessibilityLabelledBy={_accessibilityLabelledBy} accessible={accessible} diff --git a/Libraries/Components/TextInput/__tests__/TextInput-test.js b/Libraries/Components/TextInput/__tests__/TextInput-test.js index 4f1adf765e31be..7a6e6c5f54ffaa 100644 --- a/Libraries/Components/TextInput/__tests__/TextInput-test.js +++ b/Libraries/Components/TextInput/__tests__/TextInput-test.js @@ -186,6 +186,7 @@ describe('TextInput', () => { expect(instance.toJSON()).toMatchInlineSnapshot(` { expect(instance.toJSON()).toMatchInlineSnapshot(` { expect(instance.toJSON()).toMatchInlineSnapshot(` { expect(instance.toJSON()).toMatchInlineSnapshot(` textInputDelegate; +@property (nonatomic, assign, nullable) NSString *accessibilityErrorMessage; +@property (nonatomic, readwrite, nullable) NSString *currentAccessibilityError; +@property (nonatomic, readwrite, nullable) NSString *previousAccessibilityError; @property (nonatomic, assign) BOOL contextMenuHidden; @property (nonatomic, assign, readonly) BOOL textWasPasted; @property (nonatomic, copy, nullable) NSString *placeholder; diff --git a/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h b/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h index 686af9e3a363e2..6bb48d6d42c0e7 100644 --- a/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h +++ b/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h @@ -15,6 +15,9 @@ NS_ASSUME_NONNULL_BEGIN @protocol RCTBackedTextInputViewProtocol @property (nonatomic, copy, nullable) NSAttributedString *attributedText; +@property (nonatomic, assign, nullable) NSString *accessibilityErrorMessage; +@property (nonatomic, readwrite, nullable) NSString *currentAccessibilityError; +@property (nonatomic, readwrite, nullable) NSString *previousAccessibilityError; @property (nonatomic, copy, nullable) NSString *placeholder; @property (nonatomic, strong, nullable) UIColor *placeholderColor; @property (nonatomic, assign, readonly) BOOL textWasPasted; diff --git a/Libraries/Text/TextInput/RCTBaseTextInputView.m b/Libraries/Text/TextInput/RCTBaseTextInputView.m index 7f2a6e6996366b..a37b34e661b154 100644 --- a/Libraries/Text/TextInput/RCTBaseTextInputView.m +++ b/Libraries/Text/TextInput/RCTBaseTextInputView.m @@ -148,6 +148,17 @@ - (void)setAttributedText:(NSAttributedString *)attributedText textNeedsUpdate = ([self textOf:attributedTextCopy equals:backedTextInputViewTextCopy] == NO); + NSString *currentAccessibilityError = self.backedTextInputView.currentAccessibilityError; + NSString *previousAccessibilityError = self.backedTextInputView.previousAccessibilityError; + BOOL accessibilityErrorMessageWasRemoved = currentAccessibilityError == nil && ![currentAccessibilityError isEqualToString: previousAccessibilityError]; + if (accessibilityErrorMessageWasRemoved) { + BOOL validString = attributedText && [attributedText.string length] != 0; + NSString *lastChar = validString ? [attributedText.string substringFromIndex:[attributedText.string length] - 1] : @""; + self.backedTextInputView.accessibilityValue = nil; + // Triggering the announcement manually fixes screenreader announcement getting cut off + // https://bit.ly/3w18QmV https://bit.ly/3AdVKW3 https://bit.ly/3QHm7c7 https://bit.ly/3BVnmAy + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, lastChar); + } if (eventLag == 0 && textNeedsUpdate) { UITextRange *selection = self.backedTextInputView.selectedTextRange; NSInteger oldTextLength = self.backedTextInputView.attributedText.string.length; 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/Libraries/Text/TextInput/Singleline/RCTUITextField.h b/Libraries/Text/TextInput/Singleline/RCTUITextField.h index b26b41f8693d7b..dedce3d28c7d70 100644 --- a/Libraries/Text/TextInput/Singleline/RCTUITextField.h +++ b/Libraries/Text/TextInput/Singleline/RCTUITextField.h @@ -21,6 +21,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, weak) id textInputDelegate; +@property (nonatomic, assign, nullable) NSString *accessibilityErrorMessage; +@property (nonatomic, readwrite, nullable) NSString *currentAccessibilityError; +@property (nonatomic, readwrite, nullable) NSString *previousAccessibilityError; @property (nonatomic, assign) BOOL caretHidden; @property (nonatomic, assign) BOOL contextMenuHidden; @property (nonatomic, assign, readonly) BOOL textWasPasted; diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 156a913c435d6c..71e0a4c8a4f5ee 100644 --- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -32,6 +32,8 @@ @implementation RCTTextInputComponentView { UIView *_backedTextInputView; NSUInteger _mostRecentEventCount; NSAttributedString *_lastStringStateWasUpdatedWith; + NSString *currentAccessibilityError; + NSString *previousAccessibilityError; /* * UIKit uses either UITextField or UITextView as its UIKit element for . UITextField is for single line @@ -55,6 +57,12 @@ @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 _errorMessageRemoved; } #pragma mark - UIView overrides @@ -71,6 +79,7 @@ - (instancetype)initWithFrame:(CGRect)frame _ignoreNextTextInputCall = NO; _comingFromJS = NO; _didMoveToWindow = NO; + _errorMessageRemoved = NO; [self addSubview:_backedTextInputView]; } @@ -133,6 +142,26 @@ - (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) { + NSString *text = RCTNSStringFromString(newTextInputProps.text); + _backedTextInputView.accessibilityValue = text; + self.accessibilityElement.accessibilityValue = text; + } + + if (newTextInputProps.accessibilityErrorMessage != oldTextInputProps.accessibilityErrorMessage) { + NSString *text = RCTNSStringFromString(newTextInputProps.text); + NSString *error = RCTNSStringFromString(newTextInputProps.accessibilityErrorMessage); + if ([error length] != 0) { + self.triggerAccessibilityAnnouncement = YES; + NSString *errorWithText = [NSString stringWithFormat: @"%@ %@", text, error]; + self.accessibilityElement.accessibilityValue = errorWithText; + } else { + self.accessibilityElement.accessibilityValue = text; + self.triggerAccessibilityAnnouncement = NO; + } + } + if (newTextInputProps.traits.enablesReturnKeyAutomatically != oldTextInputProps.traits.enablesReturnKeyAutomatically) { _backedTextInputView.enablesReturnKeyAutomatically = newTextInputProps.traits.enablesReturnKeyAutomatically; @@ -236,6 +265,15 @@ - (void)updateState:(State::Shared const &)state oldState:(State::Shared const & } } +- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask +{ + [super finalizeUpdates:updateMask]; + if (self.triggerAccessibilityAnnouncement) { + [self announceForAccessibilityWithOptions:self.accessibilityElement.accessibilityValue]; + self.triggerAccessibilityAnnouncement = NO; + } +} + - (void)updateLayoutMetrics:(LayoutMetrics const &)layoutMetrics oldLayoutMetrics:(LayoutMetrics const &)oldLayoutMetrics { @@ -594,6 +632,16 @@ - (void)_setAttributedString:(NSAttributedString *)attributedString UITextRange *selectedRange = _backedTextInputView.selectedTextRange; NSInteger oldTextLength = _backedTextInputView.attributedText.string.length; _backedTextInputView.attributedText = attributedString; + + // check that current error is not empty + if (self.triggerAccessibilityAnnouncement) { + [self announceForAccessibilityWithOptions:self.accessibilityElement.accessibilityValue]; + self.triggerAccessibilityAnnouncement = NO; + } else { + NSString *lastChar = [attributedString.string substringFromIndex:[attributedString.string length] - 1]; + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, lastChar); + self.triggerAccessibilityAnnouncement = NO; + } if (selectedRange.empty) { // Maintaining a cursor position relative to the end of the old text. NSInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h index 154fec78372c77..bac9730a2702c8 100644 --- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h +++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h @@ -63,7 +63,7 @@ NS_ASSUME_NONNULL_BEGIN * Defaults to `self`. */ @property (nonatomic, strong, nullable, readonly) NSObject *accessibilityElement; - +@property (nonatomic, readwrite) BOOL triggerAccessibilityAnnouncement; /** * Insets used when hit testing inside this view. */ @@ -85,6 +85,7 @@ NS_ASSUME_NONNULL_BEGIN * This is a fragment of temporary workaround that we need only temporary and will get rid of soon. */ - (NSString *)componentViewName_DO_NOT_USE_THIS_IS_BROKEN; +- (void)announceForAccessibilityWithOptions:(NSString *)announcement; @end diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 9619fe743aae85..cb68eb7020bbfe 100644 --- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -412,6 +412,17 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask [self invalidateLayer]; } +- (void)announceForAccessibilityWithOptions:(NSString*)announcement +{ + if (@available(iOS 11.0, *)) { + BOOL accessibilityAnnouncementNotEmpty = [announcement length] != 0; + if (accessibilityAnnouncementNotEmpty) { + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement); + self.triggerAccessibilityAnnouncement = NO; + } + } +} + - (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/TextInputProps.cpp b/ReactCommon/react/renderer/components/textinput/iostextinput/TextInputProps.cpp index dad0ea7f8ad803..da879a2daa97bf 100644 --- a/ReactCommon/react/renderer/components/textinput/iostextinput/TextInputProps.cpp +++ b/ReactCommon/react/renderer/components/textinput/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/TextInputProps.h b/ReactCommon/react/renderer/components/textinput/iostextinput/TextInputProps.h index bede7451767b36..3c5d310389ad49 100644 --- a/ReactCommon/react/renderer/components/textinput/iostextinput/TextInputProps.h +++ b/ReactCommon/react/renderer/components/textinput/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}; diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 268550b80624eb..e4c09fab511916 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -1544,9 +1544,88 @@ function DisplayOptionStatusExample({ ); } +function AccessibilityErrorWithButtons(): React.Node { + const [text, setText] = React.useState(''); + const [error, setError] = React.useState(null); + const [accessibilityInvalid, setAccessibilityInvalid] = React.useState(false); + return ( + + + { + setText(newText); + if (newText === 'Error') { + setError('the newText is: ' + newText); + setAccessibilityInvalid(true); + } else { + setError(null); + setAccessibilityInvalid(false); + } + }} + value={text} + style={styles.default} + /> +