fix: skip inert descendants during tab navigation - #1327
Conversation
There was a problem hiding this comment.
💡 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".
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
ce4c797 to
bce64dd
Compare
| import {isVisible} from '../misc/isVisible' | ||
| import {FOCUSABLE_SELECTOR} from './selector' | ||
|
|
||
| /** Return the element's parent in the flat tree. */ |
There was a problem hiding this comment.
This term "flat tree" is used a lot, what is it?
There was a problem hiding this comment.
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:
assignedSlot- ordinary
parentElement - 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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
What
user.tab()skips ordinary inert subtrees and flat-tree descendants assigned through a slot.inerton the dialog.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 allowuser.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
inertbefore stopping inherited inertness at the document's active modal. When a modal is active, candidates outside it are inert andbodyis 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 --runInBandfails on the test commit and passes on the fix commit.npm run test -- --coverage --runInBand— 55 suites and 516 tests pass.npm run validate -snpm run lint— exits successfully with 66 pre-existing warnings outside the changed files.npm run buildnpm run setup:envnpm run test:toolboxcould not start locally because/usr/bin/chromiumis 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
user.tab()behavior without changing the public API.Shepherd Journal