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 6c76d3a2197f..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 @@ -6439,6 +6442,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 +6472,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/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/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/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, + ) +} 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/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) 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. */