@@ -9,6 +9,7 @@ package com.facebook.react.views.view
99
1010import android.app.Activity
1111import android.content.Context
12+ import android.view.FocusFinder
1213import android.view.View
1314import android.view.ViewGroup
1415import android.widget.FrameLayout
@@ -260,6 +261,118 @@ class ReactViewGroupTest {
260261 assertThat(outer.hasOverlappingRendering()).isTrue()
261262 }
262263
264+ @Test
265+ fun `focusable false - keeps an accessible view out of keyboard focus search` () {
266+ val root = ReactViewGroup (context)
267+ val leading = focusableView(top = 0 )
268+ val excluded = accessibleView(top = 10 , focusable = false )
269+ val trailing = focusableView(top = 20 )
270+ root.addView(leading)
271+ root.addView(excluded)
272+ root.addView(trailing)
273+
274+ assertThat(collectKeyboardFocusables(root)).containsExactly(leading, trailing)
275+ // Accessibility services still treat the view as one focusable element.
276+ assertThat(excluded.isFocusable).isTrue()
277+ }
278+
279+ @Test
280+ fun `focusable false - keeps the descendants of an excluded view in keyboard focus search` () {
281+ val root = ReactViewGroup (context)
282+ val excluded = accessibleView(top = 0 , focusable = false )
283+ val descendant = focusableView(top = 0 )
284+ excluded.addView(descendant)
285+ root.addView(excluded)
286+
287+ assertThat(collectKeyboardFocusables(root)).containsExactly(descendant)
288+ }
289+
290+ @Test
291+ fun `focusable false - default focus passes over an excluded view` () {
292+ val root = ReactViewGroup (context)
293+ val excluded = accessibleView(top = 0 , focusable = false )
294+ val trailing = focusableView(top = 10 )
295+ root.addView(excluded)
296+ root.addView(trailing)
297+
298+ assertThat(root.requestFocus(View .FOCUS_DOWN )).isTrue()
299+
300+ assertThat(excluded.isFocused).isFalse()
301+ assertThat(trailing.isFocused).isTrue()
302+ }
303+
304+ @Test
305+ fun `focusable false - focus requested from JS still reaches an excluded view` () {
306+ val root = ReactViewGroup (context)
307+ val excluded = accessibleView(top = 0 , focusable = false )
308+ root.addView(excluded)
309+ Robolectric .buildActivity(Activity ::class .java).setup().get().setContentView(root)
310+
311+ excluded.requestFocusFromJS()
312+
313+ assertThat(excluded.isFocused).isTrue()
314+ }
315+
316+ @Test
317+ fun `focusable false - keyboard focus moves on from an excluded view that holds focus` () {
318+ val root = ReactViewGroup (context)
319+ val leading = focusableView(top = 0 )
320+ val excluded = accessibleView(top = 10 , focusable = false )
321+ val trailing = focusableView(top = 20 )
322+ root.addView(leading)
323+ root.addView(excluded)
324+ root.addView(trailing)
325+ Robolectric .buildActivity(Activity ::class .java).setup().get().setContentView(root)
326+ excluded.requestFocusFromJS()
327+
328+ assertThat(FocusFinder .getInstance().findNextFocus(root, excluded, View .FOCUS_FORWARD ))
329+ .isSameAs(trailing)
330+ assertThat(FocusFinder .getInstance().findNextFocus(root, excluded, View .FOCUS_BACKWARD ))
331+ .isSameAs(leading)
332+ }
333+
334+ @Test
335+ fun `focusable true - returns a view to keyboard focus search` () {
336+ val root = ReactViewGroup (context)
337+ val view = accessibleView(top = 0 , focusable = false )
338+ root.addView(view)
339+
340+ ReactViewManager ().setFocusable(view, true )
341+
342+ assertThat(collectKeyboardFocusables(root)).containsExactly(view)
343+ }
344+
345+ @Test
346+ fun `focusable false - recycling a view returns it to keyboard focus search` () {
347+ val root = ReactViewGroup (context)
348+ val view = accessibleView(top = 0 , focusable = false )
349+
350+ view.recycleView()
351+ root.addView(view)
352+
353+ assertThat(collectKeyboardFocusables(root)).containsExactly(view)
354+ }
355+
356+ private fun focusableView (top : Int ): View =
357+ View (context).apply {
358+ isFocusable = true
359+ layout(0 , top, 100 , top + 10 )
360+ }
361+
362+ private fun accessibleView (top : Int , focusable : Boolean ): ReactViewGroup =
363+ ReactViewGroup (context).apply {
364+ layout(0 , top, 100 , top + 10 )
365+ val manager = ReactViewManager ()
366+ manager.setAccessible(this , true )
367+ manager.setFocusable(this , focusable)
368+ }
369+
370+ private fun collectKeyboardFocusables (root : ViewGroup ): List <View > {
371+ val focusables = ArrayList <View >()
372+ root.addFocusables(focusables, View .FOCUS_FORWARD , View .FOCUSABLES_ALL )
373+ return focusables
374+ }
375+
263376 // Mirrors the ViewManager mount/unmount hooks: attach/detach the child, then refresh the cache.
264377 private fun mount (parent : ReactViewGroup , child : View ) {
265378 parent.addView(child)
0 commit comments