diff --git a/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js index e7c28912a0779e..d22042ac61b123 100644 --- a/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js +++ b/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js @@ -166,6 +166,14 @@ export type NativeProps = $ReadOnly<{| 'off', >, + /** + * 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, + /** * Sets the return key to the label. Use it instead of `returnKeyType`. * @platform android @@ -730,6 +738,8 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = { inlineImageLeft: true, editable: true, fontVariant: true, + accessibilityErrorMessage: true, + accessibilityInvalid: true, borderBottomRightRadius: true, borderBottomColor: { process: require('../../StyleSheet/processColor').default, diff --git a/Libraries/Components/TextInput/TextInput.d.ts b/Libraries/Components/TextInput/TextInput.d.ts index 637807aafd3864..4020cee13398f7 100644 --- a/Libraries/Components/TextInput/TextInput.d.ts +++ b/Libraries/Components/TextInput/TextInput.d.ts @@ -531,6 +531,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(` and @@ -30,6 +31,7 @@ public class ReactTextUpdate { private final int mSelectionStart; private final int mSelectionEnd; private final int mJustificationMode; + private @Nullable String mAccessibilityErrorMessage; public boolean mContainsMultipleFragments; @@ -59,7 +61,8 @@ public ReactTextUpdate( Layout.BREAK_STRATEGY_HIGH_QUALITY, Layout.JUSTIFICATION_MODE_NONE, -1, - -1); + -1, + null); } public ReactTextUpdate( @@ -85,7 +88,8 @@ public ReactTextUpdate( textBreakStrategy, justificationMode, -1, - -1); + -1, + null); } public ReactTextUpdate( @@ -107,7 +111,8 @@ public ReactTextUpdate( textBreakStrategy, justificationMode, -1, - -1); + -1, + null); } public ReactTextUpdate( @@ -137,21 +142,56 @@ public ReactTextUpdate( mJustificationMode = justificationMode; } + public ReactTextUpdate( + Spannable text, + int jsEventCounter, + boolean containsImages, + float paddingStart, + float paddingTop, + float paddingEnd, + float paddingBottom, + int textAlign, + int textBreakStrategy, + int justificationMode, + int selectionStart, + int selectionEnd, + @Nullable String accessibilityErrorMessage) { + mText = text; + mJsEventCounter = jsEventCounter; + mContainsImages = containsImages; + mPaddingLeft = paddingStart; + mPaddingTop = paddingTop; + mPaddingRight = paddingEnd; + mPaddingBottom = paddingBottom; + mTextAlign = textAlign; + mTextBreakStrategy = textBreakStrategy; + mSelectionStart = selectionStart; + mSelectionEnd = selectionEnd; + mJustificationMode = justificationMode; + mAccessibilityErrorMessage = accessibilityErrorMessage; + } + public static ReactTextUpdate buildReactTextUpdateFromState( Spannable text, int jsEventCounter, int textAlign, int textBreakStrategy, int justificationMode, - boolean containsMultipleFragments) { + boolean containsMultipleFragments, + @Nullable String accessibilityErrorMessage) { ReactTextUpdate reactTextUpdate = new ReactTextUpdate( text, jsEventCounter, false, textAlign, textBreakStrategy, justificationMode); reactTextUpdate.mContainsMultipleFragments = containsMultipleFragments; + reactTextUpdate.mAccessibilityErrorMessage = accessibilityErrorMessage; return reactTextUpdate; } + public @Nullable String getScreenreaderError() { + return mAccessibilityErrorMessage; + } + public Spannable getText() { return mText; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/BUCK b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/BUCK index 233f1e12611afa..67538981f1baad 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/BUCK @@ -34,6 +34,7 @@ rn_android_library( react_native_target("java/com/facebook/react/common/mapbuffer:mapbuffer"), react_native_target("java/com/facebook/react/views/view:view"), react_native_target("java/com/facebook/react/config:config"), + react_native_target("res:uimanager"), ] + KOTLIN_STDLIB_DEPS, exported_deps = [ react_native_dep("third-party/android/androidx:appcompat"), diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 6e2e24cc29eaa1..8c1b306810e3ad 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -30,6 +30,7 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; +import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; @@ -37,8 +38,10 @@ import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatEditText; import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; +import com.facebook.react.R; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactSoftExceptionLogger; import com.facebook.react.common.build.ReactBuildConfig; @@ -157,6 +160,36 @@ public ReactEditText(Context context) { ReactAccessibilityDelegate editTextAccessibilityDelegate = new ReactAccessibilityDelegate( this, this.isFocusable(), this.getImportantForAccessibility()) { + @Override + public void onInitializeAccessibilityNodeInfo( + View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + final String accessibilityErrorMessage = + (String) host.getTag(R.id.accessibility_error_message); + boolean contentInvalid = accessibilityErrorMessage == null ? false : true; + if (accessibilityErrorMessage != info.getError()) { + info.setError(accessibilityErrorMessage); + info.setContentInvalid(contentInvalid); + } + } + + @Override + public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(host, event); + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED + && host.getParent() != null) { + try { + host.getParent().requestSendAccessibilityEvent(host, event); + } catch (AbstractMethodError e) { + FLog.w( + TAG, + host.getParent().getClass().getSimpleName() + + " does not fully implement ViewParent", + e); + } + } + } + @Override public boolean performAccessibilityAction(View host, int action, Bundle args) { if (action == AccessibilityNodeInfo.ACTION_CLICK) { @@ -539,6 +572,25 @@ public int incrementAndGetEventCounter() { return ++mNativeEventCount; } + /** + * Attempt to set an accessibility error or fail silently. EventCounter is the same one used as + * with text. + * + * @param eventCounter + * @param accessibilityErrorMessage + */ + public void maybeSetAccessibilityError( + int eventCounter, @Nullable String accessibilityErrorMessage) { + String previousScreenreaderError = (String) getTag(R.id.accessibility_error_message); + if (!canUpdateWithEventCount(eventCounter) + || previousScreenreaderError == accessibilityErrorMessage) { + return; + } + + setTag(R.id.accessibility_error_message, accessibilityErrorMessage); + sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); + } + public void maybeSetTextFromJS(ReactTextUpdate reactTextUpdate) { mIsSettingTextFromJS = true; maybeSetText(reactTextUpdate); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java index 7059ca3ed48846..e67955e27fd6f8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java @@ -384,6 +384,7 @@ public void updateExtraData(ReactEditText view, Object extraData) { view.maybeSetTextFromState(update); view.maybeSetSelection(update.getJsEventCounter(), selectionStart, selectionEnd); + view.maybeSetAccessibilityError(update.getJsEventCounter(), update.getScreenreaderError()); } } @@ -1338,6 +1339,12 @@ public Object updateState( int currentJustificationMode = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? 0 : view.getJustificationMode(); + @Nullable + String accessibilityErrorMessage = + props.hasKey("accessibilityErrorMessage") + ? props.getString("accessibilityErrorMessage") + : null; + return ReactTextUpdate.buildReactTextUpdateFromState( spanned, state.getInt("mostRecentEventCount"), @@ -1345,7 +1352,8 @@ public Object updateState( props, TextLayoutManager.isRTL(attributedString), view.getGravityHorizontal()), textBreakStrategy, TextAttributeProps.getJustificationMode(props, currentJustificationMode), - containsMultipleFragments); + containsMultipleFragments, + accessibilityErrorMessage); } public Object getReactTextUpdate(ReactEditText view, ReactStylesDiffMap props, MapBuffer state) { @@ -1365,7 +1373,6 @@ public Object getReactTextUpdate(ReactEditText view, ReactStylesDiffMap props, M Spannable spanned = TextLayoutManagerMapBuffer.getOrCreateSpannableForText( view.getContext(), attributedString, mReactTextViewManagerCallback); - boolean containsMultipleFragments = attributedString.getMapBuffer(TextLayoutManagerMapBuffer.AS_KEY_FRAGMENTS).getCount() > 1; @@ -1375,6 +1382,12 @@ public Object getReactTextUpdate(ReactEditText view, ReactStylesDiffMap props, M int currentJustificationMode = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? 0 : view.getJustificationMode(); + @Nullable + String accessibilityErrorMessage = + props.hasKey("accessibilityErrorMessage") + ? props.getString("accessibilityErrorMessage") + : null; + return ReactTextUpdate.buildReactTextUpdateFromState( spanned, state.getInt(TX_STATE_KEY_MOST_RECENT_EVENT_COUNT), @@ -1382,6 +1395,7 @@ public Object getReactTextUpdate(ReactEditText view, ReactStylesDiffMap props, M props, TextLayoutManagerMapBuffer.isRTL(attributedString), view.getGravityHorizontal()), textBreakStrategy, TextAttributeProps.getJustificationMode(props, currentJustificationMode), - containsMultipleFragments); + containsMultipleFragments, + accessibilityErrorMessage); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java index d53a7f9083aa60..b9a8d926918f30 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java @@ -46,11 +46,15 @@ public class ReactTextInputShadowNode extends ReactBaseTextShadowNode @VisibleForTesting public static final String PROP_PLACEHOLDER = "placeholder"; @VisibleForTesting public static final String PROP_SELECTION = "selection"; + @VisibleForTesting + public static final String PROP_ACCESSIBILITY_ERROR_MESSAGE = "accessibilityErrorMessage"; + // Represents the {@code text} property only, not possible nested content. private @Nullable String mText = null; private @Nullable String mPlaceholder = null; private int mSelectionStart = UNSET; private int mSelectionEnd = UNSET; + private @Nullable String mAccessibilityErrorMessage = null; public ReactTextInputShadowNode( @Nullable ReactTextViewManagerCallback reactTextViewManagerCallback) { @@ -194,6 +198,11 @@ public void setPlaceholder(@Nullable String placeholder) { return mPlaceholder; } + @ReactProp(name = PROP_ACCESSIBILITY_ERROR_MESSAGE) + public void setScreenreaderError(String accessibilityErrorMessage) { + mAccessibilityErrorMessage = accessibilityErrorMessage; + } + @ReactProp(name = PROP_SELECTION) public void setSelection(@Nullable ReadableMap selection) { mSelectionStart = mSelectionEnd = UNSET; @@ -247,7 +256,8 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { mTextBreakStrategy, mJustificationMode, mSelectionStart, - mSelectionEnd); + mSelectionEnd, + mAccessibilityErrorMessage); uiViewOperationQueue.enqueueUpdateExtraData(getReactTag(), reactTextUpdate); } } diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 6324b85af44673..0d7621cb058211 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -39,7 +39,10 @@ - + + + + diff --git a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp index 8c0900480c4c54..7e6de53abce9fc 100644 --- a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp +++ b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp @@ -68,6 +68,10 @@ AndroidTextInputProps::AndroidTextInputProps( "underlineColorAndroid", sourceProps.underlineColorAndroid, {})), + accessibilityErrorMessage(convertRawProp(context, rawProps, + "accessibilityErrorMessage", + sourceProps.accessibilityErrorMessage, + {})), inlineImageLeft(CoreFeatures::enablePropIteratorSetter? sourceProps.inlineImageLeft : convertRawProp(context, rawProps, "inlineImageLeft", sourceProps.inlineImageLeft, @@ -431,6 +435,7 @@ folly::dynamic AndroidTextInputProps::getDynamic() const { props["disableFullscreenUI"] = disableFullscreenUI; props["textBreakStrategy"] = textBreakStrategy; props["underlineColorAndroid"] = toAndroidRepr(underlineColorAndroid); + props["accessibilityErrorMessage"] = accessibilityErrorMessage; props["inlineImageLeft"] = inlineImageLeft; props["inlineImagePadding"] = inlineImagePadding; props["importantForAutofill"] = importantForAutofill; diff --git a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h index 7652984ce15e86..6c627949aee18d 100644 --- a/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h +++ b/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h @@ -120,6 +120,7 @@ class AndroidTextInputProps final : public ViewProps, public BaseTextProps { bool disableFullscreenUI{false}; std::string textBreakStrategy{}; SharedColor underlineColorAndroid{}; + std::string accessibilityErrorMessage{}; std::string inlineImageLeft{}; int inlineImagePadding{0}; std::string importantForAutofill{}; diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 268550b80624eb..da0840dadfaa76 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} + /> +