Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2/2 TextInput accessibilityErrorMessage (VoiceOver, iOS) #35908

Closed
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Libraries/Components/TextInput/RCTTextInputViewConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ const RCTTextInputViewConfig = {
allowFontScaling: true,
fontStyle: true,
textTransform: true,
accessibilityErrorMessage: true,
accessibilityInvalid: true,
textAlign: true,
fontFamily: true,
lineHeight: true,
Expand Down
8 changes: 8 additions & 0 deletions Libraries/Components/TextInput/TextInput.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
8 changes: 8 additions & 0 deletions Libraries/Components/TextInput/TextInput.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
16 changes: 16 additions & 0 deletions Libraries/Components/TextInput/TextInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1439,6 +1453,7 @@ function InternalTextInput(props: Props): React.Node {
ref={ref}
{...otherProps}
{...eventHandlers}
accessibilityErrorMessage={accessibilityErrorMessage}
accessibilityState={_accessibilityState}
accessible={accessible}
submitBehavior={submitBehavior}
Expand Down Expand Up @@ -1490,6 +1505,7 @@ function InternalTextInput(props: Props): React.Node {
ref={ref}
{...otherProps}
{...eventHandlers}
accessibilityErrorMessage={accessibilityErrorMessage}
accessibilityState={_accessibilityState}
accessibilityLabelledBy={_accessibilityLabelledBy}
accessible={accessible}
Expand Down
4 changes: 4 additions & 0 deletions Libraries/Components/TextInput/__tests__/TextInput-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe('TextInput', () => {

expect(instance.toJSON()).toMatchInlineSnapshot(`
<RCTSinglelineTextInputView
accessibilityErrorMessage={null}
accessible={true}
allowFontScaling={true}
focusable={true}
Expand Down Expand Up @@ -231,6 +232,7 @@ describe('TextInput compat with web', () => {

expect(instance.toJSON()).toMatchInlineSnapshot(`
<RCTSinglelineTextInputView
accessibilityErrorMessage={null}
accessible={true}
allowFontScaling={true}
focusable={true}
Expand Down Expand Up @@ -315,6 +317,7 @@ describe('TextInput compat with web', () => {

expect(instance.toJSON()).toMatchInlineSnapshot(`
<RCTSinglelineTextInputView
accessibilityErrorMessage={null}
accessibilityState={
Object {
"busy": true,
Expand Down Expand Up @@ -407,6 +410,7 @@ describe('TextInput compat with web', () => {

expect(instance.toJSON()).toMatchInlineSnapshot(`
<RCTSinglelineTextInputView
accessibilityErrorMessage={null}
accessible={true}
allowFontScaling={true}
focusable={true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

exports[`TextInput tests should render as expected: should deep render when mocked (please verify output manually) 1`] = `
<RCTSinglelineTextInputView
accessibilityErrorMessage={null}
accessible={true}
allowFontScaling={true}
focusable={true}
Expand Down Expand Up @@ -31,6 +32,7 @@ exports[`TextInput tests should render as expected: should deep render when mock

exports[`TextInput tests should render as expected: should deep render when not mocked (please verify output manually) 1`] = `
<RCTSinglelineTextInputView
accessibilityErrorMessage={null}
accessible={true}
allowFontScaling={true}
focusable={true}
Expand Down
3 changes: 3 additions & 0 deletions Libraries/Text/TextInput/Multiline/RCTUITextView.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ NS_ASSUME_NONNULL_BEGIN

@property (nonatomic, weak) id<RCTBackedTextInputDelegate> 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;
Expand Down
3 changes: 3 additions & 0 deletions Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ NS_ASSUME_NONNULL_BEGIN
@protocol RCTBackedTextInputViewProtocol <UITextInput>

@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;
Expand Down
11 changes: 11 additions & 0 deletions Libraries/Text/TextInput/RCTBaseTextInputView.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions Libraries/Text/TextInput/RCTBaseTextInputViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Libraries/Text/TextInput/Singleline/RCTUITextField.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ NS_ASSUME_NONNULL_BEGIN

@property (nonatomic, weak) id<RCTBackedTextInputDelegate> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ @implementation RCTTextInputComponentView {
UIView<RCTBackedTextInputViewProtocol> *_backedTextInputView;
NSUInteger _mostRecentEventCount;
NSAttributedString *_lastStringStateWasUpdatedWith;
NSString *currentAccessibilityError;
NSString *previousAccessibilityError;

/*
* UIKit uses either UITextField or UITextView as its UIKit element for <TextInput>. UITextField is for single line
Expand All @@ -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;
fabOnReact marked this conversation as resolved.
Show resolved Hide resolved
}

#pragma mark - UIView overrides
Expand All @@ -71,6 +79,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_ignoreNextTextInputCall = NO;
_comingFromJS = NO;
_didMoveToWindow = NO;
_errorMessageRemoved = NO;
[self addSubview:_backedTextInputView];
}

Expand Down Expand Up @@ -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;
fabOnReact marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,17 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
[self invalidateLayer];
}

- (void)announceForAccessibilityWithOptions:(NSString*)announcement
{
if (@available(iOS 11.0, *)) {
fabOnReact marked this conversation as resolved.
Show resolved Hide resolved
BOOL accessibilityAnnouncementNotEmpty = [announcement length] != 0;
if (accessibilityAnnouncementNotEmpty) {
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement);
self.triggerAccessibilityAnnouncement = NO;
}
}
}

fabOnReact marked this conversation as resolved.
Show resolved Hide resolved
- (void)prepareForRecycle
{
[super prepareForRecycle];
Expand Down
1 change: 1 addition & 0 deletions React/Views/UIView+React.h
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
@property (nonatomic, copy) NSArray<NSDictionary *> *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
Expand Down
11 changes: 11 additions & 0 deletions React/Views/UIView+React.m
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ TextInputProps::TextInputProps(
"selection",
sourceProps.selection,
std::optional<Selection>())),
accessibilityErrorMessage(convertRawProp(
context,
rawProps,
"accessibilityErrorMessage",
sourceProps.accessibilityErrorMessage,
{})),
inputAccessoryViewID(convertRawProp(
context,
rawProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ class TextInputProps final : public ViewProps, public BaseTextProps {

std::string const inputAccessoryViewID{};

std::string accessibilityErrorMessage{""};

bool onKeyPressSync{false};
bool onChangeSync{false};

Expand Down
Loading