diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 6c76d3a2197f..b5064c467d9c 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5161,6 +5161,7 @@ public abstract interface class com/facebook/react/viewmanagers/VirtualViewManag public final class com/facebook/react/views/drawer/ReactDrawerLayout : androidx/drawerlayout/widget/DrawerLayout, com/facebook/react/uimanager/HasChildPressedStateDelay { public fun (Lcom/facebook/react/bridge/ReactContext;)V + public fun addFocusables (Ljava/util/ArrayList;II)V public fun getHasChildPressedStateDelay ()Ljava/lang/Boolean; public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z public fun onTouchEvent (Landroid/view/MotionEvent;)Z @@ -5515,6 +5516,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android public fun onLayoutChange (Landroid/view/View;IIIIIIII)V protected fun onMeasure (II)V protected fun onOverScrolled (IIZZ)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z protected fun onScrollChanged (IIII)V protected fun onSizeChanged (IIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z @@ -5625,6 +5627,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;)V public synthetic fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun abortAnimation ()V + public fun addFocusables (Ljava/util/ArrayList;II)V public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z public fun draw (Landroid/graphics/Canvas;)V public fun executeKeyEvent (Landroid/view/KeyEvent;)Z @@ -5665,6 +5668,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun onLayoutChange (Landroid/view/View;IIIIIIII)V protected fun onMeasure (II)V protected fun onOverScrolled (IIZZ)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z protected fun onScrollChanged (IIII)V protected fun onSizeChanged (IIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z @@ -5980,10 +5984,12 @@ public final class com/facebook/react/views/scroll/VirtualViewContainerState$Com public class com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout : androidx/swiperefreshlayout/widget/SwipeRefreshLayout, com/facebook/react/uimanager/HasChildPressedStateDelay { public fun (Lcom/facebook/react/bridge/ReactContext;)V + public fun addFocusables (Ljava/util/ArrayList;II)V public fun canChildScrollUp ()Z public fun getHasChildPressedStateDelay ()Ljava/lang/Boolean; public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z public fun onLayout (ZIIII)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun requestDisallowInterceptTouchEvent (Z)V public fun setHasChildPressedStateDelay (Ljava/lang/Boolean;)V @@ -6439,6 +6445,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 @@ -6464,6 +6471,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z protected fun onLayout (ZIIII)V protected fun onMeasure (II)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z protected fun onSizeChanged (IIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun onViewAdded (Landroid/view/View;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 55daec06a8c6..5bc48ca11e7c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -505,6 +505,7 @@ public void setImportantForAccessibility( view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); } else if (importantForAccessibility.equals("no-hide-descendants")) { view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + HiddenSubtreeFocusHelper.releaseHiddenKeyboardFocus(view); } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/HiddenSubtreeFocusHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/HiddenSubtreeFocusHelper.kt new file mode 100644 index 000000000000..40d71b01113f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/HiddenSubtreeFocusHelper.kt @@ -0,0 +1,45 @@ +/* + * 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.uimanager + +import android.view.View +import android.view.ViewGroup + +/** + * Keeps the descendants of a view that hides them from accessibility services out of keyboard and + * D-pad navigation too, so a hardware keyboard never lands on content a screen reader cannot reach. + * A tap or a `focus()` call still reaches them, and the view itself keeps the place the platform + * gives it in the focus search. + */ +internal object HiddenSubtreeFocusHelper { + + @JvmStatic + fun hidesDescendants(view: View): Boolean = + view.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + + /** + * Removes the descendants [group] added to [views] from [firstAddedIndex] on while it hides them. + * A focused descendant stays, so that focus search can move on from it. + */ + @JvmStatic + fun removeHiddenFocusables(group: ViewGroup, views: ArrayList, firstAddedIndex: Int) { + if (hidesDescendants(group)) { + views.subList(firstAddedIndex, views.size).removeAll { it !== group && !it.isFocused } + } + } + + /** + * Moves keyboard focus off a descendant [view] now hides; touch focus stays where the user types. + */ + @JvmStatic + fun releaseHiddenKeyboardFocus(view: View) { + if (view is ViewGroup && hidesDescendants(view) && !view.isInTouchMode) { + view.findFocus()?.takeIf { it !== view }?.clearFocus() + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt index 7523bf1fe930..9ebae1da37f7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt @@ -8,6 +8,7 @@ package com.facebook.react.views.drawer import android.annotation.SuppressLint +import android.graphics.Rect import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -21,6 +22,7 @@ import com.facebook.react.R import com.facebook.react.bridge.ReactContext import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole import com.facebook.react.uimanager.events.NativeGestureUtil.notifyNativeGestureEnded import com.facebook.react.uimanager.events.NativeGestureUtil.notifyNativeGestureStarted @@ -67,6 +69,16 @@ public class ReactDrawerLayout(reactContext: ReactContext) : override fun shouldDelayChildPressedState(): Boolean = hasChildPressedStateDelay ?: super.shouldDelayChildPressedState() + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { try { if (super.onInterceptTouchEvent(ev)) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt index 1bfb84f45368..33a536149869 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt @@ -37,6 +37,7 @@ import com.facebook.react.common.build.ReactBuildConfig import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions @@ -565,6 +566,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : } override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + addPageFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + + private fun addPageFocusables(views: ArrayList, direction: Int, focusableMode: Int) { if (pagingEnabled && !pagedArrowScrolling) { // Only add elements within the current page to list of focusables val candidateViews = ArrayList() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt index e3c1dfd891af..8e5d10ddfcda 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt @@ -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<<37e2e57a137f899bb94e78bd265de562>> + * @generated SignedSource<<71b6e6eea33dce97af22540ffdbeec49>> */ /** @@ -39,6 +39,7 @@ import com.facebook.react.common.ReactConstants import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions @@ -461,6 +462,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : maintainVisibleContentPositionHelper?.stop() } + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + override fun focusSearch(focused: View, direction: Int): View? { val nextFocus = super.focusSearch(focused, direction) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt index 8db61a4d0433..bfffddea08be 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt @@ -31,6 +31,7 @@ import com.facebook.react.common.ReactConstants import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions @@ -453,6 +454,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : maintainVisibleContentPositionHelper?.stop() } + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + override fun focusSearch(focused: View, direction: Int): View? { val nextFocus = super.focusSearch(focused, direction) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt index d2c6fc8d6d90..8a0f86352d47 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt @@ -7,12 +7,15 @@ package com.facebook.react.views.swiperefresh +import android.graphics.Rect import android.view.MotionEvent +import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.events.NativeGestureUtil import kotlin.math.abs @@ -85,6 +88,16 @@ public open class ReactSwipeRefreshLayout(reactContext: ReactContext) : override fun shouldDelayChildPressedState(): Boolean = hasChildPressedStateDelay ?: super.shouldDelayChildPressedState() + public override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + public override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (shouldInterceptTouchEvent(ev) && super.onInterceptTouchEvent(ev)) { NativeGestureUtil.notifyNativeGestureStarted(this, ev) 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 363f1336d000..490fa007ef09 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 @@ -48,6 +48,7 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderWidth import com.facebook.react.uimanager.BackgroundStyleApplicator.setFeedbackUnderlay import com.facebook.react.uimanager.BlendModeHelper.needsIsolatedLayer import com.facebook.react.uimanager.HasElevatedDescendantCache +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions.assertExplicitMeasureSpec @@ -508,6 +509,16 @@ 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) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + internal fun requestFocusFromJS() { if (isAttachedToWindow) { super.requestFocus(FOCUS_DOWN, null) diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp index 1e4e04c49d9c..f1a97fc6798c 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp @@ -227,6 +227,14 @@ void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( } } + // A view that hides its descendants from accessibility services keeps them + // out of keyboard navigation, as ReactViewGroup does for the views it hosts. + if (props != nullptr && + props->importantForAccessibility == + ImportantForAccessibility::NoHideDescendants) { + return; + } + for (auto& child : currNode->getChildren()) { if (child->getTraits().check(ShadowNodeTraits::Trait::RootNodeKind)) { continue; diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/HiddenSubtreeKeyboardFocusTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/HiddenSubtreeKeyboardFocusTest.kt new file mode 100644 index 000000000000..442a3f499c70 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/HiddenSubtreeKeyboardFocusTest.kt @@ -0,0 +1,180 @@ +/* + * 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.uimanager + +import android.app.Activity +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewGroup +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.views.drawer.ReactDrawerLayout +import com.facebook.react.views.drawer.ReactDrawerLayoutManager +import com.facebook.react.views.scroll.ReactHorizontalScrollView +import com.facebook.react.views.scroll.ReactHorizontalScrollViewManager +import com.facebook.react.views.scroll.ReactNestedScrollView +import com.facebook.react.views.scroll.ReactNestedScrollViewManager +import com.facebook.react.views.scroll.ReactScrollView +import com.facebook.react.views.scroll.ReactScrollViewManager +import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout +import com.facebook.react.views.swiperefresh.SwipeRefreshLayoutManager +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 +import org.robolectric.RuntimeEnvironment + +/** Every container React Native renders holds the content it hides out of keyboard navigation. */ +@RunWith(RobolectricTestRunner::class) +class HiddenSubtreeKeyboardFocusTest { + + private lateinit var themedContext: ThemedReactContext + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + themedContext = ThemedReactContext(context, context, null, -1) + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun `hidden content leaves keyboard focus search`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + + container.applyImportance(scene.container, "no-hide-descendants") + + assertThat(collectFocusables(scene.root)) + .describedAs(container.name) + .containsExactly(scene.shown) + } + } + + @Test + fun `hidden content returns to keyboard focus search once shown again`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + container.applyImportance(scene.container, "no-hide-descendants") + + container.applyImportance(scene.container, null) + + assertThat(collectFocusables(scene.root)) + .describedAs(container.name) + .containsExactly(scene.hidden, scene.shown) + } + } + + @Test + fun `hidden content takes no default focus`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + attachToWindow(scene.root) + container.applyImportance(scene.container, "no-hide-descendants") + + scene.root.requestFocus(View.FOCUS_DOWN) + + assertThat(scene.hidden.isFocused).describedAs(container.name).isFalse() + } + } + + @Test + fun `keyboard focus on hidden content moves off as the container hides it`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + attachToWindow(scene.root) + assertThat(scene.hidden.requestFocus()).describedAs(container.name).isTrue() + + container.applyImportance(scene.container, "no-hide-descendants") + + assertThat(scene.hidden.isFocused).describedAs(container.name).isFalse() + } + } + + private fun buildScene(container: Container): Scene { + val root = ReactViewGroup(themedContext) + val hiding = container.create(themedContext) + val content = ReactViewGroup(themedContext).apply { layout(0, 0, 100, 10) } + val hidden = focusableView(top = 0) + val shown = focusableView(top = 20) + content.addView(hidden) + hiding.addView(content) + hiding.layout(0, 0, 100, 10) + root.addView(hiding) + root.addView(shown) + return Scene(root, hiding, hidden, shown) + } + + private fun focusableView(top: Int): View = + View(themedContext).apply { + isFocusable = true + layout(0, top, 100, top + 10) + } + + private fun collectFocusables(root: ViewGroup): List { + val focusables = ArrayList() + root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL) + return focusables.filter { it !is ViewGroup } + } + + private fun attachToWindow(root: View) { + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + } + + private class Scene( + val root: ReactViewGroup, + val container: ViewGroup, + val hidden: View, + val shown: View, + ) + + private class Container( + val name: String, + val create: (ThemedReactContext) -> ViewGroup, + val applyImportance: (ViewGroup, String?) -> Unit, + ) + + private companion object { + val CONTAINERS: List = + listOf( + Container("vertical scroll view", ::ReactScrollView) { view, mode -> + ReactScrollViewManager().setImportantForAccessibility(view as ReactScrollView, mode) + }, + Container("horizontal scroll view", ::ReactHorizontalScrollView) { view, mode -> + ReactHorizontalScrollViewManager() + .setImportantForAccessibility(view as ReactHorizontalScrollView, mode) + }, + Container("nested scroll view", ::ReactNestedScrollView) { view, mode -> + ReactNestedScrollViewManager() + .setImportantForAccessibility(view as ReactNestedScrollView, mode) + }, + Container("drawer layout", ::ReactDrawerLayout) { view, mode -> + ReactDrawerLayoutManager() + .setImportantForAccessibility(view as ReactDrawerLayout, mode) + }, + Container("swipe refresh layout", ::ReactSwipeRefreshLayout) { view, mode -> + SwipeRefreshLayoutManager() + .setImportantForAccessibility(view as ReactSwipeRefreshLayout, mode) + }, + ) + } +} 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 56383ff7efeb..e9b7384030e7 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 @@ -22,6 +22,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) class ReactViewGroupTest { @@ -260,6 +261,162 @@ class ReactViewGroupTest { assertThat(outer.hasOverlappingRendering()).isTrue() } + @Test + fun `hidden descendants - leave keyboard focus search`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(collectKeyboardFocusables(root)).containsExactly(shown) + } + + @Test + fun `hidden descendants - return to keyboard focus search once shown again`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + val manager = ReactViewManager() + manager.setImportantForAccessibility(hidden, "no-hide-descendants") + + manager.setImportantForAccessibility(hidden, null) + + assertThat(collectKeyboardFocusables(root)).containsExactly(hiddenControl, shown) + } + + @Test + fun `hidden descendants - default focus passes over them`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(root.requestFocus(View.FOCUS_DOWN)).isTrue() + + assertThat(hiddenControl.isFocused).isFalse() + assertThat(shown.isFocused).isTrue() + } + + @Test + fun `hidden descendants - a descendant keyboard focus sits on gives it up as they hide`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + hidden.addView(hiddenControl) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + assertThat(hiddenControl.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(hiddenControl.isFocused).isFalse() + } + + @Test + fun `hidden descendants - still take focus directly, as a tap or a focus() call gives it`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + hidden.addView(hiddenControl) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(hiddenControl.requestFocus()).isTrue() + } + + @Test + fun `hidden descendants - a descendant focused by touch keeps its focus as they hide`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val field = focusableView(top = 0).apply { isFocusableInTouchMode = true } + hidden.addView(field) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + enterTouchMode(root) + assertThat(field.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(field.isFocused).isTrue() + } + + @Test + fun `hidden descendants - keyboard navigation moves on from one that keeps its focus`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val field = focusableView(top = 0).apply { isFocusableInTouchMode = true } + val otherHiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(field) + hidden.addView(otherHiddenControl) + root.addView(hidden) + root.addView(shown) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + enterTouchMode(root) + assertThat(field.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(collectKeyboardFocusables(root)).containsExactly(field, shown) + } + + @Test + fun `hidden descendants - the view hiding them stays a keyboard focus candidate itself`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0).apply { isFocusable = true } + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(collectKeyboardFocusables(root)).containsExactly(hidden, shown) + } + + @Test + fun `hidden descendants - the view hiding them keeps keyboard focus it holds itself`() { + val root = ReactViewGroup(context) + val before = focusableView(top = 0) + val hidden = containerView(top = 20).apply { isFocusable = true } + hidden.addView(focusableView(top = 20)) + root.addView(before) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + assertThat(hidden.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(hidden.isFocused).isTrue() + } + + @Test + fun `hidden descendants - leave the view's own descendant focusability as it was`() { + val hidden = + containerView(top = 0).apply { descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS } + val manager = ReactViewManager() + + manager.setImportantForAccessibility(hidden, "no-hide-descendants") + manager.setImportantForAccessibility(hidden, null) + + assertThat(hidden.descendantFocusability).isEqualTo(ViewGroup.FOCUS_AFTER_DESCENDANTS) + } + // Mirrors the ViewManager mount/unmount hooks: attach/detach the child, then refresh the cache. private fun mount(parent: ReactViewGroup, child: View) { parent.addView(child) @@ -280,6 +437,27 @@ class ReactViewGroupTest { } private fun elevatedChild(): View = View(context).apply { elevation = 10f } + + private fun containerView(top: Int): ReactViewGroup = + ReactViewGroup(context).apply { layout(0, top, 100, top + 10) } + + private fun focusableView(top: Int): View = + View(context).apply { + isFocusable = true + layout(0, top, 100, top + 10) + } + + private fun collectKeyboardFocusables(root: ViewGroup): List { + val focusables = ArrayList() + root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL) + return focusables + } + + // A touch puts the window in touch mode; Robolectric's windows start out of it. + private fun enterTouchMode(attached: View) { + val attachInfo = ReflectionHelpers.getField(attached, "mAttachInfo") + ReflectionHelpers.setField(attachInfo, "mInTouchMode", true) + } } class TestView(context: Context, yPos: Int) : View(context) {