Skip to content

[FocusTrap] Fix tab order when children have positive tabIndex#48500

Open
starboyvarun wants to merge 2 commits into
mui:masterfrom
starboyvarun:36479-focus-trap-tabindex-order
Open

[FocusTrap] Fix tab order when children have positive tabIndex#48500
starboyvarun wants to merge 2 commits into
mui:masterfrom
starboyvarun:36479-focus-trap-tabindex-order

Conversation

@starboyvarun
Copy link
Copy Markdown
Contributor

Summary

Fixes #36479.

When children inside FocusTrap have positive tabIndex values (e.g. tabIndex={2}), the browser processes them before tabIndex={0} elements. The sentinelStart div had tabIndex={0}, so after tabbing through all positive-tabIndex children the browser would reach tabIndex={0} elements outside the trap before looping back to the sentinel — allowing focus to escape.

Fix: Set sentinelStart to tabIndex={1} so it is reached in tab order immediately after the highest positive-tabIndex child and before any tabIndex={0} elements outside the trap.

sentinelEnd keeps tabIndex={0} — it is at the end of the DOM so the browser reaches it naturally when tabbing forward through tabIndex={0} elements.

Test plan

  • New test: "should keep focus inside when children have positive tabIndex"
  • Existing FocusTrap tests pass

…6479)

Set sentinelStart tabIndex to 1 instead of 0 so it takes priority over
positive-tabIndex children inside the trap. Previously, tabbing past
the last element with tabIndex > 0 would reach the sentinel only after
all other tabIndex=0 elements — including elements outside the trap —
allowing focus to escape.
@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 8, 2026

Deploy preview

https://deploy-preview-48500--material-ui.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/material 🔺+212B(+0.04%) 🔺+111B(+0.08%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

Comment on lines +424 to +425
screen.getByTestId('outside-input').focus();
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the fix works. The test is wrong since the input is focus()ed directly.

@zannager zannager added the scope: focus trap Changes related to the focus trap. label May 11, 2026
The sentinel approach alone is not enough when children have positive tabIndex.
After the last positive-tabIndex element inside the trap, the browser's natural
Tab target is the first tabIndex=0 element in document order — which can be
outside the trap (e.g. elements rendered before the modal in the DOM).

Fix: In loopFocus, intercept forward Tab from any positive-tabIndex element
inside the trap and redirect to the next entry in getTabbable() order instead,
preventing the brief focus escape.

Also fix the test: the previous version called .focus() directly on outside-input,
which contain() already handles regardless of the fix. The new test fires a
keydown Tab event, which exercises the loopFocus path that the fix adds.
@starboyvarun
Copy link
Copy Markdown
Contributor Author

You're right on both counts — I've pushed a corrected fix.

Why the original test was wrong

Calling .focus() directly on outside-input goes through contain(), which listens to focusin on the document. contain() redirects any focus that lands outside rootRef.current back to the trap — this works regardless of what sentinelStart.tabIndex is set to. So the test was passing without actually validating the fix.

Why the original fix was wrong

Setting sentinelStart to tabIndex={1} changes the forward tab cycle to: sentinelStart(1) → positive-tab(2) → [tabIndex=0 elements in document order]. But after the last positive-tabIndex element, the browser still jumps to tabIndex=0 elements in document order — and those may start outside the trap (any element in the page with lower document position than the trap's content). contain() catches it, but focus briefly escapes before being redirected.

The real fix

The problem is in the loopFocus keydown handler. It already handles one special case (Shift+Tab from rootRef.current), but it doesn't handle forward Tab from positive-tabIndex elements inside the trap.

The fix: in loopFocus, when Tab (forward) is pressed from an element with tabIndex > 0 inside the trap, call getTabbable() to find the next element in sorted order (positive-tabIndex elements come first in that list, then tabIndex=0) and call preventDefault() + nextEl.focus(). This prevents the browser from jumping to outside elements entirely.

The corrected test fires a keydown Tab event on the focused positive-tabIndex element and asserts that focus moved to normal-tab (the next tabbable inside the trap, which is a tabIndex=0 button). Without the loopFocus fix, the handler never runs this path and focus stays on positive-tab — so the test would fail without the fix.

@mj12albert
Copy link
Copy Markdown
Member

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: focus trap Changes related to the focus trap.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FocusTrap] Wrong tab order when tabIndex >= 1

3 participants