Skip to content

Commit 83fc08a

Browse files
Keep hidden Android view subtrees out of keyboard navigation
A View with importantForAccessibility="no-hide-descendants", which is what aria-hidden becomes, hid its descendants from TalkBack while Tab, the arrow keys and a D-pad still landed on them. ReactViewGroup now leaves those descendants out of the focus search and out of focus that enters through the view, and keyboard focus sitting on one moves off when the view hides it. A tap or a focus() call still focuses them, and the view's own descendantFocusability is untouched. The shadow tree search a scroll view falls back to when focus leaves it skips those descendants too, so Tab past a scroll view's last control no longer picks a hidden one and stops there.
1 parent b8df948 commit 83fc08a

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,35 @@ public open class ReactViewGroup public constructor(context: Context?) :
508508
updateClippingToRect(clippingRect, excludedViews)
509509
}
510510

511+
/**
512+
* A view that hides its descendants from accessibility services keeps them out of keyboard and
513+
* D-pad navigation too, so a hardware keyboard never lands on content a screen reader cannot
514+
* reach. A tap or a `focus()` call still reaches them.
515+
*/
516+
private var hidesDescendantsFromNavigation = false
517+
518+
override fun setImportantForAccessibility(mode: Int) {
519+
super.setImportantForAccessibility(mode)
520+
hidesDescendantsFromNavigation = mode == IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
521+
// Keyboard focus leaves content that is now hidden; touch focus stays where the user types.
522+
if (hidesDescendantsFromNavigation && !isInTouchMode) {
523+
findFocus()?.takeIf { it !== this }?.clearFocus()
524+
}
525+
}
526+
527+
override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
528+
val firstAddedIndex = views.size
529+
super.addFocusables(views, direction, focusableMode)
530+
if (hidesDescendantsFromNavigation) {
531+
// A focused descendant stays a candidate so that focus search can move on from it.
532+
views.subList(firstAddedIndex, views.size).removeAll { it !== this && !it.isFocused }
533+
}
534+
}
535+
536+
override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean =
537+
!hidesDescendantsFromNavigation &&
538+
super.onRequestFocusInDescendants(direction, previouslyFocusedRect)
539+
511540
internal fun requestFocusFromJS() {
512541
if (isAttachedToWindow) {
513542
super.requestFocus(FOCUS_DOWN, null)

‎packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,14 @@ void FocusOrderingHelper::traverseAndUpdateNextFocusableElement(
227227
}
228228
}
229229

230+
// A view that hides its descendants from accessibility services keeps them
231+
// out of keyboard navigation, as ReactViewGroup does for the views it hosts.
232+
if (props != nullptr &&
233+
props->importantForAccessibility ==
234+
ImportantForAccessibility::NoHideDescendants) {
235+
return;
236+
}
237+
230238
for (auto& child : currNode->getChildren()) {
231239
if (child->getTraits().check(ShadowNodeTraits::Trait::RootNodeKind)) {
232240
continue;

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import org.junit.Test
2222
import org.junit.runner.RunWith
2323
import org.robolectric.Robolectric
2424
import org.robolectric.RobolectricTestRunner
25+
import org.robolectric.util.ReflectionHelpers
2526

2627
@RunWith(RobolectricTestRunner::class)
2728
class ReactViewGroupTest {
@@ -260,6 +261,131 @@ class ReactViewGroupTest {
260261
assertThat(outer.hasOverlappingRendering()).isTrue()
261262
}
262263

264+
@Test
265+
fun `hidden descendants - leave keyboard focus search`() {
266+
val root = ReactViewGroup(context)
267+
val hidden = containerView(top = 0)
268+
val hiddenControl = focusableView(top = 0)
269+
val shown = focusableView(top = 20)
270+
hidden.addView(hiddenControl)
271+
root.addView(hidden)
272+
root.addView(shown)
273+
274+
ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants")
275+
276+
assertThat(collectKeyboardFocusables(root)).containsExactly(shown)
277+
}
278+
279+
@Test
280+
fun `hidden descendants - return to keyboard focus search once shown again`() {
281+
val root = ReactViewGroup(context)
282+
val hidden = containerView(top = 0)
283+
val hiddenControl = focusableView(top = 0)
284+
val shown = focusableView(top = 20)
285+
hidden.addView(hiddenControl)
286+
root.addView(hidden)
287+
root.addView(shown)
288+
val manager = ReactViewManager()
289+
manager.setImportantForAccessibility(hidden, "no-hide-descendants")
290+
291+
manager.setImportantForAccessibility(hidden, null)
292+
293+
assertThat(collectKeyboardFocusables(root)).containsExactly(hiddenControl, shown)
294+
}
295+
296+
@Test
297+
fun `hidden descendants - default focus passes over them`() {
298+
val root = ReactViewGroup(context)
299+
val hidden = containerView(top = 0)
300+
val hiddenControl = focusableView(top = 0)
301+
val shown = focusableView(top = 20)
302+
hidden.addView(hiddenControl)
303+
root.addView(hidden)
304+
root.addView(shown)
305+
ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants")
306+
307+
assertThat(root.requestFocus(View.FOCUS_DOWN)).isTrue()
308+
309+
assertThat(hiddenControl.isFocused).isFalse()
310+
assertThat(shown.isFocused).isTrue()
311+
}
312+
313+
@Test
314+
fun `hidden descendants - a descendant keyboard focus sits on gives it up as they hide`() {
315+
val root = ReactViewGroup(context)
316+
val hidden = containerView(top = 0)
317+
val hiddenControl = focusableView(top = 0)
318+
hidden.addView(hiddenControl)
319+
root.addView(hidden)
320+
Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root)
321+
assertThat(hiddenControl.requestFocus()).isTrue()
322+
323+
ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants")
324+
325+
assertThat(hiddenControl.isFocused).isFalse()
326+
}
327+
328+
@Test
329+
fun `hidden descendants - still take focus directly, as a tap or a focus() call gives it`() {
330+
val root = ReactViewGroup(context)
331+
val hidden = containerView(top = 0)
332+
val hiddenControl = focusableView(top = 0)
333+
hidden.addView(hiddenControl)
334+
root.addView(hidden)
335+
Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root)
336+
ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants")
337+
338+
assertThat(hiddenControl.requestFocus()).isTrue()
339+
}
340+
341+
@Test
342+
fun `hidden descendants - a descendant focused by touch keeps its focus as they hide`() {
343+
val root = ReactViewGroup(context)
344+
val hidden = containerView(top = 0)
345+
val field = focusableView(top = 0).apply { isFocusableInTouchMode = true }
346+
hidden.addView(field)
347+
root.addView(hidden)
348+
Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root)
349+
enterTouchMode(root)
350+
assertThat(field.requestFocus()).isTrue()
351+
352+
ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants")
353+
354+
assertThat(field.isFocused).isTrue()
355+
}
356+
357+
@Test
358+
fun `hidden descendants - keyboard navigation moves on from one that keeps its focus`() {
359+
val root = ReactViewGroup(context)
360+
val hidden = containerView(top = 0)
361+
val field = focusableView(top = 0).apply { isFocusableInTouchMode = true }
362+
val otherHiddenControl = focusableView(top = 0)
363+
val shown = focusableView(top = 20)
364+
hidden.addView(field)
365+
hidden.addView(otherHiddenControl)
366+
root.addView(hidden)
367+
root.addView(shown)
368+
Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root)
369+
enterTouchMode(root)
370+
assertThat(field.requestFocus()).isTrue()
371+
372+
ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants")
373+
374+
assertThat(collectKeyboardFocusables(root)).containsExactly(field, shown)
375+
}
376+
377+
@Test
378+
fun `hidden descendants - leave the view's own descendant focusability as it was`() {
379+
val hidden =
380+
containerView(top = 0).apply { descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS }
381+
val manager = ReactViewManager()
382+
383+
manager.setImportantForAccessibility(hidden, "no-hide-descendants")
384+
manager.setImportantForAccessibility(hidden, null)
385+
386+
assertThat(hidden.descendantFocusability).isEqualTo(ViewGroup.FOCUS_AFTER_DESCENDANTS)
387+
}
388+
263389
// Mirrors the ViewManager mount/unmount hooks: attach/detach the child, then refresh the cache.
264390
private fun mount(parent: ReactViewGroup, child: View) {
265391
parent.addView(child)
@@ -280,6 +406,27 @@ class ReactViewGroupTest {
280406
}
281407

282408
private fun elevatedChild(): View = View(context).apply { elevation = 10f }
409+
410+
private fun containerView(top: Int): ReactViewGroup =
411+
ReactViewGroup(context).apply { layout(0, top, 100, top + 10) }
412+
413+
private fun focusableView(top: Int): View =
414+
View(context).apply {
415+
isFocusable = true
416+
layout(0, top, 100, top + 10)
417+
}
418+
419+
private fun collectKeyboardFocusables(root: ViewGroup): List<View> {
420+
val focusables = ArrayList<View>()
421+
root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL)
422+
return focusables
423+
}
424+
425+
// A touch puts the window in touch mode; Robolectric's windows start out of it.
426+
private fun enterTouchMode(attached: View) {
427+
val attachInfo = ReflectionHelpers.getField<Any>(attached, "mAttachInfo")
428+
ReflectionHelpers.setField(attachInfo, "mInTouchMode", true)
429+
}
283430
}
284431

285432
class TestView(context: Context, yPos: Int) : View(context) {

0 commit comments

Comments
 (0)