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 7 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/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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -71,6 +78,8 @@ - (instancetype)initWithFrame:(CGRect)frame
_ignoreNextTextInputCall = NO;
_comingFromJS = NO;
_didMoveToWindow = NO;
_triggerAccessibilityAnnouncement = NO;
_skipNextAccessibilityAnnouncement = NO;
[self addSubview:_backedTextInputView];
}

Expand Down Expand Up @@ -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:RCTNSStringFromString(newTextInputProps.accessibilityErrorMessage) text:RCTNSStringFromString(newTextInputProps.text)];
}

if (newTextInputProps.traits.enablesReturnKeyAutomatically !=
oldTextInputProps.traits.enablesReturnKeyAutomatically) {
_backedTextInputView.enablesReturnKeyAutomatically = newTextInputProps.traits.enablesReturnKeyAutomatically;
Expand Down Expand Up @@ -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:self.accessibilityElement.accessibilityValue];
fabOnReact marked this conversation as resolved.
Show resolved Hide resolved
_triggerAccessibilityAnnouncement = NO;
}
}

- (void)updateLayoutMetrics:(LayoutMetrics const &)layoutMetrics
oldLayoutMetrics:(LayoutMetrics const &)oldLayoutMetrics
{
Expand Down Expand Up @@ -594,6 +621,16 @@ - (void)_setAttributedString:(NSAttributedString *)attributedString
UITextRange *selectedRange = _backedTextInputView.selectedTextRange;
NSInteger oldTextLength = _backedTextInputView.attributedText.string.length;
_backedTextInputView.attributedText = attributedString;

if (_triggerAccessibilityAnnouncement && [self.accessibilityElement.accessibilityValue length] != 0) {
[self announceForAccessibility:self.accessibilityElement.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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)announceForAccessibility:(NSString *)announcement;

@end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,15 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
[self invalidateLayer];
}

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

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