Skip to content

Shift+Tab from the just-opened preferences modal escapes the focus trap #828

Description

@virafb

Description

The preferences modal's focus trap intercepts Shift+Tab only when document.activeElement is the modal's first focusable element or outside the modal. But after showPreferences(), the library itself moves focus onto the dialog's tabindex="-1" container div (the delayed focus ~100ms after open). From that state Shift+Tab is not intercepted: the browser's default backward tab-navigation runs, and because that container is the dialog's first child and not tabbable, focus moves to the previous tabbable element in the document — outside the dialog, e.g. onto the consent bar behind the overlay.

So a keyboard user who opens the preferences modal and immediately presses Shift+Tab (a common way to reach the last control of a trapped dialog) lands behind the open modal.

The forward direction is fine: plain Tab from the container happens to move to the first focusable inside the dialog, and subsequent presses are trapped.

Expected

Shift+Tab from the dialog container (the library's own auto-focus target) wraps to the modal's last focusable element, like a trapped dialog normally behaves.

Reproduction (v3.1.0, Chromium/Firefox/WebKit)

  1. CookieConsent.run() with a consent bar + preferences modal.
  2. Open the preferences modal and wait ~150ms so the library's delayed focus lands on the dialog container (document.activeElement is the div[tabindex="-1"] child of .pm).
  3. Press Shift+Tab.
  4. document.activeElement is now outside #cc-main .pm (on the consent bar's button behind the overlay).

We reproduce this deterministically in Playwright; it also surfaced as a flaky e2e focus-trap test, because whether the user/test acts before or after the delayed focus decides which state Shift+Tab fires from.

Cause

In the focus-trap keydown handler, the shiftKey branch only redirects when activeElement === firstFocusable or when activeElement is not contained in the modal. The library's own auto-focus target (inside the modal, not the first focusable, not tabbable) falls through to browser default.

Suggested fix: also intercept when activeElement is the dialog's tabindex="-1" container (or any non-tabbable element inside the modal) and wrap to the last focusable.

We're currently shipping a small downstream keydown shim that completes the wrap, and will drop it once this is fixed. Happy to PR the fix if you'd take it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageyet to be reviewed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions