Skip to content

Commit 502abcd

Browse files
twasilczykmeta-codesync[bot]
authored andcommitted
Better link detection in inline text (#57853)
Summary: Pull Request resolved: #57853 Improved link detection to make them appear in Gaze and TalkBack: - Adds support for button RN role in addition to link. These are both triggered by link elements, but the former happens when `<a>` tag is without a `href` - for example `<a href="#" onclick="...">`. - Don't ignore AX role when RN role is set. The latter gets inherited from ancestor, so `axrole=link` under a parent that's `role=header` was not considered a link. Changelog: [Internal] Reviewed By: javache Differential Revision: D115058930
1 parent 892ea5e commit 502abcd

22 files changed

Lines changed: 301 additions & 79 deletions

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<eb0bafceba60cb439a4be3d68b890725>>
7+
* @generated SignedSource<<35388e99bb5b778abade6d1d435eb797>>
88
*/
99

1010
/**
@@ -324,6 +324,12 @@ public object ReactNativeFeatureFlags {
324324
@JvmStatic
325325
public fun enablePropsUpdateReconciliationAndroid(): Boolean = accessor.enablePropsUpdateReconciliationAndroid()
326326

327+
/**
328+
* When enabled, inline text with a link-like role produces a ClickableSpan and appears in the TalkBack links rotor.
329+
*/
330+
@JvmStatic
331+
public fun enableRelaxedLinkRole(): Boolean = accessor.enableRelaxedLinkRole()
332+
327333
/**
328334
* Enables the ResizeObserver Web API in React Native.
329335
*/

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<0107593110db613063b6f4ceb50d8e58>>
7+
* @generated SignedSource<<407328ccc8d2611b10d7c39458b859f9>>
88
*/
99

1010
/**
@@ -69,6 +69,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
6969
private var enablePreallocatedPropsDiffOnInsertAndroidCache: Boolean? = null
7070
private var enablePreparedTextLayoutCache: Boolean? = null
7171
private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null
72+
private var enableRelaxedLinkRoleCache: Boolean? = null
7273
private var enableResizeObserverByDefaultCache: Boolean? = null
7374
private var enableSwiftUIBasedFiltersCache: Boolean? = null
7475
private var enableViewCullingCache: Boolean? = null
@@ -555,6 +556,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
555556
return cached
556557
}
557558

559+
override fun enableRelaxedLinkRole(): Boolean {
560+
var cached = enableRelaxedLinkRoleCache
561+
if (cached == null) {
562+
cached = ReactNativeFeatureFlagsCxxInterop.enableRelaxedLinkRole()
563+
enableRelaxedLinkRoleCache = cached
564+
}
565+
return cached
566+
}
567+
558568
override fun enableResizeObserverByDefault(): Boolean {
559569
var cached = enableResizeObserverByDefaultCache
560570
if (cached == null) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<fa0374808e3cbe7122ca993780c0b627>>
7+
* @generated SignedSource<<5bda11ded6763e8ed9c953203a619662>>
88
*/
99

1010
/**
@@ -126,6 +126,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
126126

127127
@DoNotStrip @JvmStatic public external fun enablePropsUpdateReconciliationAndroid(): Boolean
128128

129+
@DoNotStrip @JvmStatic public external fun enableRelaxedLinkRole(): Boolean
130+
129131
@DoNotStrip @JvmStatic public external fun enableResizeObserverByDefault(): Boolean
130132

131133
@DoNotStrip @JvmStatic public external fun enableSwiftUIBasedFilters(): Boolean

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<768077a22c110aa98ab02bc96eec695b>>
7+
* @generated SignedSource<<7f1fbd35f6088e754c3a5b74cb8c579a>>
88
*/
99

1010
/**
@@ -121,6 +121,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
121121

122122
override fun enablePropsUpdateReconciliationAndroid(): Boolean = false
123123

124+
override fun enableRelaxedLinkRole(): Boolean = false
125+
124126
override fun enableResizeObserverByDefault(): Boolean = false
125127

126128
override fun enableSwiftUIBasedFilters(): Boolean = false

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<86ebc7307e7ebe2023e909a868ed3117>>
7+
* @generated SignedSource<<33d7cb35437e6bd722507abeaac7845e>>
88
*/
99

1010
/**
@@ -73,6 +73,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
7373
private var enablePreallocatedPropsDiffOnInsertAndroidCache: Boolean? = null
7474
private var enablePreparedTextLayoutCache: Boolean? = null
7575
private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null
76+
private var enableRelaxedLinkRoleCache: Boolean? = null
7677
private var enableResizeObserverByDefaultCache: Boolean? = null
7778
private var enableSwiftUIBasedFiltersCache: Boolean? = null
7879
private var enableViewCullingCache: Boolean? = null
@@ -608,6 +609,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
608609
return cached
609610
}
610611

612+
override fun enableRelaxedLinkRole(): Boolean {
613+
var cached = enableRelaxedLinkRoleCache
614+
if (cached == null) {
615+
cached = currentProvider.enableRelaxedLinkRole()
616+
accessedFeatureFlags.add("enableRelaxedLinkRole")
617+
enableRelaxedLinkRoleCache = cached
618+
}
619+
return cached
620+
}
621+
611622
override fun enableResizeObserverByDefault(): Boolean {
612623
var cached = enableResizeObserverByDefaultCache
613624
if (cached == null) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<c83fba10dc6f7d596e3bcd9725c64e6e>>
7+
* @generated SignedSource<<a4bf2736bf202873ffbfbcc9ddcb970c>>
88
*/
99

1010
/**
@@ -121,6 +121,8 @@ public interface ReactNativeFeatureFlagsProvider {
121121

122122
@DoNotStrip public fun enablePropsUpdateReconciliationAndroid(): Boolean
123123

124+
@DoNotStrip public fun enableRelaxedLinkRole(): Boolean
125+
124126
@DoNotStrip public fun enableResizeObserverByDefault(): Boolean
125127

126128
@DoNotStrip public fun enableSwiftUIBasedFilters(): Boolean

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,21 @@ internal object TextLayoutManager {
292292
}
293293
}
294294

295+
private fun isRoleLinkLike(
296+
role: ReactAccessibilityDelegate.Role?,
297+
accessibilityRole: ReactAccessibilityDelegate.AccessibilityRole?,
298+
): Boolean {
299+
if (!ReactNativeFeatureFlags.enableRelaxedLinkRole()) {
300+
return if (role != null) (role == ReactAccessibilityDelegate.Role.LINK)
301+
else (accessibilityRole == ReactAccessibilityDelegate.AccessibilityRole.LINK)
302+
}
303+
304+
return role == ReactAccessibilityDelegate.Role.LINK ||
305+
accessibilityRole == ReactAccessibilityDelegate.AccessibilityRole.LINK ||
306+
role == ReactAccessibilityDelegate.Role.BUTTON ||
307+
accessibilityRole == ReactAccessibilityDelegate.AccessibilityRole.BUTTON
308+
}
309+
295310
@OptIn(UnstableReactNativeAPI::class)
296311
private fun buildSpannableFromFragments(
297312
assets: AssetManager,
@@ -334,13 +349,7 @@ internal object TextLayoutManager {
334349
),
335350
)
336351
} else if (end >= start) {
337-
val roleIsLink =
338-
if (textAttributes.role != null)
339-
(textAttributes.role == ReactAccessibilityDelegate.Role.LINK)
340-
else
341-
(textAttributes.accessibilityRole ==
342-
ReactAccessibilityDelegate.AccessibilityRole.LINK)
343-
if (roleIsLink) {
352+
if (isRoleLinkLike(textAttributes.role, textAttributes.accessibilityRole)) {
344353
if (ReactNativeFeatureFlags.enablePreparedTextLayout()) {
345354
ops.add(SetSpanOperation(start, end, ReactLinkSpan(i)))
346355
} else {
@@ -556,14 +565,7 @@ internal object TextLayoutManager {
556565
spanFlags,
557566
)
558567
} else {
559-
val roleIsLink =
560-
if (fragment.props.role != null)
561-
(fragment.props.role == ReactAccessibilityDelegate.Role.LINK)
562-
else
563-
(fragment.props.accessibilityRole ==
564-
ReactAccessibilityDelegate.AccessibilityRole.LINK)
565-
566-
if (roleIsLink) {
568+
if (isRoleLinkLike(fragment.props.role, fragment.props.accessibilityRole)) {
567569
if (ReactNativeFeatureFlags.enablePreparedTextLayout()) {
568570
spannable.setSpan(ReactLinkSpan(i), start, end, spanFlags)
569571
} else {

‎packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<200266835197634d13de07182d80a2c1>>
7+
* @generated SignedSource<<41b604c34178a3c904f96ad4bcaadd97>>
88
*/
99

1010
/**
@@ -333,6 +333,12 @@ class ReactNativeFeatureFlagsJavaProvider
333333
return method(javaProvider_);
334334
}
335335

336+
bool enableRelaxedLinkRole() override {
337+
static const auto method =
338+
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("enableRelaxedLinkRole");
339+
return method(javaProvider_);
340+
}
341+
336342
bool enableResizeObserverByDefault() override {
337343
static const auto method =
338344
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("enableResizeObserverByDefault");
@@ -846,6 +852,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enablePropsUpdateReconciliationAndroid(
846852
return ReactNativeFeatureFlags::enablePropsUpdateReconciliationAndroid();
847853
}
848854

855+
bool JReactNativeFeatureFlagsCxxInterop::enableRelaxedLinkRole(
856+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
857+
return ReactNativeFeatureFlags::enableRelaxedLinkRole();
858+
}
859+
849860
bool JReactNativeFeatureFlagsCxxInterop::enableResizeObserverByDefault(
850861
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
851862
return ReactNativeFeatureFlags::enableResizeObserverByDefault();
@@ -1244,6 +1255,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
12441255
makeNativeMethod(
12451256
"enablePropsUpdateReconciliationAndroid",
12461257
JReactNativeFeatureFlagsCxxInterop::enablePropsUpdateReconciliationAndroid),
1258+
makeNativeMethod(
1259+
"enableRelaxedLinkRole",
1260+
JReactNativeFeatureFlagsCxxInterop::enableRelaxedLinkRole),
12471261
makeNativeMethod(
12481262
"enableResizeObserverByDefault",
12491263
JReactNativeFeatureFlagsCxxInterop::enableResizeObserverByDefault),

‎packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<13aa248dae556b06b31c25a28e448460>>
7+
* @generated SignedSource<<70b5fc340fe72a4abc3b05e01c87b38b>>
88
*/
99

1010
/**
@@ -177,6 +177,9 @@ class JReactNativeFeatureFlagsCxxInterop
177177
static bool enablePropsUpdateReconciliationAndroid(
178178
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
179179

180+
static bool enableRelaxedLinkRole(
181+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
182+
180183
static bool enableResizeObserverByDefault(
181184
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
182185

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.text
9+
10+
import android.text.Spannable
11+
import android.text.style.ClickableSpan
12+
import com.facebook.react.common.mapbuffer.WritableMapBuffer
13+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
14+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults
15+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
16+
import com.facebook.react.uimanager.DisplayMetricsHolder
17+
import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role
18+
import org.assertj.core.api.Assertions.assertThat
19+
import org.junit.After
20+
import org.junit.Before
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import org.robolectric.RobolectricTestRunner
24+
import org.robolectric.RuntimeEnvironment
25+
import org.robolectric.annotation.Config
26+
27+
@RunWith(RobolectricTestRunner::class)
28+
@Config(sdk = [34])
29+
class TextLayoutManagerRelaxedLinkRoleTest {
30+
31+
@Before
32+
fun setUp() {
33+
ReactNativeFeatureFlagsForTests.setUp()
34+
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication())
35+
}
36+
37+
@After
38+
fun tearDown() {
39+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
40+
ReactNativeFeatureFlags.dangerouslyReset()
41+
}
42+
43+
@Test
44+
fun `role=button becomes a link span when enableRelaxedLinkRole is on`() {
45+
setRelaxedLinkRole(true)
46+
assertThat(spannable(role = Role.BUTTON).clickableSpanCount()).isEqualTo(1)
47+
}
48+
49+
@Test
50+
fun `role=button stays plain when enableRelaxedLinkRole is off`() {
51+
setRelaxedLinkRole(false)
52+
assertThat(spannable(role = Role.BUTTON).clickableSpanCount()).isZero()
53+
}
54+
55+
@Test
56+
fun `heading role with link accessibilityRole is detected when enableRelaxedLinkRole is on`() {
57+
setRelaxedLinkRole(true)
58+
assertThat(spannable(role = Role.HEADING, accessibilityRole = "link").clickableSpanCount())
59+
.isEqualTo(1)
60+
}
61+
62+
@Test
63+
fun `heading role with link accessibilityRole stays plain when enableRelaxedLinkRole is off`() {
64+
setRelaxedLinkRole(false)
65+
assertThat(spannable(role = Role.HEADING, accessibilityRole = "link").clickableSpanCount())
66+
.isZero()
67+
}
68+
69+
private fun setRelaxedLinkRole(enabled: Boolean) =
70+
ReactNativeFeatureFlags.override(
71+
object : ReactNativeFeatureFlagsDefaults() {
72+
override fun enableRelaxedLinkRole(): Boolean = enabled
73+
}
74+
)
75+
76+
private fun Spannable.clickableSpanCount(): Int =
77+
getSpans(0, length, ClickableSpan::class.java).size
78+
79+
private fun spannable(role: Role? = null, accessibilityRole: String? = null): Spannable {
80+
val attrs =
81+
WritableMapBuffer().apply {
82+
put(TextAttributeProps.TA_KEY_FONT_SIZE, 16.0)
83+
role?.let { put(TextAttributeProps.TA_KEY_ROLE, it.ordinal) }
84+
accessibilityRole?.let { put(TextAttributeProps.TA_KEY_ACCESSIBILITY_ROLE, it) }
85+
}
86+
val fragment =
87+
WritableMapBuffer().apply {
88+
put(TextLayoutManager.FR_KEY_STRING, "Follow")
89+
put(TextLayoutManager.FR_KEY_REACT_TAG, 1)
90+
put(TextLayoutManager.FR_KEY_TEXT_ATTRIBUTES, attrs)
91+
}
92+
val attributedString =
93+
WritableMapBuffer().apply {
94+
put(TextLayoutManager.AS_KEY_STRING, "Follow")
95+
put(TextLayoutManager.AS_KEY_FRAGMENTS, WritableMapBuffer().put(0, fragment))
96+
}
97+
return TextLayoutManager.getOrCreateSpannableForText(
98+
RuntimeEnvironment.getApplication().assets,
99+
attributedString,
100+
null,
101+
)
102+
}
103+
}

0 commit comments

Comments
 (0)