Skip to content

[popups] Keep closing popups out of the a11y tree and focus order - #5537

Draft
michaldudak wants to merge 10 commits into
mui:masterfrom
michaldudak:claude/issue-5519-triage-ce7c69
Draft

[popups] Keep closing popups out of the a11y tree and focus order#5537
michaldudak wants to merge 10 commits into
mui:masterfrom
michaldudak:claude/issue-5519-triage-ce7c69

Conversation

@michaldudak

@michaldudak michaldudak commented Aug 19, 2026

Copy link
Copy Markdown
Member

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's inert option never set the attribute — it only applied pointer-events: none.

So mid-animation the trigger reports aria-expanded="false" and drops aria-controls, while the popup and its options stay exposed. Chrome's accessibility tree before the fix:

dialog:   "Fruits"        [focusable]
combobox: "Search fruits" [focusable] [focused=true] [expanded=false]   ← focus parked in the dying popup
listbox:  ""              [focusable]                                   ← orphaned, unnamed
combobox: ""              [focusable] [expanded=false]                  ← the real trigger
option:   "Apple"  [selected=true]
option:   "Banana" [selected=false]

Measured in Chromium: axe aria-input-field-name (serious) fires on Combobox and Select, and aria-hidden-focus (serious) on Select, where Tab lands on an aria-hidden guard 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. inert alone strands focus on <body>; handing focus back alone
leaves the stale subtree exposed.

File Change
usePositioner.tsx New closed option applying the HTML inert attribute. Deliberately separate from the existing inert option, which is pointer-events only — TooltipPositioner passes that one as true while open, so conflating them would hide an open tooltip from assistive tech.
ComboboxPositioner, MenuPositioner, PopoverPositioner, SelectPositioner, PreviewCardPositioner Pass closed: !open.
DialogPopup.tsx, DrawerPopup.tsx No positioner, so they apply inert directly. 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.tsx Focus management is scoped to one session per open interval, and the session ends at the logical close rather than at unmount. Focus guards are gated on open. 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.ts Guard destinations resolve from a single tab-order snapshot taken before the close, since closing unmounts the guard the lookup was anchored on.
PopoverStore.ts, PopoverPopup.tsx The trigger's leading guard is declared through getInsideElements, so the focus manager recognises it as its own.
MenuStore.ts, MenuPopup.tsx The same guard declaration for Menu, gated on parent.type === undefined so submenus and menubar items are unaffected.

Two subtleties worth calling out

A guard-driven close owns its destination. handleFocusOutside defers to a microtask, and that
microtask runs between the trigger's focusout and the guard's focusin. So focus landing on the
trigger'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 to
the guard, which then resolves where focus goes. The focus manager records that and skips its
default return rather than overriding it.

finalFocus outranks everything. Resolution now yields both the target and whether the caller
actually named it, so finalFocus={() => true}, finalFocus={() => null} and an empty ref all
still fall back to the default target, while a real ref or an element-returning callback wins over a
guard's chosen destination.

Behavior changes

  • Focus returns to the trigger when a popup closes, not when its exit animation finishes.
  • A Combobox with <Combobox.Input> inside the popup can no longer be typed into during the
    exit 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.
  • A return focus queued by a close is cancelled if the popup reopens before the queued
    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.
  • Tabbing into a trigger's trailing focus guard from outside now closes the popup and moves focus
    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-hidden guard.
  • Shift+Tab out of an open Menu.Trigger moves focus to the previous tabbable element instead of
    dropping it on <body>.
  • Documented in the animation handbook and on the Combobox page.

Not in this PR

Deliberately deferred, with reasoning in the commits rather than silently dropped:

  • Navigation Menu is not inert while closing. It has no logical-close focus handoff —
    handleUnmount focuses the previous trigger only after the animation — and NavigationMenuContent
    deliberately skips its own inert while focus is inside it. A positioner-level inert would
    override that and strand focus. Needs its own handoff first.
  • Tooltip keeps pointer-events-only blocking.
  • Descendant logical inside elements (e.g. a nested Combobox dismiss control) are not covered by
    the floating-tree containment check; the safe fix needs tree-level registration.
  • finalFocus read at cleanup can be one render stale if a consumer swaps it in the same render
    that closes the popup. Re-examined since: the ordering is real, but open reaches the focus
    manager 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.
  • Toast is not covered. It is the one usePositioner consumer that passes neither closed nor
    inert, so an ending toast stays exposed and tabbable. ToastRoot applies inert only for
    toast.limited, never for transitionStatus === 'ending'. Not fixed here on purpose: Toast is a
    live 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.
  • getReturnElement distinguishes an explicitly named finalFocus from a fallback, but the
    "focus already moved outside" check still uses the looser test, so finalFocus={() => true},
    () => null and an empty ref can still reclaim focus. Pre-existing and unchanged by this PR. The
    three 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 Menu guard handoff. Both ship with regression
