Skip to content

[Bug] Popover with isModal={false} pulls focus into itself on any Tab on the page #7687

Closed

Description

@ejc3

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-427 calls useFocusTrap({isActive: layer.isOpen, onEscape: …}). The trap is active for every open popover, whatever isModal says.
  • packages/core/src/hooks/useFocusTrap.ts:396-456: the keydown listener on document (handleKeyDown) sets isKeyboardNavigationRef.current = true for every Tab, before it checks whether focus is inside the container (line 404).
  • packages/core/src/hooks/useFocusTrap.ts:336-375: the capture-phase focus listener on document (handleFocus) then sees focus land outside the container. Because the flag is set, it calls focusFirstDescendant(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.

  • isModal is documented, and implemented, as setting aria-modal only (usePopover.tsx:518): "Set to false for non-modal dialogs that do not inert the rest of the page."
  • hasLightDismiss={false}, hasAutoFocus={false} and hasCloseButton={false} don't affect the trap either.
  • Popover.spec.md AV3 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-416 already 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)

  1. 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 with hasAutoFocus={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.
  2. No Tab trap for a non-modal popover. In usePopover, activate the trap only when isModal is true, and register a non-modal popover's Escape through useLayerDismissal directly, as BottomSheetSwitcher does. This matches the APG non-modal pattern. It changes what isModal={false} means beyond aria-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

  1. HelloOjasMutreja commented on Oct 21, 2026

    @HelloOjasMutreja
    Contributor

    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 isModal true, so isModal is not what decides this. useFocusTrap's handleKeyDown sets isKeyboardNavigationRef for every Tab before checking where focus is, and handleFocus then 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 both isModal values), 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:

    • The spec rows for Tab (Popover.spec.md, "Tab from a focused container" and Shift+Tab) only describe Tab from a focused container, and AR3 only requires that a trapped surface with no tabbable descendant does not let focus escape. Neither asks for Tabs that start outside to be captured, which fits your reading of AV3 as a separate matter. The existing no-tabbables branch in handleKeyDown already leaves Tab alone when focus legitimately sits outside.
    • useFocusTrap is also used by Drawer and BottomSheetSwitcher, so option 1 changes all three, while option 2 stays inside usePopover.

    #7356 (second-document Popover focus, Tab and Escape) also edits useFocusTrap.ts and usePopover.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.

  2. yimhanbi commented on Oct 21, 2026

    @yimhanbi

    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:

    1. 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 both isModal={false} and isModal. 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.

    2. The modal caveat for option 1 doesn't seem to happen in browsers. Because of the same source ordering, a modal popover with hasAutoFocus={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 isModal is 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.

  3. cixzhang commented on Oct 27, 2026

    @cixzhang
    Contributor

    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.

  4. added a commit that references this issue on Oct 27, 2026
    81eeb6a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions