Repository navigation
[Bug] Popover with isModal={false} pulls focus into itself on any Tab on the page #7687
Description
Description
While a Popover with isModal={false} is open, a Tab pressed anywhere on the page moves focus into the popover. That includes Tabs pressed while focus is on controls outside it that never went near it. Keyboard users can't move through the rest of the page in order while a non-modal popover is open. A popover left open beside other content, such as a comment thread kept open next to a list, makes that list unreachable by Tab.
Expected: a non-modal dialog leaves the page's focus order alone. The WAI-ARIA APG says only a modal dialog contains Tab. At minimum, I'd expect a Tab pressed outside the popover to move focus to the next control in the page, not into the popover.
Where it happens (@astryxdesign/core 0.7.1; the same lines are on main today):
packages/core/src/Popover/usePopover.tsx:424-427callsuseFocusTrap({isActive: layer.isOpen, onEscape: …}). The trap is active for every open popover, whateverisModalsays.packages/core/src/hooks/useFocusTrap.ts:396-456: thekeydownlistener ondocument(handleKeyDown) setsisKeyboardNavigationRef.current = truefor every Tab, before it checks whether focus is inside the container (line 404).packages/core/src/hooks/useFocusTrap.ts:336-375: the capture-phasefocuslistener ondocument(handleFocus) then sees focus land outside the container. Because the flag is set, it callsfocusFirstDescendant(container)(or the last descendant) and pulls focus into the popover.
Is a prop meant to stop it? Not as far as I can tell.
isModalis documented, and implemented, as settingaria-modalonly (usePopover.tsx:518): "Set tofalsefor non-modal dialogs that do not inert the rest of the page."hasLightDismiss={false},hasAutoFocus={false}andhasCloseButton={false}don't affect the trap either.Popover.spec.mdAV3 lists "focus containment" as part of the Popover model, so containing Tab within the popover may be intended even for non-modal popovers. Capturing Tabs that start outside it looks like a separate thing.useFocusTrap.ts:405-416already leaves Tab alone when focus "legitimately sits outside" a layer that has no tabbable controls.
Reproduction
Only Astryx components. Run it as a Vitest test in packages/core/src/Popover/ (jsdom with @testing-library/user-event):
import {describe, it, expect} from 'vitest';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {useRef} from 'react';
import {Popover} from './Popover';
function Page() {
const anchor = useRef<HTMLButtonElement>(null);
return (
<div>
<button ref={anchor} type="button">Anchor</button>
<Popover
anchorRef={anchor}
isOpen
isModal={false}
hasLightDismiss={false}
hasAutoFocus={false}
label="Details"
content={<button type="button">Inside</button>}
/>
<button type="button">Elsewhere 1</button>
<button type="button">Elsewhere 2</button>
</div>
);
}
describe('non-modal Popover and Tab elsewhere on the page', () => {
it('leaves a Tab pressed outside the popover to the page', async () => {
const user = userEvent.setup();
render(<Page />);
screen.getByRole('button', {name: 'Elsewhere 1'}).focus();
await user.tab();
expect(screen.getByRole('button', {name: 'Elsewhere 2'})).toHaveFocus();
});
});On main (91cfb53) the test fails: focus lands on the popover's fallback "Close popover" button instead of "Elsewhere 2". In a real browser (Chromium and WebKit) the same layout behaves the same way: with the popover open, every Tab from the rest of the page goes into it.
Possible fixes (an owner decision; I can send a PR for either)
- Leave Tabs from outside alone, for every trap. In
handleKeyDown, set the keyboard-navigation flag only when the Tab starts inside the container (or on it). Containment for focus already inside is unchanged. Caveat: a modal popover opened withhasAutoFocus={false}is then no longer entered by a Tab from its trigger; the APG expects a modal dialog to take focus when it opens anyway. - No Tab trap for a non-modal popover. In
usePopover, activate the trap only whenisModalis true, and register a non-modal popover's Escape throughuseLayerDismissaldirectly, asBottomSheetSwitcherdoes. This matches the APG non-modal pattern. It changes whatisModal={false}means beyondaria-modal, so it may need a spec update (AV3).
Astryx Version
@astryxdesign/core@0.7.1 (also on main, 91cfb53)
Environment
Chromium and WebKit (Safari's engine) via Playwright; jsdom for the reproduction above.
Activity
HelloOjasMutreja commentedon Oct 21, 2026
Reproduced on current main (acfd1ff), and the diagnosis holds from source.
Your test fails as written: focus lands on "Close popover". It fails the same way with
isModaltrue, soisModalis not what decides this.useFocusTrap'shandleKeyDownsetsisKeyboardNavigationReffor every Tab before checking where focus is, andhandleFocusthen pulls any focus that lands outside the container back in.I tried your first option locally, setting the flag only when
container.contains(document.activeElement). Your test passes (for bothisModalvalues), and the hooks, Popover, Drawer and BottomSheet suites pass unchanged (47 files, 875 tests). So nothing in them pins "a Tab from outside is captured". I did not try the second option.Two things I noticed that bear on the choice:
handleKeyDownalready leaves Tab alone when focus legitimately sits outside.useFocusTrapis also used byDrawerandBottomSheetSwitcher, so option 1 changes all three, while option 2 stays insideusePopover.#7356 (second-document Popover focus, Tab and Escape) also edits
useFocusTrap.tsandusePopover.tsx, so whichever fix lands here will want to be ordered against it.You offered to send the PR, so I will leave it to you unless a maintainer says otherwise. Happy to review it, or to take it if you would rather not.
[fix] useFocusTrap: leave a Tab that starts outside the trap to the p…
yimhanbi commentedon Oct 21, 2026
I checked option 1 in real browsers (Chrome and Safari, local Storybook story with the same layout as the repro), using the change in the commit referenced above (a test branch on my fork, not a PR). I found two things the jsdom test can't show:
Tab from the anchor still enters the popover and stays there. The layer opens with
showPopover({source: trigger})(useLayer.tsx:774), so browsers place the popover right after its trigger in the sequential focus order. With option 1 applied, Tab goes input → Anchor → Inside → Close popover → Inside again, for bothisModal={false}andisModal. Once focus is inside, the trap contains it, so a keyboard user moving forward from the anchor still can't reach the content after it. Option 1 fixes Tabs that start elsewhere (Elsewhere 1 → Elsewhere 2 → Elsewhere 3 works in Chrome), but the "list beside an open popover is unreachable" case is only partly fixed.The modal caveat for option 1 doesn't seem to happen in browsers. Because of the same
sourceordering, a modal popover withhasAutoFocus={false}is still entered by Tab from its trigger. It only goes to the next page control in jsdom, which doesn't implement invoker ordering.So for a non-modal popover to leave the page's focus order alone in practice, option 2 (no trap when
isModalis false) looks necessary, possibly with option 1 as well so modal traps also stop capturing Tabs from unrelated controls.@ejc3 since you're sending the PR, I hope this helps with the choice. Happy to re-run the browser check on your branch.
spec(Popover): a Popover is a focus region, modal or not (DEC-9) (#8608)
fix(Popover): an open popover no longer pulls in a Tab pressed elsewh…
cixzhang commentedon Oct 27, 2026
Thanks for the careful report, and to @HelloOjasMutreja and @yimhanbi for testing in real browsers. Decision: a Popover is a focus region, modal or not. Focus inside it still cycles with Tab and Shift+Tab, and Escape returns focus to the trigger, but a Tab pressed elsewhere on the page is no longer pulled in. This is recorded in the Popover spec (#8608). The fix is in #8611.
fix(Popover): an open popover no longer pulls in a Tab pressed elsewh…