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(
<>