[popups] Keep closing popups out of the a11y tree and focus order - #5537
Draft
michaldudak wants to merge 10 commits into
Draft
[popups] Keep closing popups out of the a11y tree and focus order#5537michaldudak wants to merge 10 commits into
michaldudak wants to merge 10 commits into
Conversation
commit: |
Bundle size
PerformanceTotal duration: 1,362.34 ms -109.95 ms(-7.5%) | Renders: 76 (+0) | Paint: 2,199.81 ms -163.75 ms(-6.9%)
…and 2 more (+8 within noise) — details Metric alarms
…and 2 more metric alarms — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
michaldudak
force-pushed
the
claude/issue-5519-triage-ce7c69
branch
2 times, most recently
from
August 19, 2026 10:35
925fcc4 to
47f9735
Compare
michaldudak
added a commit
to michaldudak/base-ui
that referenced
this pull request
Aug 19, 2026
`shouldRenderGuards` only checked `disabled` (`!mounted`), so the two focus
guards stayed rendered for the whole exit animation. Each guard hardcodes
`tabIndex={0}` and `aria-hidden="true"`, and axe's `aria-hidden-focus` applies to
`aria-hidden` elements that are themselves focusable, not just ones containing
focusable descendants.
For Positioner-based popups the guards sit inside the subtree that now carries
`inert`, so they were already neutralized. Dialog and Drawer have no Positioner
and apply `inert` to the popup element itself, leaving their guards as siblings
outside it and still focusable while the popup animated out.
Reported by an external reviewer on mui#5537.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
michaldudak
force-pushed
the
claude/issue-5519-triage-ce7c69
branch
9 times, most recently
from
August 19, 2026 14:04
048ffe4 to
c3a62a6
Compare
michaldudak
force-pushed
the
claude/issue-5519-triage-ce7c69
branch
4 times, most recently
from
August 21, 2026 13:03
d0dd779 to
29bdc0c
Compare
The pre-trigger guard handler closed the popup inside `flushSync`, which unmounts the guard, and only then looked up where to send focus. `getTabbableNearElement` returns `null` both for a nullish reference and for a detached one, so focus was left on the removed guard and fell to the body. Resolve the destination from an ordered tabbable snapshot taken before the close, then focus the first candidate that survives it. This also drops the dependence on `getNextTabbable`, whose positioner-skipping loop was a no-op because it resolves relative to `activeElement` rather than to its argument, and retires the now-unused `getTabbableBeforeElement`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…unts Focus restoration ran from the teardown of an effect gated on `disabled`, so it waited for `mounted` to become false. For the whole exit animation, focus stayed inside a subtree that was already logically closed. Scope the manager to one session per open interval and end it at the logical close. Per-session state (the element focused before opening, the close type, the return-focus suppression flag) moves off component-level refs so it cannot leak into the next session, and a setup that runs while the manager is still active cancels a queued return rather than letting a reopen fight it. Focus that sat on the body when the session ended is re-checked before the return runs: if something outside the tree has taken focus since, it owns the destination. That is what keeps a trigger focus guard, which closes the popup and moves focus onward itself, from being overridden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A popup kept mounted for its exit animation stayed in the tab order and the accessibility tree, so a closed Combobox listbox and its focus guards were still reachable. Apply the HTML `inert` attribute while logically closed: on the positioner for Combobox, Menu, Popover and Select, and on the popup for Dialog and Drawer. Drawer scopes it to `!swiping` because its swipe handlers live inside the popup. Popover's trigger focus guards now follow `isOpenedByThisTrigger`, matching Menu. Fixes mui#5519 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suppressing the focus return for every close carrying `REASONS.focusOut` also captured the closes dispatched by the trigger focus guards, list navigation and the portal guards, none of which suppressed it before: an explicit `finalFocus` was ignored when Shift+Tab closed a Menu or Select, and a controlled popup that ignores a focus-out close was left permanently suppressed. Keep the suppression on this manager's own focus-out close and set it only once the close is accepted. Also drop the candidate cap in the guard destination lookup, which could exhaust a truncated list and strand focus, and add the missing positive `inert` assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…estination
The handoff check added with the closing-popup work skips the focus return when something
outside the tree took focus after `activeEl` was already `body` — which is what stops a
trigger focus guard's own destination being overridden. It ran before explicitness was
resolved, so it also discarded a `finalFocus` the caller had named outright.
Resolve the return target and its explicitness together, and skip the check only when a ref
or function actually resolved to an element. `finalFocus={() => true}`, `{() => null}` and
an empty ref all fall back to the default target, so none of them counts as an instruction.
Also apply `inert` to a closing PreviewCard positioner, which was the one popup with
tabbable content still left in sequential focus navigation during its exit animation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tabbing backwards out of an open, non-modal Popover dropped focus to the body instead of moving it to the control before the trigger. Two things combined to cause it. The trigger's leading focus guard was not among the elements the focus manager recognises as its own, so focus landing there read as leaving the tree. `handleFocusOutside` defers to a microtask, which runs between the trigger's `focusout` and the guard's `focusin`, so the popup was already closed — and the guard unmounted with it — before the guard's own handler could run. Declaring it through `getInsideElements` leaves the close to the guard. The guard then resolves its own destination, so the focus manager must not return focus on top of it. It now records that a guard owns the close and skips the default return, while still honouring an explicit `finalFocus`, which outranks both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
michaldudak
force-pushed
the
claude/issue-5519-triage-ce7c69
branch
from
August 27, 2026 12:37
aacbaad to
098c4c7
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`guardOwnsDestination` was set when a focus guard requested a close, but never reset. A consumer can refuse a close by ignoring the open change rather than calling `cancel()`, which leaves the session live with the refused request's ownership still recorded. The next close the consumer *does* accept then inherits it, skips its return focus, and drops focus on `<body>` once the popup goes inert. Reset the flag at the start of each close request so every request derives its own policy. The reset is safe because the flag is written in exactly one place, immediately below. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shift+Tab out of an open `Menu.Trigger` left focus on `<body>`. The guard's focusout microtask closes the menu and unmounts the guard before its own handler can place focus, and the focus manager then returns focus on top of whatever the guard chose — the defect already fixed for Popover. Declare the trigger's leading guard through `getInsideElements` so the focus manager recognises it as its own and defers the destination to it, mirroring `PopoverPopup`. Gated on `parent.type === undefined` like its sibling props, so submenus and menubar items are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fixes to how a closing session decides whether to return focus.
A fallback `finalFocus` — a callback returning `true` or `null`, or an empty ref — resolves
to the default target rather than naming one, but the guard protecting focus the user had
already moved elsewhere still classified it by prop shape and treated it as explicit. So
`finalFocus={true}` left that focus alone while `finalFocus={() => true}` stole it back. Both
now use the resolved value.
`preventReturnFocus` and `guardOwnsDestination` describe a single close request, but a
controlled consumer can refuse a close without cancelling it, by leaving `open` at `true`.
The session survives, so policy from the refused request could be inherited by the next one.
Both are now reset at the start of each close dispatch, ahead of every path that sets them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5519.
The problem
A popup kept mounted for its exit animation is already logically closed, but nothing removed it from the accessibility tree or from sequential focus navigation until it unmounted.
usePositioner'sinertoption never set the attribute — it only appliedpointer-events: none.So mid-animation the trigger reports
aria-expanded="false"and dropsaria-controls, while the popup and its options stay exposed. Chrome's accessibility tree before the fix:Measured in Chromium: axe
aria-input-field-name(serious) fires on Combobox and Select, andaria-hidden-focus(serious) on Select, where Tab lands on anaria-hiddenguard and the next Tab drops to<body>. Dialog's closing popup stays in the tree alongside the content it just un-hid.The reporter saw intermittent CI failures. It isn't flaky — it's deterministic for the animation's duration, and the scan just happens to land inside or outside that window. This predates the positioner centralization in #4483.
The fix
Two mechanisms, both needed.
inertalone strands focus on<body>; handing focus back aloneleaves the stale subtree exposed.
usePositioner.tsxclosedoption applying the HTMLinertattribute. Deliberately separate from the existinginertoption, which is pointer-events only —TooltipPositionerpasses that one astruewhile open, so conflating them would hide an open tooltip from assistive tech.ComboboxPositioner,MenuPositioner,PopoverPositioner,SelectPositioner,PreviewCardPositionerclosed: !open.DialogPopup.tsx,DrawerPopup.tsxinertdirectly. Drawer scopes it to!open && !swiping— its swipe handlers are bound to the popup itself, so going inert mid-gesture would kill a dismissal in progress.FloatingFocusManager.tsxopen. Guard ownership of the close destination is re-derived on every close request, so a request the consumer refuses cannot leak its policy into the next accepted one.useTriggerFocusGuards.tsPopoverStore.ts,PopoverPopup.tsxgetInsideElements, so the focus manager recognises it as its own.MenuStore.ts,MenuPopup.tsxMenu, gated onparent.type === undefinedso submenus and menubar items are unaffected.Two subtleties worth calling out
A guard-driven close owns its destination.
handleFocusOutsidedefers to a microtask, and thatmicrotask runs between the trigger's
focusoutand the guard'sfocusin. So focus landing on thetrigger's own guard used to close the popup — and unmount the guard — before the guard's handler
could run, dropping focus to
<body>. Declaring the guard as an inside element leaves the close tothe guard, which then resolves where focus goes. The focus manager records that and skips its
default return rather than overriding it.
finalFocusoutranks everything. Resolution now yields both the target and whether the calleractually named it, so
finalFocus={() => true},finalFocus={() => null}and an empty ref allstill fall back to the default target, while a real ref or an element-returning callback wins over a
guard's chosen destination.
Behavior changes
Comboboxwith<Combobox.Input>inside the popup can no longer be typed into during theexit animation, so typing no longer reopens it. Reopen from the trigger; the filter resets. This
window only ever existed because focus lingered in a closed popup. Input outside the popup is
unaffected.
microtask runs. This replaces the previous behaviour, where the reopened popup still received the
earlier session's return focus — a visible flicker. It inverts the assertion of the test added in
[all components] Fix stale popup close modality #5388, now renamed
cancels focus restoration when reopening before it runs.forward when the popup renders no guards of its own (a hover-opened popup, where the focus manager
is disabled). Previously focus was left stranded on an
aria-hiddenguard.Menu.Triggermoves focus to the previous tabbable element instead ofdropping it on
<body>.Not in this PR
Deliberately deferred, with reasoning in the commits rather than silently dropped:
handleUnmountfocuses the previous trigger only after the animation — andNavigationMenuContentdeliberately skips its own
inertwhile focus is inside it. A positioner-levelinertwouldoverride that and strand focus. Needs its own handoff first.
the floating-tree containment check; the safe fix needs tree-level registration.
finalFocusread at cleanup can be one render stale if a consumer swaps it in the same renderthat closes the popup. Re-examined since: the ordering is real, but
openreaches the focusmanager through an ancestor's layout effect, which schedules a separate render, so the ref is
already fresh by the time the manager first renders closed. No triggering shape has been found
through the public API. Left annotated rather than fixed — the hazard is one refactor away from
becoming live.
usePositionerconsumer that passes neitherclosednorinert, so an ending toast stays exposed and tabbable.ToastRootappliesinertonly fortoast.limited, never fortransitionStatus === 'ending'. Not fixed here on purpose: Toast is alive region with viewport-level focus management, and inerting a dismissing toast risks more than
it fixes. The handbook's exception list names only Tooltip and Navigation Menu; Toast belongs
there too.
getReturnElementdistinguishes an explicitly namedfinalFocusfrom a fallback, but the"focus already moved outside" check still uses the looser test, so
finalFocus={() => true},() => nulland an empty ref can still reclaim focus. Pre-existing and unchanged by this PR. Thethree tests covering those forms open by hover, which disables the focus manager, so they do not
currently exercise that path.
Review status
Reviewed across several rounds, including the docs. The two most recent commits fix defects found by
that review: the sticky guard-ownership flag and the
Menuguard handoff. Both ship with regressiontests that were confirmed to fail without their fix (focus landing on
<body>in each case).