diff --git a/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js b/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js index f596bf60d2..79a50cc145 100644 --- a/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js +++ b/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js @@ -36,6 +36,11 @@ function attemptFocus(element: any) { return false; } + // Programmatically focusable elements can still be excluded from tab order. + if (element.hasAttribute?.('tabindex') && element.tabIndex < 0) { + return false; + } + try { element.focus(); } catch (e) { diff --git a/packages/react-native-web/src/exports/Modal/__tests__/index-test.js b/packages/react-native-web/src/exports/Modal/__tests__/index-test.js index 9e82fcc5ba..9dc7299d04 100644 --- a/packages/react-native-web/src/exports/Modal/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Modal/__tests__/index-test.js @@ -6,6 +6,7 @@ */ import Modal from '..'; +import ScrollView from '../../ScrollView'; import React from 'react'; import { fireEvent, render } from '@testing-library/react'; @@ -532,6 +533,76 @@ describe('components/Modal', () => { expect(document.activeElement).toBe(insideEndElement); }); + test.each([{ tabIndex: -1 }, { focusable: false }])( + 'wraps focus through children of a ScrollView excluded from tab order: %s', + (props) => { + const { getByTestId } = render( + + + + + + + ); + const first = getByTestId('first'); + const last = getByTestId('last'); + const trap = getByTestId('scroll').closest('[role="dialog"]').parentNode; + + expect(document.activeElement).toBe(first); + first.focus(); + trap.previousSibling.focus(); + expect(document.activeElement).toBe(last); + trap.nextSibling.focus(); + expect(document.activeElement).toBe(first); + } + ); + + test('skips excluded elements at both ends of the modal', () => { + const { getByTestId } = render( + + + + + + + ); + const first = getByTestId('first'); + const last = getByTestId('last'); + const trap = first.closest('[role="dialog"]').parentNode; + expect(document.activeElement).toBe(first); + trap.previousSibling.focus(); + expect(document.activeElement).toBe(last); + trap.nextSibling.focus(); + expect(document.activeElement).toBe(first); + }); + + test('allows explicitly focusing an excluded element inside the modal', () => { + const { getByTestId } = render( + + + + + ); + const excluded = getByTestId('excluded'); + excluded.focus(); + expect(document.activeElement).toBe(excluded); + }); + + test('falls back to the trap when all descendants are excluded from tab order', () => { + const { getByTestId } = render( + + + + ); + const excluded = getByTestId('excluded'); + const trap = excluded.closest('[role="dialog"]').parentNode; + expect(document.activeElement).toBe(trap); + }); + test('focus is trapped without contents', () => { render( <>