Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { expect } from 'chai';
import { act, createRenderer, reactMajor, screen } from '@mui/internal-test-utils';
import { act, createRenderer, fireEvent, reactMajor, screen } from '@mui/internal-test-utils';
import FocusTrap from '@mui/material/Unstable_TrapFocus';
import Portal from '@mui/material/Portal';
import { FOCUSABLE_ATTRIBUTE } from '../utils/focusable';
Expand Down Expand Up @@ -401,6 +401,33 @@ describe('<FocusTrap />', () => {
expect(screen.getByRole('textbox')).toHaveFocus();
});

it('should keep focus inside when Tab is pressed from a positive-tabIndex element', async () => {
render(
<div>
<input data-testid="outside-input" />
<FocusTrap open disableAutoFocus>
<div tabIndex={-1} data-testid="root">
<input data-testid="positive-tab" tabIndex={2} />
<button type="button" data-testid="normal-tab" />
</div>
</FocusTrap>
</div>,
);

await act(async () => {
screen.getByTestId('positive-tab').focus();
});
expect(screen.getByTestId('positive-tab')).toHaveFocus();

// Without the fix, forward Tab from a positive-tabIndex element jumps to
// the first tabIndex=0 element in document order — which is outside-input (outside the trap).
// The fix intercepts the Tab keydown and redirects to the next tabbable inside the trap.
await act(async () => {
fireEvent.keyDown(screen.getByTestId('positive-tab'), { key: 'Tab', bubbles: true });
});
expect(screen.getByTestId('normal-tab')).toHaveFocus();
});

it('should trap once the focus moves inside', async () => {
render(
<div>
Expand Down
28 changes: 27 additions & 1 deletion packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,32 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
if (sentinelEnd.current) {
sentinelEnd.current.focus();
}
return;
}

// Forward Tab from a positive-tabIndex element inside the trap.
// After all positive-tabIndex elements, the browser's natural next focus target
// is tabIndex=0 elements in document order — which may be outside the trap.
// Intercept and redirect to the correct next element inside the trap.
if (
!nativeEvent.shiftKey &&
rootRef.current !== null &&
contains(rootRef.current, activeElement) &&
(activeElement as HTMLElement).tabIndex > 0
) {
const tabbable = getTabbable(rootRef.current);
const currentIndex = tabbable.indexOf(activeElement as HTMLElement);
if (currentIndex !== -1) {
nativeEvent.preventDefault();
const nextEl = tabbable[currentIndex + 1];
if (nextEl) {
nextEl.focus();
} else {
// Last tabbable element — loop back via sentinelEnd
ignoreNextEnforceFocus.current = true;
sentinelEnd.current?.focus();
}
}
}
};

Expand Down Expand Up @@ -352,7 +378,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
return (
<React.Fragment>
<div
tabIndex={open ? 0 : -1}
tabIndex={open ? 1 : -1}
onFocus={handleFocusSentinel}
ref={sentinelStart}
data-testid="sentinelStart"
Expand Down
Loading