tests that were confirmed to fail without their fix (focus landing on <body> in each case).

@pkg-pr-new

pkg-pr-new Bot commented Aug 19, 2026

Copy link
Copy Markdown

commit: a8fc56e

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 19, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+1.72KB(+0.38%) 🔺+577B(+0.39%)

Details of bundle changes

Performance

Total duration: 1,362.34 ms -109.95 ms(-7.5%) | Renders: 76 (+0) | Paint: 2,199.81 ms -163.75 ms(-6.9%)

Test Duration Renders
Mixed surface mount (app-like density) 102.81 ms 🔺+40.89 ms(+66.0%) 5 (+0)
Combobox open — 500 items 42.56 ms 🔺+19.44 ms(+84.1%) 4 (+0)
Combobox type — 500 items, all stay mounted (type "Row ") 43.84 ms 🔺+18.01 ms(+69.7%) 11 (+0)
Combobox type — 500 items, narrows to ~11 (type "Row 25") 51.37 ms 🔺+17.93 ms(+53.6%) 15 (+0)
Slider mount (300 instances) 132.62 ms ▼-36.19 ms(-21.4%) 2 (+0)

…and 2 more (+8 within noise) — details

Metric alarms

Test Metric Change
Mixed surface mount (app-like density) bench:paint 🔺 +61.82 ms
Combobox open — 500 items bench:paint#combobox-open 🔺 +45.45 ms
Combobox open — 500 items bench:paint 🔺 +45.45 ms
Combobox type — 500 items, all stay mounted (type "Row ") bench:paint#combobox-open 🔺 +41.18 ms
Combobox type — 500 items, all stay mounted (type "Row ") bench:paint 🔺 +41.18 ms

…and 2 more metric alarms — details


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

@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit a8fc56e
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a9167ce24f1460008b7da2a
😎 Deploy Preview https://deploy-preview-5537--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@michaldudak
michaldudak force-pushed the claude/issue-5519-triage-ce7c69 branch 2 times, most recently from 925fcc4 to 47f9735 Compare August 19, 2026 10:35
@michaldudak michaldudak changed the title [all components] Keep closing popups out of the a11y tree and focus order [popups] Keep closing popups out of the a11y tree and focus order Aug 19, 2026
@michaldudak michaldudak added component: combobox Changes related to the combobox component. component: autocomplete Changes related to the autocomplete component. component: select Changes related to the select component. component: menu Changes related to the menu component. component: context menu Changes related to the context menu component. component: menubar Changes related to the menubar component. component: popover Changes related to the popover component. component: navigation menu Changes related to the navigation menu component. component: preview card Changes related to the preview card component. component: tooltip Changes related to the tooltip component. component: dialog Changes related to the dialog component. component: alert dialog Changes related to the alert dialog component. component: drawer Changes related to the drawer component. accessibility a11y labels Aug 19, 2026
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
michaldudak force-pushed the claude/issue-5519-triage-ce7c69 branch 9 times, most recently from 048ffe4 to c3a62a6 Compare August 19, 2026 14:04
@michaldudak
michaldudak force-pushed the claude/issue-5519-triage-ce7c69 branch 4 times, most recently from d0dd779 to 29bdc0c Compare August 21, 2026 13:03
michaldudak and others added 6 commits August 27, 2026 09:09
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
michaldudak force-pushed the claude/issue-5519-triage-ce7c69 branch from aacbaad to 098c4c7 Compare August 27, 2026 12:37
michaldudak and others added 4 commits August 27, 2026 14:42
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

accessibility a11y component: alert dialog Changes related to the alert dialog component. component: autocomplete Changes related to the autocomplete component. component: combobox Changes related to the combobox component. component: context menu Changes related to the context menu component. component: dialog Changes related to the dialog component. component: drawer Changes related to the drawer component. component: menu Changes related to the menu component. component: menubar Changes related to the menubar component. component: navigation menu Changes related to the navigation menu component. component: popover Changes related to the popover component. component: preview card Changes related to the preview card component. component: select Changes related to the select component. component: tooltip Changes related to the tooltip component.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[combobox] Prevent inaccessible popup state during exit animations

1 participant