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 2 commits
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
1 change: 1 addition & 0 deletions Libraries/Text/TextInput/Multiline/RCTUITextView.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN

@property (nonatomic, weak) id<RCTBackedTextInputDelegate> textInputDelegate;

@property (nonatomic, assign, nullable) NSString *accessibilityErrorMessage;
@property (nonatomic, assign) BOOL contextMenuHidden;
@property (nonatomic, assign, readonly) BOOL textWasPasted;
@property (nonatomic, copy, nullable) NSString *placeholder;
Expand Down
1 change: 1 addition & 0 deletions Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ NS_ASSUME_NONNULL_BEGIN
@protocol RCTBackedTextInputViewProtocol <UITextInput>

@property (nonatomic, copy, nullable) NSAttributedString *attributedText;
@property (nonatomic, assign, nullable) NSString *accessibilityErrorMessage;
@property (nonatomic, copy, nullable) NSString *placeholder;
@property (nonatomic, strong, nullable) UIColor *placeholderColor;
@property (nonatomic, assign, readonly) BOOL textWasPasted;
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
1 change: 1 addition & 0 deletions Libraries/Text/TextInput/Singleline/RCTUITextField.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ NS_ASSUME_NONNULL_BEGIN

@property (nonatomic, weak) id<RCTBackedTextInputDelegate> textInputDelegate;

@property (nonatomic, assign, nullable) NSString *accessibilityErrorMessage;
@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 @@ -55,6 +55,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 _triggerAccessibilityAnnouncement;
}

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

Expand Down Expand Up @@ -133,6 +140,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) {
_triggerAccessibilityAnnouncement = YES;
NSString *errorWithText = [NSString stringWithFormat: @"%@ %@", text, error];
self.accessibilityElement.accessibilityValue = errorWithText;
} else {
self.accessibilityElement.accessibilityValue = text;
_triggerAccessibilityAnnouncement = NO;
}
}

if (newTextInputProps.traits.enablesReturnKeyAutomatically !=
oldTextInputProps.traits.enablesReturnKeyAutomatically) {
_backedTextInputView.enablesReturnKeyAutomatically = newTextInputProps.traits.enablesReturnKeyAutomatically;
Expand Down Expand Up @@ -236,6 +263,15 @@ - (void)updateState:(State::Shared const &)state oldState:(State::Shared const &
}
}

- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
{
[super finalizeUpdates:updateMask];
if (_triggerAccessibilityAnnouncement) {
[self announceForAccessibilityWithOptions:self.accessibilityElement.accessibilityValue];
_triggerAccessibilityAnnouncement = NO;
}
}

- (void)updateLayoutMetrics:(LayoutMetrics const &)layoutMetrics
oldLayoutMetrics:(LayoutMetrics const &)oldLayoutMetrics
{
Expand Down Expand Up @@ -594,6 +630,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 (_triggerAccessibilityAnnouncement) {
[self announceForAccessibilityWithOptions:self.accessibilityElement.accessibilityValue];
_triggerAccessibilityAnnouncement = NO;
} else {
NSString *lastChar = [attributedString.string substringFromIndex:[attributedString.string length] - 1];
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, lastChar);
_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,6 @@ NS_ASSUME_NONNULL_BEGIN
* Defaults to `self`.
*/
@property (nonatomic, strong, nullable, readonly) NSObject *accessibilityElement;

/**
* Insets used when hit testing inside this view.
*/
Expand All @@ -85,6 +84,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
17 changes: 17 additions & 0 deletions React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ @implementation RCTViewComponentView {
BOOL _removeClippedSubviews;
NSMutableArray<UIView *> *_reactSubviews;
NSSet<NSString *> *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN;
/*
* 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;
}

- (instancetype)initWithFrame:(CGRect)frame
Expand All @@ -37,6 +42,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_props = defaultProps;
_reactSubviews = [NSMutableArray new];
self.multipleTouchEnabled = YES;
_triggerAccessibilityAnnouncement = NO;
}
return self;
}
Expand Down Expand Up @@ -412,6 +418,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);
_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