Skip to content

fix: skip inert descendants during tab navigation - #1327

Open
jonathanong wants to merge 2 commits into
testing-library:mainfrom
jonathanong:pr/skip-inert-tab-stops
Open

fix: skip inert descendants during tab navigation#1327
jonathanong wants to merge 2 commits into
testing-library:mainfrom
jonathanong:pr/skip-inert-tab-stops

Conversation

@jonathanong

@jonathanong jonathanong commented Aug 28, 2026

Copy link
Copy Markdown

What

  • Add regression coverage showing that user.tab() skips ordinary inert subtrees and flat-tree descendants assigned through a slot.
  • Cover the exception that lets focus leave the active element after an ancestor becomes inert.
  • Keep tab navigation inside an active modal dialog while honoring explicit inert on the dialog.
  • Keep the failing regression tests and implementation in separate commits.

Why

getTabDestination() currently gathers elements matching the focusable selector and excludes only negative-tabindex and disabled candidates. It does not account for HTML inertness, so DOM environments without native inert focus enforcement allow user.tab() to focus descendants that browsers remove from sequential focus navigation.

Checking only closest("[inert]") is insufficient: slotted content follows flat-tree ancestry rather than only DOM-parent ancestry. Modal dialogs also require both sides of the HTML behavior: an active modal escapes inertness inherited from ancestors, while the modal blocks the rest of the document and keeps sequential navigation within the dialog.

This is related to #1215, but does not close it: that issue also covers typing and clicking inert elements, while this change is intentionally limited to tab navigation.

How

A small inertness helper walks flat-tree ancestry through assigned slots, parent elements, and shadow hosts. It checks explicit inert before stopping inherited inertness at the document's active modal. When a modal is active, candidates outside it are inert and body is omitted from the tab ring, so forward and reverse navigation wrap among the modal's controls. The existing active-element exception remains first, preserving the ability to tab away when the currently focused element becomes inert.

The first commit contains the failing tests. The second contains the fix.

Validation:

  • npm test -- tests/convenience/tab.ts --runInBand fails on the test commit and passes on the fix commit.
  • npm run test -- --coverage --runInBand — 55 suites and 516 tests pass.
  • npm run validate -s
  • npm run lint — exits successfully with 66 pre-existing warnings outside the changed files.
  • npm run build
  • npm run setup:env
  • npm run test:toolbox could not start locally because /usr/bin/chromium is not installed.

AI assistance: I used Codex to help investigate, implement, and validate this change. I reviewed the resulting code, tests, commits, and description.

Checklist

  • N/A Documentation — this corrects existing user.tab() behavior without changing the public API.
  • Tests
  • Ready to be merged
Shepherd Journal
  • Addressed modal-induced document inertness: resolve the active modal once, exclude outside candidates, and remove body from the modal tab ring so navigation wraps internally. The first commit now proves the modal-wrap failure; the second fixes it. Full coverage, validation, lint, and build pass.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce4c7973ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utils/focus/getTabDestination.ts Outdated
Cover forward and reverse tab navigation around focusable descendants of an inert ancestor.

Assisted-by: Codex
Exclude focusable candidates inside inert subtrees while retaining the active element so tab navigation can leave it.

Assisted-by: Codex
@jonathanong
jonathanong force-pushed the pr/skip-inert-tab-stops branch from ce4c797 to bce64dd Compare August 28, 2026 18:50
import {isVisible} from '../misc/isVisible'
import {FOCUSABLE_SELECTOR} from './selector'

/** Return the element's parent in the flat tree. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This term "flat tree" is used a lot, what is it?

@jonathanong jonathanong Aug 29, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

tbh i don't know, this is codex recommending this fix after i searched for workarounds in my repos. looks like it's something around the shadow dom

here's its summary:


“Flat tree” means the browser’s effective tree after Shadow DOM slots are resolved—not merely the parent/child relationships visible in the light DOM.

For the PR’s test:

<div id="host">
  <input slot="content">
</div>
<!-- host’s shadow root -->
<div inert>
  <slot name="content"></slot>
</div>

The input’s ordinary DOM ancestry is:

input → host → body

None of those elements is inert, so element.closest('[inert]') incorrectly returns nothing.

Its flat-tree ancestry is effectively:

input → slot → inert div → shadow host → body

Because the input is rendered through that slot, it is a flat-tree descendant of the inert <div> and must be skipped during tab navigation.

That is why the PR walks parents in this order:

  1. assignedSlot
  2. ordinary parentElement
  3. the shadow root’s host

In short: the flat tree represents where an element participates after Shadow DOM composition. That is the relevant relationship because HTML inertness applies to flat-tree descendants.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First, thank you for the PR and your honesty.

I am just getting acquainted with this codebase as a maintainer. As a result, I am asking that would be contributors be able to clearly explain their changes, indicating that they have an understanding not only of what they are proposing to change, but how it affects the project overall.

This PR does not give me much confidence as I asked a pretty straightforward question and was met with a verbatim wall of an explanation from AI along with

tbh i don't know

I'm excited that you want to help, but this PR will take a lot of my time to check. I know this because I maintain other projects beyond this one, and AI generated PRs always take more effort, especially when the human steps back. You are the first line of defense against mistakes in an AI generated PR.

I hope you can understand my concerns, and I look forward to reviewing this when you feel ready. That does mean updating comments to be more understandable by humans. If flat tree doesn't immediately make sense on its own, then either it needs a definition or it needs to be removed and a better term or phase used.

Thanks again

@jonathanong jonathanong Sep 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

no worries, this is beyond my expertise. i haven't looked at raw html in years. if you put up a contribution policy, i think that would help

what i can tell you is what breaks. the flat tree stuff is AI handling shadow DOM scenarios i've never handled, but it also fixes the basic case. here's my case:


We have a sensitive-media reveal gate. Before reveal, the real media remains rendered but is placed inside an inert container. A separate sibling button reveals it:

<button id="before">Before sensitive media</button>
<div>
  <div inert>
    <button id="hidden-action">Open sensitive image</button>
  </div>
  <button id="reveal">Sensitive content</button>
</div>

Starting on #before, a native browser Tab skips #hidden-action and focuses #reveal. The current userEvent.tab() includes #hidden-action as a candidate. In a DOM emulator it can incorrectly focus that button; in our Chromium Storybook test, Chromium rejects the focus attempt and focus remains on #before. Either way, the assertion fails:

test('tab skips controls inside an inert reveal gate', async () => {
  document.body.innerHTML = `
    <button id="before">Before sensitive media</button>
    <div>
      <div inert>
        <button id="hidden-action">Open sensitive image</button>
      </div>
      <button id="reveal">Sensitive content</button>
    </div>
  `

  const user = userEvent.setup()
  const before = document.querySelector('#before') as HTMLButtonElement
  const reveal = document.querySelector('#reveal') as HTMLButtonElement

  before.focus()
  await user.tab()

  expect(reveal).toHaveFocus()
})

This ordinary inert-ancestor case is the behavior that motivated the PR. It does not require Shadow DOM. I expanded the implementation to flat-tree ancestry because the HTML inert model includes slotted descendants, but that was not part of our application’s reproducer.


if you're still not satisfied, feel free to close

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants