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)
CookieConsent.run() with a consent bar + preferences modal.
- 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).
- Press
Shift+Tab.
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.
Description
The preferences modal's focus trap intercepts
Shift+Tabonly whendocument.activeElementis the modal's first focusable element or outside the modal. But aftershowPreferences(), the library itself moves focus onto the dialog'stabindex="-1"container div (the delayed focus ~100ms after open). From that stateShift+Tabis 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
Tabfrom the container happens to move to the first focusable inside the dialog, and subsequent presses are trapped.Expected
Shift+Tabfrom 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)
CookieConsent.run()with a consent bar + preferences modal.document.activeElementis thediv[tabindex="-1"]child of.pm).Shift+Tab.document.activeElementis 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+Tabfires from.Cause
In the focus-trap keydown handler, the
shiftKeybranch only redirects whenactiveElement === firstFocusableor whenactiveElementis 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
activeElementis the dialog'stabindex="-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.