From 6c5b5b411379b56d8cc6f70f8569bb4b2cac0ed5 Mon Sep 17 00:00:00 2001 From: BilalWalker Date: Mon, 12 Oct 2026 15:15:20 +0500 Subject: [PATCH 1/2] Set AccessibilityNodeInfo stateDescription for checked state on Android TalkBack speaks the unchecked state of a checkable node, and avoids announcing "checked" and "on" together for a switch, only when the node carries a stateDescription (API 30+). Native CompoundButton sets it, but ReactAccessibilityDelegate never did, so a React Native checkbox or radio with checked: false was announced with no state at all, and a switch with checked: true was read as "checked. on.". Set stateDescription whenever the checked state is a boolean: "on"/"off" for the switch role and "checked"/"not checked" otherwise, using the existing uimanager string resources. Fixes #58739 Fixes #58588 --- .../uimanager/ReactAccessibilityDelegate.kt | 24 ++++- .../res/views/uimanager/values/strings.xml | 8 ++ .../ReactAccessibilityDelegateTest.kt | 95 +++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt index 6a227761f256..5a555478ef08 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt @@ -99,7 +99,7 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac // state is changeable. val accessibilityState = host.getTag(R.id.accessibility_state) as ReadableMap? if (accessibilityState != null) { - setState(info, accessibilityState) + setState(info, accessibilityState, accessibilityRole, host.context) } val accessibilityActions = host.getTag(R.id.accessibility_actions) as ReadableArray? @@ -626,6 +626,8 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac private fun setState( info: AccessibilityNodeInfoCompat, accessibilityState: ReadableMap, + role: AccessibilityRole?, + context: Context, ) { val i = accessibilityState.keySetIterator() while (i.hasNextKey()) { @@ -639,10 +641,30 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac val boolValue = value.asBoolean() info.isCheckable = true info.isChecked = boolValue + // TalkBack only speaks the unchecked state of a checkable node, and only avoids + // announcing "checked" and "on" together for a switch, when the node carries a + // stateDescription (API 30+). Native CompoundButton always sets it, so do the same. + info.stateDescription = getCheckedStateDescription(boolValue, role, context) } } } + private fun getCheckedStateDescription( + checked: Boolean, + role: AccessibilityRole?, + context: Context, + ): String = + if (role == AccessibilityRole.SWITCH) { + context.getString( + if (checked) R.string.state_on_description else R.string.state_off_description + ) + } else { + context.getString( + if (checked) R.string.state_checked_description + else R.string.state_unchecked_description + ) + } + // TODO: Eventually support for other languages on talkback @JvmStatic public fun setRole( diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml index a2f6ad526315..4996785d70a0 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml @@ -96,6 +96,14 @@ name="state_off_description" description="a switch in its disabled state" >off + checked + not checked Date: Tue, 13 Oct 2026 14:11:37 +0500 Subject: [PATCH 2/2] Only set stateDescription for the unchecked state of non-switch roles TalkBack already announces the checked state of a checkable node in the device language. React Native's state strings are translated by Meta's internal pipeline after import, so a new string would be English-only for a while. Keep the switch on/off strings, which are already translated in every locale, and only fill the case TalkBack is silent on: unchecked checkboxes and radios. Drop the unused state_checked_description. --- .../uimanager/ReactAccessibilityDelegate.kt | 18 +++++++++++------- .../res/views/uimanager/values/strings.xml | 4 ---- .../ReactAccessibilityDelegateTest.kt | 5 ++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt index 5a555478ef08..8f44bd3a7ad6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt @@ -643,8 +643,11 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac info.isChecked = boolValue // TalkBack only speaks the unchecked state of a checkable node, and only avoids // announcing "checked" and "on" together for a switch, when the node carries a - // stateDescription (API 30+). Native CompoundButton always sets it, so do the same. - info.stateDescription = getCheckedStateDescription(boolValue, role, context) + // stateDescription (API 30+), as native CompoundButton sets. + val stateDescription = getCheckedStateDescription(boolValue, role, context) + if (stateDescription != null) { + info.stateDescription = stateDescription + } } } } @@ -653,16 +656,17 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac checked: Boolean, role: AccessibilityRole?, context: Context, - ): String = + ): String? = if (role == AccessibilityRole.SWITCH) { context.getString( if (checked) R.string.state_on_description else R.string.state_off_description ) + } else if (!checked) { + context.getString(R.string.state_unchecked_description) } else { - context.getString( - if (checked) R.string.state_checked_description - else R.string.state_unchecked_description - ) + // Leave the checked state to TalkBack, which already announces it in the device + // language. + null } // TODO: Eventually support for other languages on talkback diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml index 4996785d70a0..1a032eb255ae 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml @@ -96,10 +96,6 @@ name="state_off_description" description="a switch in its disabled state" >off - checked