From f912da089d0448c09d682ef754d201b173b6c2c3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 18:47:28 +0300 Subject: [PATCH 1/3] Keep focusable={false} Android views out of keyboard navigation An accessible view keeps isFocusable for accessibility services, so setFocusable(false) left it in Tab and arrow-key navigation: every Pressable is accessible, and tabIndex={-1} or focusable={false} on one changed nothing about where a hardware keyboard moved focus. ReactViewGroup now records the prop and skips the view in addFocusables and requestFocus, the two ways keyboard navigation reaches it, while its descendants, accessibility services and requestFocusFromJS still do. --- .../react/views/view/ReactViewGroup.kt | 24 ++++ .../react/views/view/ReactViewManager.kt | 3 +- .../react/views/view/ReactViewGroupTest.kt | 113 ++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index c861a961cf35..207da713fec2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -160,6 +160,12 @@ public open class ReactViewGroup public constructor(context: Context?) : null private var focusOnAttach = false + /** + * Set from the `focusable` prop. [isFocusable] also carries `accessible`, so a view excluded here + * stays focusable for accessibility services while keyboard navigation passes over it. + */ + internal var excludedFromKeyboardFocus = false + internal var nativeBackgroundMap: ReadableMap? = null internal var nativeForegroundMap: ReadableMap? = null @@ -201,6 +207,7 @@ public open class ReactViewGroup public constructor(context: Context?) : childrenRemovedWhileTransitioning = null nativeBackgroundMap = null nativeForegroundMap = null + excludedFromKeyboardFocus = false } internal open fun recycleView() { @@ -508,6 +515,23 @@ public open class ReactViewGroup public constructor(context: Context?) : updateClippingToRect(clippingRect, excludedViews) } + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + // A focused view stays a candidate so that focus search can move on from it. + if (excludedFromKeyboardFocus && !isFocused) { + views.subList(firstAddedIndex, views.size).remove(this) + } + } + + override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean { + if (!excludedFromKeyboardFocus) { + return super.requestFocus(direction, previouslyFocusedRect) + } + return descendantFocusability != FOCUS_BLOCK_DESCENDANTS && + onRequestFocusInDescendants(direction, previouslyFocusedRect) + } + internal fun requestFocusFromJS() { if (isAttachedToWindow) { super.requestFocus(FOCUS_DOWN, null) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt index 21ef11182da3..75dde5d6ff96 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt @@ -421,6 +421,7 @@ public open class ReactViewManager : ReactClippingViewManager() @ReactProp(name = "focusable") public open fun setFocusable(view: ReactViewGroup, focusable: Boolean) { + view.excludedFromKeyboardFocus = !focusable if (focusable) { view.setOnClickListener { val eventDispatcher = UIManagerHelper.getEventDispatcher(view.context as ReactContext) @@ -436,7 +437,7 @@ public open class ReactViewManager : ReactClippingViewManager() view.setOnClickListener(null) view.isClickable = false // Don't set view.setFocusable(false) because we might still want it to be focusable for - // accessibility reasons + // accessibility reasons. excludedFromKeyboardFocus keeps it out of keyboard navigation. } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt index 4bc272951144..5a6ae4530ec4 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt @@ -12,6 +12,7 @@ import android.content.Context import android.graphics.BlendMode import android.graphics.Canvas import android.graphics.Paint +import android.view.FocusFinder import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -290,6 +291,118 @@ class ReactViewGroupTest { assertThat(outer.hasOverlappingRendering()).isTrue() } + @Test + fun `focusable false - keeps an accessible view out of keyboard focus search`() { + val root = ReactViewGroup(context) + val leading = focusableView(top = 0) + val excluded = accessibleView(top = 10, focusable = false) + val trailing = focusableView(top = 20) + root.addView(leading) + root.addView(excluded) + root.addView(trailing) + + assertThat(collectKeyboardFocusables(root)).containsExactly(leading, trailing) + // Accessibility services still treat the view as one focusable element. + assertThat(excluded.isFocusable).isTrue() + } + + @Test + fun `focusable false - keeps the descendants of an excluded view in keyboard focus search`() { + val root = ReactViewGroup(context) + val excluded = accessibleView(top = 0, focusable = false) + val descendant = focusableView(top = 0) + excluded.addView(descendant) + root.addView(excluded) + + assertThat(collectKeyboardFocusables(root)).containsExactly(descendant) + } + + @Test + fun `focusable false - default focus passes over an excluded view`() { + val root = ReactViewGroup(context) + val excluded = accessibleView(top = 0, focusable = false) + val trailing = focusableView(top = 10) + root.addView(excluded) + root.addView(trailing) + + assertThat(root.requestFocus(View.FOCUS_DOWN)).isTrue() + + assertThat(excluded.isFocused).isFalse() + assertThat(trailing.isFocused).isTrue() + } + + @Test + fun `focusable false - focus requested from JS still reaches an excluded view`() { + val root = ReactViewGroup(context) + val excluded = accessibleView(top = 0, focusable = false) + root.addView(excluded) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + + excluded.requestFocusFromJS() + + assertThat(excluded.isFocused).isTrue() + } + + @Test + fun `focusable false - keyboard focus moves on from an excluded view that holds focus`() { + val root = ReactViewGroup(context) + val leading = focusableView(top = 0) + val excluded = accessibleView(top = 10, focusable = false) + val trailing = focusableView(top = 20) + root.addView(leading) + root.addView(excluded) + root.addView(trailing) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + excluded.requestFocusFromJS() + + assertThat(FocusFinder.getInstance().findNextFocus(root, excluded, View.FOCUS_FORWARD)) + .isSameAs(trailing) + assertThat(FocusFinder.getInstance().findNextFocus(root, excluded, View.FOCUS_BACKWARD)) + .isSameAs(leading) + } + + @Test + fun `focusable true - returns a view to keyboard focus search`() { + val root = ReactViewGroup(context) + val view = accessibleView(top = 0, focusable = false) + root.addView(view) + + ReactViewManager().setFocusable(view, true) + + assertThat(collectKeyboardFocusables(root)).containsExactly(view) + } + + @Test + fun `focusable false - recycling a view returns it to keyboard focus search`() { + val root = ReactViewGroup(context) + val view = accessibleView(top = 0, focusable = false) + + view.recycleView() + root.addView(view) + + assertThat(collectKeyboardFocusables(root)).containsExactly(view) + } + + private fun focusableView(top: Int): View = + View(context).apply { + isFocusable = true + layout(0, top, 100, top + 10) + } + + private fun accessibleView(top: Int, focusable: Boolean): ReactViewGroup = + ReactViewGroup(context).apply { + layout(0, top, 100, top + 10) + val manager = ReactViewManager() + manager.setAccessible(this, true) + manager.setFocusable(this, focusable) + } + + private fun collectKeyboardFocusables(root: ViewGroup): List { + val focusables = ArrayList() + root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL) + return focusables + } + // Mirrors the ViewManager mount/unmount hooks: attach/detach the child, then refresh the cache. private fun mount(parent: ReactViewGroup, child: View) { parent.addView(child) From b969e8393335412b889aa1378520cc1063077e17 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 12:33:49 +0300 Subject: [PATCH 2/3] Pass over a scroll view's focus candidate that takes no keyboard focus When the next focus lies outside a scroll view, ReactScrollView and its horizontal and nested siblings ask the shadow tree for the next focusable element inside it, and that search offers every focusable or accessible node. A view focusable={false} keeps out of keyboard navigation is still accessible, so the search handed it back, the view refused focus and Tab stopped moving. findNextFocusableView now moves on from a candidate whose addFocusables adds nothing, and ends at a candidate it already offered, since forward candidates can lead back to each other. ReactViewGroup's addFocusables and requestFocus overrides are added to ReactAndroid.api. --- .../ReactAndroid/api/ReactAndroid.api | 2 + .../views/scroll/ReactScrollViewHelper.kt | 41 +++-- .../ReactScrollViewHelperFocusSearchTest.kt | 169 ++++++++++++++++++ 3 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 6c76d3a2197f..29c658339297 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6439,6 +6439,7 @@ public final class com/facebook/react/views/view/ReactDrawableHelper { public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGroup, com/facebook/react/touch/ReactHitSlopView, com/facebook/react/touch/ReactInterceptingViewGroup, com/facebook/react/uimanager/HasElevatedDescendantCache, com/facebook/react/uimanager/ReactClippingViewGroup, com/facebook/react/uimanager/ReactOverflowViewWithInset, com/facebook/react/uimanager/ReactPointerEventsView { public fun (Landroid/content/Context;)V public fun addChildrenForAccessibility (Ljava/util/ArrayList;)V + public fun addFocusables (Ljava/util/ArrayList;II)V public final fun cleanUpAxOrderListener ()V protected fun dispatchDraw (Landroid/graphics/Canvas;)V public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z @@ -6468,6 +6469,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun onViewAdded (Landroid/view/View;)V public fun onViewRemoved (Landroid/view/View;)V + public fun requestFocus (ILandroid/graphics/Rect;)Z public fun requestLayout ()V public final fun setAxOrderList (Ljava/util/List;)V public final fun setBackfaceVisibility (Ljava/lang/String;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt index e6e0660d174b..ef848d6cd9c4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -581,20 +581,41 @@ public object ReactScrollViewHelper { UIManagerHelper.getUIManager(host.context as ReactContext, UIManagerType.FABRIC) ?: return null - val nextFocusableViewId = - (uimanager as FabricUIManager).findNextFocusableElement(host.id, focused.id, direction) - ?: return null + // The shadow tree offers every focusable or accessible node, while a view can still keep + // itself out of keyboard navigation (focusable={false} on an accessible view), so the search + // moves on from a candidate that offers no keyboard focus. Forward and backward candidates can + // lead back to each other, which the visited ids end. + val visitedIds = mutableSetOf(focused.id) + var sourceId = focused.id + while (true) { + val nextFocusableViewId = + (uimanager as FabricUIManager).findNextFocusableElement(host.id, sourceId, direction) + ?: return null + if (!visitedIds.add(nextFocusableViewId)) { + return null + } + + val ancestorIdList = + uimanager + .getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId) + ?.toMutableSet() ?: return null - val ancestorIdList = - uimanager - .getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId) - ?.toMutableSet() ?: return null + ancestorIdList.add(nextFocusableViewId) - ancestorIdList.add(nextFocusableViewId) + host.updateClippingRect(ancestorIdList) - host.updateClippingRect(ancestorIdList) + val nextFocusableView = host.findViewById(nextFocusableViewId) ?: return null + if (offersKeyboardFocus(nextFocusableView, direction)) { + return nextFocusableView + } + sourceId = nextFocusableViewId + } + } - return host.findViewById(nextFocusableViewId) + private fun offersKeyboardFocus(view: View, @FocusDirection direction: Int): Boolean { + val focusables = ArrayList() + view.addFocusables(focusables, direction) + return focusables.isNotEmpty() } @JvmStatic diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt new file mode 100644 index 000000000000..5095e4d075e7 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// The deprecated APIs exercised here remain public and required by this Robolectric setup. +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.scroll + +import android.util.DisplayMetrics +import android.view.View +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.bridge.UIManager +import com.facebook.react.fabric.FabricUIManager +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.UIManagerType +import com.facebook.react.views.view.ReactViewGroup +import com.facebook.react.views.view.ReactViewManager +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The shadow-tree search a scroll view falls back to, when the next focus lies outside it, passes + * over a candidate the view tree keeps out of keyboard navigation. + */ +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) +class ReactScrollViewHelperFocusSearchTest { + + private lateinit var themedContext: ThemedReactContext + private lateinit var uiManagerHelperMock: MockedStatic + private val fabricUIManager: FabricUIManager = mock() + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + themedContext = ThemedReactContext(context, context, null, -1) + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + uiManagerHelperMock = mockStatic(UIManagerHelper::class.java) + uiManagerHelperMock + .`when` { + UIManagerHelper.getUIManager(any(), eq(UIManagerType.FABRIC)) + } + .thenReturn(fabricUIManager) + } + + @After + fun tearDown() { + uiManagerHelperMock.close() + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun `passes over a candidate that focusable false keeps out of keyboard navigation`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + offerInOrder(scene, scene.first, scene.candidate, scene.last) + + assertThat(findNextFocusableView(scene)).isSameAs(scene.last) + } + + @Test + fun `finds nothing when no later candidate takes keyboard focus`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + offerInOrder(scene, scene.first, scene.candidate) + + assertThat(findNextFocusableView(scene)).isNull() + } + + @Test + fun `ends when the candidates lead back to where the search started`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + offerInOrder(scene, scene.first, scene.candidate, scene.first) + + assertThat(findNextFocusableView(scene)).isNull() + } + + @Test + fun `takes a candidate whose descendant takes keyboard focus`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + scene.candidate.addView(focusableView(id = 4)) + offerInOrder(scene, scene.first, scene.candidate, scene.last) + + assertThat(findNextFocusableView(scene)).isSameAs(scene.candidate) + } + + @Test + fun `takes a candidate that takes keyboard focus itself`() { + val scene = buildScene() + offerInOrder(scene, scene.first, scene.candidate, scene.last) + + assertThat(findNextFocusableView(scene)).isSameAs(scene.candidate) + } + + private fun buildScene(): Scene { + val scrollView = ReactScrollView(themedContext).apply { id = 20 } + val content = ReactViewGroup(themedContext).apply { id = 10 } + val first = focusableView(id = 1) + val candidate = + ReactViewGroup(themedContext).apply { + id = 2 + ReactViewManager().setAccessible(this, true) + } + val last = focusableView(id = 3) + content.addView(first) + content.addView(candidate) + content.addView(last) + scrollView.addView(content) + whenever(fabricUIManager.getRelativeAncestorList(eq(content.id), any())) + .thenReturn(intArrayOf(content.id)) + return Scene(scrollView, first, candidate, last) + } + + private fun focusableView(id: Int): View = + View(themedContext).apply { + this.id = id + isFocusable = true + } + + private fun offerInOrder(scene: Scene, vararg order: View) { + order.toList().zipWithNext().forEach { (source, next) -> + whenever( + fabricUIManager.findNextFocusableElement( + eq(scene.scrollView.id), + eq(source.id), + eq(View.FOCUS_FORWARD), + ) + ) + .thenReturn(next.id) + } + } + + private fun findNextFocusableView(scene: Scene): View? = + ReactScrollViewHelper.findNextFocusableView(scene.scrollView, scene.first, View.FOCUS_FORWARD) + + private class Scene( + val scrollView: ReactScrollView, + val first: View, + val candidate: ReactViewGroup, + val last: View, + ) +} From 29f768babf016814cb2ad00179edbeb4567d3229 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 14:13:54 +0300 Subject: [PATCH 3/3] Keep focusable={false} Android text out of keyboard navigation A Text with onPress or onLongPress is accessible, so setAccessible made it focusable, and Tab, Shift+Tab and the arrow keys of a hardware keyboard stopped on it. Text had no focusable prop to take it out, so a pressable label between two inputs was always an extra Tab stop. The text view managers now take focusable, and ReactTextView and PreparedLayoutTextView skip an excluded text in addFocusables and requestFocus while isFocusable stays with accessible, so accessibility services still reach it. A text that holds focus stays a candidate, an inline view inside one stays reachable, and a touch still focuses a selectable text, which text selection needs. TextProps and the legacy Text types declare the prop; the base view config already sends it. --- .../react-native/Libraries/Text/TextProps.js | 9 + .../ReactAndroid/api/ReactAndroid.api | 3 + .../views/text/PreparedLayoutTextView.kt | 24 ++ .../text/PreparedLayoutTextViewManager.kt | 5 + .../react/views/text/ReactTextView.java | 31 +++ .../react/views/text/ReactTextViewManager.kt | 5 + .../react/views/text/TextKeyboardFocusTest.kt | 206 ++++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 9 +- .../types_DEPRECATED/Libraries/Text/Text.d.ts | 7 + 9 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextKeyboardFocusTest.kt diff --git a/packages/react-native/Libraries/Text/TextProps.js b/packages/react-native/Libraries/Text/TextProps.js index c8f28ddc4b49..7e6d7bab4579 100644 --- a/packages/react-native/Libraries/Text/TextProps.js +++ b/packages/react-native/Libraries/Text/TextProps.js @@ -94,6 +94,15 @@ export type TextPropsAndroid = { */ disabled?: ?boolean, + /** + * Whether this `Text` should be focusable with a non-touch input device, eg. + * receive focus with a hardware keyboard. An accessible `Text` is focusable + * unless this is `false`, and accessibility services reach it either way. + * + * @platform android + */ + focusable?: ?boolean, + /** * Highlight color of the text when selected. * diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 29c658339297..40943bdde016 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6018,6 +6018,7 @@ public final class com/facebook/react/views/text/ReactFontManager$Companion { public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/widget/AppCompatTextView, com/facebook/react/uimanager/ReactCompoundView { public fun (Landroid/content/Context;)V + public fun addFocusables (Ljava/util/ArrayList;II)V protected fun dispatchHoverEvent (Landroid/view/MotionEvent;)Z public fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z public fun getSpanned ()Landroid/text/Spannable; @@ -6029,6 +6030,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi protected fun onMeasure (II)V protected fun onSizeChanged (IIII)V public fun reactTagForTouch (FF)I + public fun requestFocus (ILandroid/graphics/Rect;)Z public fun setAdjustFontSizeToFit (Z)V public fun setBackgroundColor (I)V public fun setBorderColor (ILjava/lang/Integer;)V @@ -6085,6 +6087,7 @@ public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/r public final fun setDataDetectorType (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V public final fun setDisabled (Lcom/facebook/react/views/text/ReactTextView;Z)V public final fun setEllipsizeMode (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V + public final fun setFocusable (Lcom/facebook/react/views/text/ReactTextView;Z)V public final fun setFontSize (Lcom/facebook/react/views/text/ReactTextView;F)V public final fun setIncludeFontPadding (Lcom/facebook/react/views/text/ReactTextView;Z)V public final fun setLetterSpacing (Lcom/facebook/react/views/text/ReactTextView;F)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 7caac8f5496e..20eb6e833639 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -90,6 +90,12 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re @ColorInt var selectionColor: Int? = null + /** + * Set from the `focusable` prop. [isFocusable] also carries `accessible`, so a text excluded here + * stays focusable for accessibility services while keyboard navigation passes over it. + */ + var excludedFromKeyboardFocus: Boolean = false + val text: CharSequence? // Avoid mangling the getter name, to allow black box E2E tests to read text content via // reflection @@ -106,6 +112,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re clickableSpans = emptyList() selection = null selectionColor = null + excludedFromKeyboardFocus = false lastFrameTimeNanos = 0L preparedLayout = null } @@ -424,6 +431,23 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } } + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + // A focused view stays a candidate so that focus search can move on from it. + if (excludedFromKeyboardFocus && !isFocused) { + views.subList(firstAddedIndex, views.size).remove(this) + } + } + + override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean { + if (!excludedFromKeyboardFocus) { + return super.requestFocus(direction, previouslyFocusedRect) + } + return descendantFocusability != FOCUS_BLOCK_DESCENDANTS && + onRequestFocusInDescendants(direction, previouslyFocusedRect) + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { val accessibilityDelegateCompat = ViewCompat.getAccessibilityDelegate(this) val delegateHandled = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt index 036695919522..e26c8a124bad 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt @@ -124,6 +124,11 @@ internal class PreparedLayoutTextViewManager : view.isFocusable = accessible } + @ReactProp(name = "focusable", defaultBoolean = true) + fun setFocusable(view: PreparedLayoutTextView, focusable: Boolean) { + view.excludedFromKeyboardFocus = !focusable + } + @ReactProp(name = "selectable", defaultBoolean = false) fun setSelectable(view: PreparedLayoutTextView, isSelectable: Boolean) { check(!isSelectable) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index d388d1d5f365..3b14daa992f0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -53,6 +53,7 @@ import com.facebook.react.views.text.internal.span.ReactTagSpan; import com.facebook.yoga.YogaMeasureMode; import java.lang.reflect.Method; +import java.util.ArrayList; @Nullsafe(Nullsafe.Mode.LOCAL) public class ReactTextView extends AppCompatTextView implements ReactCompoundView { @@ -80,6 +81,13 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private boolean mShouldAdjustSpannableFontSize; private Overflow mOverflow = Overflow.VISIBLE; + /** + * Set from the `focusable` prop. {@link #isFocusable()} also carries `accessible`, so a text + * excluded here stays focusable for accessibility services while keyboard navigation passes over + * it. + */ + private boolean mExcludedFromKeyboardFocus; + private @Nullable Spannable mSpanned; private @Nullable PreparedLayout mPreparedLayout; @@ -132,6 +140,7 @@ private void initView() { mAdjustsFontSizeToFit = false; mLinkifyMaskType = 0; mTextIsSelectable = false; + mExcludedFromKeyboardFocus = false; mShouldAdjustSpannableFontSize = false; mEllipsizeLocation = TextUtils.TruncateAt.END; mFontSize = Float.NaN; @@ -687,6 +696,28 @@ public void setLinkifyMask(int mask) { mLinkifyMaskType = mask; } + /* package */ void setExcludedFromKeyboardFocus(boolean excludedFromKeyboardFocus) { + mExcludedFromKeyboardFocus = excludedFromKeyboardFocus; + } + + @Override + public void addFocusables(ArrayList views, int direction, int focusableMode) { + // A focused view stays a candidate so that focus search can move on from it. + if (mExcludedFromKeyboardFocus && !isFocused()) { + return; + } + super.addFocusables(views, direction, focusableMode); + } + + @Override + public boolean requestFocus(int direction, @Nullable Rect previouslyFocusedRect) { + // Selecting text takes focus from a touch, which `focusable` leaves alone. + if (mExcludedFromKeyboardFocus && !isInTouchMode()) { + return false; + } + return super.requestFocus(direction, previouslyFocusedRect); + } + @Override protected boolean dispatchHoverEvent(MotionEvent event) { // if this view has an accessibility delegate set, and that delegate supports virtual view diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index 8512fccba2f6..2ca19ff6ffba 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -268,6 +268,11 @@ public constructor( view.isFocusable = accessible } + @ReactProp(name = "focusable", defaultBoolean = true) + public fun setFocusable(view: ReactTextView, focusable: Boolean) { + view.setExcludedFromKeyboardFocus(!focusable) + } + // maxLines can only be set in master view (block), doesn't really make sense to set in a span @ReactProp(name = ViewProps.NUMBER_OF_LINES, defaultInt = ViewDefaults.NUMBER_OF_LINES) public fun setNumberOfLines(view: ReactTextView, numberOfLines: Int) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextKeyboardFocusTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextKeyboardFocusTest.kt new file mode 100644 index 000000000000..85b76cd209cc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextKeyboardFocusTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.app.Activity +import android.content.Context +import android.view.FocusFinder +import android.view.View +import android.view.ViewGroup +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.views.view.ReactViewGroup +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +abstract class TextKeyboardFocusTest { + + protected lateinit var context: Context + private lateinit var manager: ViewManager + + protected abstract fun createManager(): ViewManager + + protected abstract fun createTextView(context: Context): T + + protected abstract fun recycle(text: T) + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + context = Robolectric.buildActivity(Activity::class.java).create().get() + manager = createManager() + } + + @Test + fun `focusable false - keeps an accessible text out of keyboard focus search`() { + val root = ReactViewGroup(context) + val leading = focusableView(top = 0) + val text = accessibleText(top = 10, "focusable", false) + val trailing = focusableView(top = 20) + root.addView(leading) + root.addView(text) + root.addView(trailing) + + assertThat(collectKeyboardFocusables(root)).containsExactly(leading, trailing) + // Accessibility services still treat the text as one focusable element. + assertThat(text.isFocusable).isTrue() + } + + @Test + fun `focusable false - default focus passes over an excluded text`() { + val root = ReactViewGroup(context) + val text = accessibleText(top = 0, "focusable", false) + val trailing = focusableView(top = 10) + root.addView(text) + root.addView(trailing) + + assertThat(root.requestFocus(View.FOCUS_DOWN)).isTrue() + + assertThat(text.isFocused).isFalse() + assertThat(trailing.isFocused).isTrue() + } + + @Test + fun `focusable false - keyboard focus moves on from an excluded text that holds focus`() { + val root = ReactViewGroup(context) + val leading = focusableView(top = 0) + val text = accessibleText(top = 10) + val trailing = focusableView(top = 20) + root.addView(leading) + root.addView(text) + root.addView(trailing) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + assertThat(text.requestFocus()).isTrue() + + applyProps(text, "focusable", false) + + assertThat(FocusFinder.getInstance().findNextFocus(root, text, View.FOCUS_FORWARD)) + .isSameAs(trailing) + assertThat(FocusFinder.getInstance().findNextFocus(root, text, View.FOCUS_BACKWARD)) + .isSameAs(leading) + } + + @Test + fun `focusable true - returns a text to keyboard focus search`() { + val root = ReactViewGroup(context) + val text = accessibleText(top = 0, "focusable", false) + root.addView(text) + + applyProps(text, "focusable", true) + + assertThat(collectKeyboardFocusables(root)).containsExactly(text) + } + + @Test + fun `focusable removed - returns a text to keyboard focus search`() { + val root = ReactViewGroup(context) + val text = accessibleText(top = 0, "focusable", false) + root.addView(text) + + applyProps(text, "focusable", null) + + assertThat(collectKeyboardFocusables(root)).containsExactly(text) + } + + @Test + fun `focusable false - recycling a text returns it to keyboard focus search`() { + val root = ReactViewGroup(context) + val text = accessibleText(top = 0, "focusable", false) + + recycle(text) + applyProps(text, "accessible", true) + root.addView(text) + + assertThat(collectKeyboardFocusables(root)).containsExactly(text) + } + + protected fun accessibleText(top: Int, vararg props: Any?): T = + createTextView(context).apply { + layout(0, top, 100, top + 10) + applyProps(this, "accessible", true, *props) + } + + protected fun applyProps(text: T, vararg keysAndValues: Any?) { + manager.updateProperties(text, ReactStylesDiffMap(JavaOnlyMap.of(*keysAndValues))) + } + + protected fun focusableView(top: Int): View = + View(context).apply { + isFocusable = true + layout(0, top, 100, top + 10) + } + + protected fun collectKeyboardFocusables(root: ViewGroup): List { + val focusables = ArrayList() + root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL) + return focusables + } +} + +@RunWith(RobolectricTestRunner::class) +class ReactTextViewKeyboardFocusTest : TextKeyboardFocusTest() { + + override fun createManager(): ViewManager = ReactTextViewManager() + + override fun createTextView(context: Context): ReactTextView = ReactTextView(context) + + override fun recycle(text: ReactTextView) { + text.recycleView() + } + + @After + fun tearDown() { + InstrumentationRegistry.getInstrumentation().setInTouchMode(false) + } + + @Test + fun `focusable false - a touch still focuses an excluded selectable text`() { + InstrumentationRegistry.getInstrumentation().setInTouchMode(true) + val root = ReactViewGroup(context) + val text = accessibleText(top = 0, "focusable", false, "selectable", true) + root.addView(text) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + assertThat(text.isInTouchMode).isTrue() + + assertThat(text.requestFocus()).isTrue() + } +} + +@RunWith(RobolectricTestRunner::class) +internal class PreparedLayoutTextViewKeyboardFocusTest : + TextKeyboardFocusTest() { + + override fun createManager(): ViewManager = + PreparedLayoutTextViewManager() + + override fun createTextView(context: Context): PreparedLayoutTextView = + PreparedLayoutTextView(context) + + override fun recycle(text: PreparedLayoutTextView) { + text.recycleView() + } + + @Test + fun `focusable false - keeps the inline views of an excluded text in keyboard focus search`() { + val root = ReactViewGroup(context) + val text = accessibleText(top = 0, "focusable", false) + val inlineView = focusableView(top = 0) + text.addView(inlineView) + root.addView(text) + + assertThat(collectKeyboardFocusables(root)).containsExactly(inlineView) + } +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..81caa80241a8 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<86b7a1271a969bb08a0328f40290a98d>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5189,6 +5189,7 @@ declare type TextPropsAndroid = { adjustsFontSizeToFit?: boolean dataDetectorType?: "all" | "email" | "link" | "none" | "phoneNumber" disabled?: boolean + focusable?: boolean minimumFontScale?: number selectionColor?: ColorValue textBreakStrategy?: "balanced" | "highQuality" | "simple" @@ -5786,7 +5787,7 @@ export { AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // f6f7758c + Animated, // d8158b48 AppConfig, // 35c0ca70 AppRegistry, // edec2a30 AppState, // 12012be5 @@ -6020,7 +6021,7 @@ export { TVViewPropsIOS, // 1c612e4e TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // 6f8269c1 + Text, // be5d60ae TextContentType, // 239b3ecc TextInput, // cf6467dc TextInputAndroidProps, // 9ebbc103 @@ -6037,7 +6038,7 @@ export { TextInputSubmitEditingEvent, // e811fdbd TextInstance, // 7c7e2069 TextLayoutEvent, // 1a21e24a - TextProps, // ab00c755 + TextProps, // bb88f78f TextStyle, // 898adfd1 ToastAndroid, // 88a8969a TouchableHighlight, // a015710f diff --git a/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts index 25d8d8abe1ef..eebeb5314858 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts @@ -61,6 +61,13 @@ export interface TextPropsAndroid { */ disabled?: boolean | undefined; + /** + * Whether this `Text` should be focusable with a non-touch input device, eg. receive focus with + * a hardware keyboard. An accessible `Text` is focusable unless this is `false`, and + * accessibility services reach it either way. + */ + focusable?: boolean | undefined; + /** * Lets the user select text, to use the native copy and paste functionality. */