From 08ae0de92e9d0245e82a5dfb1db97fe1cb419067 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 12:12:19 +0300 Subject: [PATCH 01/15] fix: add ARIA group role to inline banner for screen reader accessibility Adds role="group" and aria-label to the InlineBanner container so screen readers announce the banner as a distinct region when navigating with the virtual cursor. --- packages/js/src/ai-content-planner/components/inline-banner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js/src/ai-content-planner/components/inline-banner.js b/packages/js/src/ai-content-planner/components/inline-banner.js index 13bd8abab46..174fdea201a 100644 --- a/packages/js/src/ai-content-planner/components/inline-banner.js +++ b/packages/js/src/ai-content-planner/components/inline-banner.js @@ -15,7 +15,7 @@ import { OneSparkNote } from "./one-spark-note"; */ export const InlineBanner = ( { isPremium, onDismiss, onClick } ) => { const ariaProps = useSvgAria(); - return
+ return

{ __( "Stuck on what to write next?", "wordpress-seo" ) }

From f89eae26b0055e097b25c6d991afd0f8aa10768e Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 12:14:25 +0300 Subject: [PATCH 02/15] fix: make inline banner keyboard-navigable via Tab in Gutenberg editor Gutenberg's useTabNav hook runs in the bubble phase and redirects Tab to sentinel divs when the next tabbable is not inside a [data-block] wrapper. The banner sits outside any real block, so it was always skipped. Two changes fix this: - Add data-block="yoast-content-planner-banner" to the wrapper div so that intra-banner Tab works via Gutenberg's existing null === null path in isInSameBlock. - Attach a capture-phase keydown listener on the editor document that fires before Gutenberg's bubble handler. When Tab crosses the banner boundary (entering or leaving), it calls preventDefault() and manually moves focus; Gutenberg's early-return guard then fires and leaves focus alone. The navigation logic is extracted to a standalone helper so it can be unit tested independently. @wordpress/dom is added as an explicit direct dependency since it is now imported directly. --- packages/js/package.json | 1 + .../components/with-inline-banner.js | 28 +++++++++++++++- .../helpers/handle-banner-tab-navigation.js | 32 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js diff --git a/packages/js/package.json b/packages/js/package.json index 037ba392466..eed10dc66db 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -21,6 +21,7 @@ "@wordpress/components": "^28.8.12", "@wordpress/compose": "^7.8.4", "@wordpress/data": "^10.8.4", + "@wordpress/dom": "^4.27.0", "@wordpress/dom-ready": "^4.8.1", "@wordpress/editor": "^14.33.10", "@wordpress/element": "^6.8.1", diff --git a/packages/js/src/ai-content-planner/components/with-inline-banner.js b/packages/js/src/ai-content-planner/components/with-inline-banner.js index 6a80c068c4f..f3b84fb56a3 100644 --- a/packages/js/src/ai-content-planner/components/with-inline-banner.js +++ b/packages/js/src/ai-content-planner/components/with-inline-banner.js @@ -1,3 +1,4 @@ +/* eslint-disable jsdoc/require-jsdoc */ import { createHigherOrderComponent } from "@wordpress/compose"; import { useSelect, useDispatch } from "@wordpress/data"; import { useCallback, useEffect, useRef } from "@wordpress/element"; @@ -5,6 +6,7 @@ import { InlineBanner } from "./inline-banner"; import { CONTENT_PLANNER_STORE, FEATURE_MODAL_STATUS, INJECTED_STYLE_ID } from "../constants"; import { STORE_NAME_AI, STORE_NAME_EDITOR } from "../../ai-generator/constants"; import { useFetchContentSuggestions } from "../hooks/use-fetch-content-suggestions"; +import { handleBannerTabNavigation } from "../helpers/handle-banner-tab-navigation"; /** * The component that conditionally renders the Content Planner inline banner and injects the Tailwind stylesheet into the editor iframe. @@ -67,10 +69,34 @@ const FirstBlockWithBanner = ( { BlockListBlock, props } ) => { ownerDoc.head.appendChild( link ); }, [ shouldShow ] ); + useEffect( () => { + if ( ! shouldShow ) { + return; + } + + // Gutenberg's writing-flow Tab handler runs in the bubble phase and redirects + // focus to sentinel divs when the next tabbable is not inside the same block. + // The banner sits outside any [data-block] block wrapper, so it is always + // skipped. Attaching a capture-phase listener lets us act before Gutenberg does; + // once we call preventDefault(), Gutenberg's early-return guard fires and leaves + // focus alone. + const ownerDoc = ref.current?.ownerDocument; + if ( ! ownerDoc ) { + return; + } + + function handleTabNavigation( event ) { + handleBannerTabNavigation( ref.current, event ); + } + + ownerDoc.addEventListener( "keydown", handleTabNavigation, { capture: true } ); + return () => ownerDoc.removeEventListener( "keydown", handleTabNavigation, { capture: true } ); + }, [ shouldShow ] ); + return ( <> { shouldShow && ( -
+
Date: Tue, 5 May 2026 12:20:31 +0300 Subject: [PATCH 03/15] test: add unit tests for handleBannerTabNavigation helpe Covers all branches: early returns (defaultPrevented, non-Tab key, Shift+Tab, null banner, no next tabbable), Tab into banner, Tab out of banner, intra-banner Tab, and Tab between two elements both outside the banner. --- .../helpers/handle-banner-tab-navigation.js | 1 + .../handle-banner-tab-navigation.test.js | 140 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index 7c80eafe1da..50612aae46e 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -13,6 +13,7 @@ import { focus } from "@wordpress/dom"; * @param {KeyboardEvent} event The keydown event. * @returns {void} */ +// eslint-disable-next-line complexity export function handleBannerTabNavigation( bannerEl, event ) { if ( event.defaultPrevented || event.keyCode !== 9 || event.shiftKey || ! bannerEl ) { return; diff --git a/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js new file mode 100644 index 00000000000..a297e776309 --- /dev/null +++ b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js @@ -0,0 +1,140 @@ +import { handleBannerTabNavigation } from "../../../src/ai-content-planner/helpers/handle-banner-tab-navigation"; + +const mockFindNext = jest.fn(); + +jest.mock( "@wordpress/dom", () => ( { + focus: { + tabbable: { + findNext: ( ...args ) => mockFindNext( ...args ), + }, + }, +} ) ); + +/** + * Creates a minimal keydown event mock. + * @param {object} overrides Properties to override on the default Tab event. + * @returns {object} The event mock. + */ +const makeEvent = ( overrides = {} ) => ( { + defaultPrevented: false, + keyCode: 9, + shiftKey: false, + target: document.createElement( "button" ), + preventDefault: jest.fn(), + ...overrides, +} ); + +describe( "handleBannerTabNavigation", () => { + let bannerEl; + let insideButton; + let outsideButton; + + beforeEach( () => { + bannerEl = document.createElement( "div" ); + insideButton = document.createElement( "button" ); + outsideButton = document.createElement( "button" ); + + bannerEl.appendChild( insideButton ); + document.body.appendChild( bannerEl ); + document.body.appendChild( outsideButton ); + + mockFindNext.mockReset(); + } ); + + afterEach( () => { + document.body.innerHTML = ""; + } ); + + describe( "early returns", () => { + it( "does nothing when event.defaultPrevented is true", () => { + const event = makeEvent( { defaultPrevented: true } ); + handleBannerTabNavigation( bannerEl, event ); + expect( mockFindNext ).not.toHaveBeenCalled(); + expect( event.preventDefault ).not.toHaveBeenCalled(); + } ); + + it( "does nothing for non-Tab keys", () => { + const event = makeEvent( { keyCode: 13 } ); + handleBannerTabNavigation( bannerEl, event ); + expect( mockFindNext ).not.toHaveBeenCalled(); + expect( event.preventDefault ).not.toHaveBeenCalled(); + } ); + + it( "does nothing for Shift+Tab", () => { + const event = makeEvent( { shiftKey: true } ); + handleBannerTabNavigation( bannerEl, event ); + expect( mockFindNext ).not.toHaveBeenCalled(); + expect( event.preventDefault ).not.toHaveBeenCalled(); + } ); + + it( "does nothing when bannerEl is null", () => { + const event = makeEvent(); + handleBannerTabNavigation( null, event ); + expect( mockFindNext ).not.toHaveBeenCalled(); + expect( event.preventDefault ).not.toHaveBeenCalled(); + } ); + + it( "does nothing when there is no next tabbable element", () => { + mockFindNext.mockReturnValue( null ); + const event = makeEvent( { target: outsideButton } ); + handleBannerTabNavigation( bannerEl, event ); + expect( event.preventDefault ).not.toHaveBeenCalled(); + } ); + } ); + + describe( "Tab into banner", () => { + it( "focuses the first banner button and prevents default when tabbing from outside", () => { + mockFindNext.mockReturnValue( insideButton ); + const focusSpy = jest.spyOn( insideButton, "focus" ); + const event = makeEvent( { target: outsideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( "Tab out of banner", () => { + it( "focuses the next element outside the banner and prevents default when tabbing from the last button", () => { + mockFindNext.mockReturnValue( outsideButton ); + const focusSpy = jest.spyOn( outsideButton, "focus" ); + const event = makeEvent( { target: insideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( "Tab within banner", () => { + it( "does not intercept when both current and next tabbable are inside the banner", () => { + const secondInsideButton = document.createElement( "button" ); + bannerEl.appendChild( secondInsideButton ); + mockFindNext.mockReturnValue( secondInsideButton ); + const focusSpy = jest.spyOn( secondInsideButton, "focus" ); + const event = makeEvent( { target: insideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).not.toHaveBeenCalled(); + expect( focusSpy ).not.toHaveBeenCalled(); + } ); + } ); + + describe( "Tab outside banner entirely", () => { + it( "does not intercept when both current and next tabbable are outside the banner", () => { + const anotherOutsideButton = document.createElement( "button" ); + document.body.appendChild( anotherOutsideButton ); + mockFindNext.mockReturnValue( anotherOutsideButton ); + const focusSpy = jest.spyOn( anotherOutsideButton, "focus" ); + const event = makeEvent( { target: outsideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).not.toHaveBeenCalled(); + expect( focusSpy ).not.toHaveBeenCalled(); + } ); + } ); +} ); From 65592643f790af20659f5bb7c11c05f35f20c726 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 12:36:08 +0300 Subject: [PATCH 04/15] add shift tab navigation --- .../helpers/handle-banner-tab-navigation.js | 9 +-- .../handle-banner-tab-navigation.test.js | 69 ++++++++++++++++--- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index 50612aae46e..ac8f38c3473 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -15,17 +15,18 @@ import { focus } from "@wordpress/dom"; */ // eslint-disable-next-line complexity export function handleBannerTabNavigation( bannerEl, event ) { - if ( event.defaultPrevented || event.keyCode !== 9 || event.shiftKey || ! bannerEl ) { + if ( event.defaultPrevented || event.keyCode !== 9 || ! bannerEl ) { return; } - const next = focus.tabbable.findNext( event.target ); + const findSibling = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext; + const next = findSibling( event.target ); if ( ! next ) { return; } - // Intercept only when Tab crosses the banner boundary (entering or leaving). - // Intra-banner Tab is already handled by Gutenberg via the data-block attribute. + // Intercept only when Tab or Shift+Tab crosses the banner boundary (entering or leaving). + // Intra-banner navigation is already handled by Gutenberg via the data-block attribute. if ( bannerEl.contains( event.target ) !== bannerEl.contains( next ) ) { event.preventDefault(); next.focus(); diff --git a/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js index a297e776309..fd4f8f68722 100644 --- a/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js +++ b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js @@ -1,11 +1,13 @@ import { handleBannerTabNavigation } from "../../../src/ai-content-planner/helpers/handle-banner-tab-navigation"; const mockFindNext = jest.fn(); +const mockFindPrevious = jest.fn(); jest.mock( "@wordpress/dom", () => ( { focus: { tabbable: { findNext: ( ...args ) => mockFindNext( ...args ), + findPrevious: ( ...args ) => mockFindPrevious( ...args ), }, }, } ) ); @@ -39,6 +41,7 @@ describe( "handleBannerTabNavigation", () => { document.body.appendChild( outsideButton ); mockFindNext.mockReset(); + mockFindPrevious.mockReset(); } ); afterEach( () => { @@ -60,13 +63,6 @@ describe( "handleBannerTabNavigation", () => { expect( event.preventDefault ).not.toHaveBeenCalled(); } ); - it( "does nothing for Shift+Tab", () => { - const event = makeEvent( { shiftKey: true } ); - handleBannerTabNavigation( bannerEl, event ); - expect( mockFindNext ).not.toHaveBeenCalled(); - expect( event.preventDefault ).not.toHaveBeenCalled(); - } ); - it( "does nothing when bannerEl is null", () => { const event = makeEvent(); handleBannerTabNavigation( null, event ); @@ -80,10 +76,17 @@ describe( "handleBannerTabNavigation", () => { handleBannerTabNavigation( bannerEl, event ); expect( event.preventDefault ).not.toHaveBeenCalled(); } ); + + it( "does nothing when there is no previous tabbable element (Shift+Tab)", () => { + mockFindPrevious.mockReturnValue( null ); + const event = makeEvent( { shiftKey: true, target: outsideButton } ); + handleBannerTabNavigation( bannerEl, event ); + expect( event.preventDefault ).not.toHaveBeenCalled(); + } ); } ); describe( "Tab into banner", () => { - it( "focuses the first banner button and prevents default when tabbing from outside", () => { + it( "focuses the first banner button and prevents default when tabbing forward from outside", () => { mockFindNext.mockReturnValue( insideButton ); const focusSpy = jest.spyOn( insideButton, "focus" ); const event = makeEvent( { target: outsideButton } ); @@ -93,10 +96,21 @@ describe( "handleBannerTabNavigation", () => { expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); expect( focusSpy ).toHaveBeenCalledTimes( 1 ); } ); + + it( "focuses the last banner button and prevents default when shift-tabbing backward from outside", () => { + mockFindPrevious.mockReturnValue( insideButton ); + const focusSpy = jest.spyOn( insideButton, "focus" ); + const event = makeEvent( { shiftKey: true, target: outsideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); + } ); } ); describe( "Tab out of banner", () => { - it( "focuses the next element outside the banner and prevents default when tabbing from the last button", () => { + it( "focuses the next element outside and prevents default when tabbing forward from the last button", () => { mockFindNext.mockReturnValue( outsideButton ); const focusSpy = jest.spyOn( outsideButton, "focus" ); const event = makeEvent( { target: insideButton } ); @@ -106,6 +120,17 @@ describe( "handleBannerTabNavigation", () => { expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); expect( focusSpy ).toHaveBeenCalledTimes( 1 ); } ); + + it( "focuses the previous element outside and prevents default when shift-tabbing from the first button", () => { + mockFindPrevious.mockReturnValue( outsideButton ); + const focusSpy = jest.spyOn( outsideButton, "focus" ); + const event = makeEvent( { shiftKey: true, target: insideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); + } ); } ); describe( "Tab within banner", () => { @@ -121,6 +146,19 @@ describe( "handleBannerTabNavigation", () => { expect( event.preventDefault ).not.toHaveBeenCalled(); expect( focusSpy ).not.toHaveBeenCalled(); } ); + + it( "does not intercept when both current and previous tabbable are inside the banner (Shift+Tab)", () => { + const secondInsideButton = document.createElement( "button" ); + bannerEl.appendChild( secondInsideButton ); + mockFindPrevious.mockReturnValue( insideButton ); + const focusSpy = jest.spyOn( insideButton, "focus" ); + const event = makeEvent( { shiftKey: true, target: secondInsideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).not.toHaveBeenCalled(); + expect( focusSpy ).not.toHaveBeenCalled(); + } ); } ); describe( "Tab outside banner entirely", () => { @@ -136,5 +174,18 @@ describe( "handleBannerTabNavigation", () => { expect( event.preventDefault ).not.toHaveBeenCalled(); expect( focusSpy ).not.toHaveBeenCalled(); } ); + + it( "does not intercept when both current and previous tabbable are outside the banner (Shift+Tab)", () => { + const anotherOutsideButton = document.createElement( "button" ); + document.body.appendChild( anotherOutsideButton ); + mockFindPrevious.mockReturnValue( anotherOutsideButton ); + const focusSpy = jest.spyOn( anotherOutsideButton, "focus" ); + const event = makeEvent( { shiftKey: true, target: outsideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).not.toHaveBeenCalled(); + expect( focusSpy ).not.toHaveBeenCalled(); + } ); } ); } ); From 9773d957a61d2667b674f40d9ff33c757a29eb54 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 13:37:28 +0300 Subject: [PATCH 05/15] fix complexity --- .../helpers/handle-banner-tab-navigation.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index ac8f38c3473..52a940c431d 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -13,7 +13,6 @@ import { focus } from "@wordpress/dom"; * @param {KeyboardEvent} event The keydown event. * @returns {void} */ -// eslint-disable-next-line complexity export function handleBannerTabNavigation( bannerEl, event ) { if ( event.defaultPrevented || event.keyCode !== 9 || ! bannerEl ) { return; @@ -21,13 +20,10 @@ export function handleBannerTabNavigation( bannerEl, event ) { const findSibling = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext; const next = findSibling( event.target ); - if ( ! next ) { - return; - } // Intercept only when Tab or Shift+Tab crosses the banner boundary (entering or leaving). // Intra-banner navigation is already handled by Gutenberg via the data-block attribute. - if ( bannerEl.contains( event.target ) !== bannerEl.contains( next ) ) { + if ( next && bannerEl.contains( event.target ) !== bannerEl.contains( next ) ) { event.preventDefault(); next.focus(); } From b33bde71d035f7c815e3c627a6153accd0869d8e Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 13:37:55 +0300 Subject: [PATCH 06/15] fix deprecated keycode --- .../helpers/handle-banner-tab-navigation.js | 2 +- .../helpers/handle-banner-tab-navigation.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index 52a940c431d..0a2c32a2c34 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -14,7 +14,7 @@ import { focus } from "@wordpress/dom"; * @returns {void} */ export function handleBannerTabNavigation( bannerEl, event ) { - if ( event.defaultPrevented || event.keyCode !== 9 || ! bannerEl ) { + if ( event.defaultPrevented || event.key !== "Tab" || ! bannerEl ) { return; } diff --git a/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js index fd4f8f68722..586e96c991d 100644 --- a/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js +++ b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js @@ -19,7 +19,7 @@ jest.mock( "@wordpress/dom", () => ( { */ const makeEvent = ( overrides = {} ) => ( { defaultPrevented: false, - keyCode: 9, + key: "Tab", shiftKey: false, target: document.createElement( "button" ), preventDefault: jest.fn(), @@ -57,7 +57,7 @@ describe( "handleBannerTabNavigation", () => { } ); it( "does nothing for non-Tab keys", () => { - const event = makeEvent( { keyCode: 13 } ); + const event = makeEvent( { key: "Enter" } ); handleBannerTabNavigation( bannerEl, event ); expect( mockFindNext ).not.toHaveBeenCalled(); expect( event.preventDefault ).not.toHaveBeenCalled(); From 863c05f23e985e462941add91d9d0930d21c7e53 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 13:38:11 +0300 Subject: [PATCH 07/15] fix js doc lint --- .../src/ai-content-planner/components/with-inline-banner.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/js/src/ai-content-planner/components/with-inline-banner.js b/packages/js/src/ai-content-planner/components/with-inline-banner.js index f3b84fb56a3..7cf7ac63ad9 100644 --- a/packages/js/src/ai-content-planner/components/with-inline-banner.js +++ b/packages/js/src/ai-content-planner/components/with-inline-banner.js @@ -1,4 +1,3 @@ -/* eslint-disable jsdoc/require-jsdoc */ import { createHigherOrderComponent } from "@wordpress/compose"; import { useSelect, useDispatch } from "@wordpress/data"; import { useCallback, useEffect, useRef } from "@wordpress/element"; @@ -85,6 +84,11 @@ const FirstBlockWithBanner = ( { BlockListBlock, props } ) => { return; } + /** + * Forwards keydown events to the banner Tab navigation helper. + * @param {KeyboardEvent} event The keydown event. + * @returns {void} + */ function handleTabNavigation( event ) { handleBannerTabNavigation( ref.current, event ); } From b388bc471eaab27b87e56e429863bf1849fb63ab Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 13:49:56 +0300 Subject: [PATCH 08/15] fix: reduce complexity --- .../helpers/handle-banner-tab-navigation.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index 0a2c32a2c34..fd4cc10a60b 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -1,5 +1,16 @@ import { focus } from "@wordpress/dom"; + +/** + * Get sibling function. + * + * @param {KeyboardEvent} event The keydown event. + * @returns {Function} The function to find the next or previous tabbable element. + */ +const getFindSibling = ( event ) => { + return event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext; +}; + /** * Keydown handler that keeps the inline banner reachable via Tab inside the Gutenberg * writing flow. Gutenberg's useTabNav hook intercepts Tab in the bubble phase and @@ -18,7 +29,7 @@ export function handleBannerTabNavigation( bannerEl, event ) { return; } - const findSibling = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext; + const findSibling = getFindSibling( event ); const next = findSibling( event.target ); // Intercept only when Tab or Shift+Tab crosses the banner boundary (entering or leaving). From c8b3c1617e4718853f3207f812ea223d4d83a2bd Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 15:00:03 +0300 Subject: [PATCH 09/15] fix: remove max width class --- packages/js/src/ai-content-planner/components/inline-banner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js/src/ai-content-planner/components/inline-banner.js b/packages/js/src/ai-content-planner/components/inline-banner.js index 174fdea201a..486538a80af 100644 --- a/packages/js/src/ai-content-planner/components/inline-banner.js +++ b/packages/js/src/ai-content-planner/components/inline-banner.js @@ -15,7 +15,7 @@ import { OneSparkNote } from "./one-spark-note"; */ export const InlineBanner = ( { isPremium, onDismiss, onClick } ) => { const ariaProps = useSvgAria(); - return
+ return

{ __( "Stuck on what to write next?", "wordpress-seo" ) }

From e21b4d4c89751a7e695d81140b97e1354293c3b3 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 16:07:12 +0300 Subject: [PATCH 10/15] feature(content-planner): adds learn more link to inline banner --- .../ai-content-planner/components/inline-banner.js | 14 ++++++++++---- .../components/with-inline-banner.js | 4 +++- .../components/with-inline-banner.test.js | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/js/src/ai-content-planner/components/inline-banner.js b/packages/js/src/ai-content-planner/components/inline-banner.js index 486538a80af..8c0f996aea9 100644 --- a/packages/js/src/ai-content-planner/components/inline-banner.js +++ b/packages/js/src/ai-content-planner/components/inline-banner.js @@ -1,7 +1,8 @@ import { __ } from "@wordpress/i18n"; -import { Button, GradientSparklesIcon, Root, useSvgAria } from "@yoast/ui-library"; -import { XIcon } from "@heroicons/react/solid"; +import { Button, GradientSparklesIcon, Link, Root, useSvgAria } from "@yoast/ui-library"; +import { ArrowNarrowRightIcon, XIcon } from "@heroicons/react/solid"; import { OneSparkNote } from "./one-spark-note"; +import { OutboundLink } from "../../shared-admin/components"; /** * The inline banner that is shown when the user has no content in a new post using the block editor. @@ -11,9 +12,10 @@ import { OneSparkNote } from "./one-spark-note"; * @param {boolean} props.isPremium Whether the user has a premium add-on activated. * @param {Function} props.onDismiss The function to call when the banner is dismissed. * @param {Function} props.onClick The function to call when the "Get content suggestions" button is clicked. + * @param {string} props.learnMoreLink The link to the learn more page about the AI Content Planner. * @returns {JSX.Element} The inline banner with the button. */ -export const InlineBanner = ( { isPremium, onDismiss, onClick } ) => { +export const InlineBanner = ( { isPremium, onDismiss, onClick, learnMoreLink } ) => { const ariaProps = useSvgAria(); return
@@ -24,9 +26,13 @@ export const InlineBanner = ( { isPremium, onDismiss, onClick } ) => { { __( "Close", "wordpress-seo" ) }
-

+

{ __( "Let Yoast analyze your site and suggest high-impact topics that fill content gaps and strengthen your SEO strategy.", "wordpress-seo" ) }

+ + { __( "Learn more", "wordpress-seo" ) } + +
{ ! isPremium && <> diff --git a/packages/js/src/ai-content-planner/components/with-inline-banner.js b/packages/js/src/ai-content-planner/components/with-inline-banner.js index 7cf7ac63ad9..3877d5dd497 100644 --- a/packages/js/src/ai-content-planner/components/with-inline-banner.js +++ b/packages/js/src/ai-content-planner/components/with-inline-banner.js @@ -14,7 +14,7 @@ import { handleBannerTabNavigation } from "../helpers/handle-banner-tab-navigati * @returns {JSX.Element} The wrapped block component with the inline banner conditionally rendered before it. */ const FirstBlockWithBanner = ( { BlockListBlock, props } ) => { - const { isNewPost, isBannerDismissed, isBannerRendered, hasConsent, isPremium, minPostsMet } = useSelect( ( select ) => { + const { isNewPost, isBannerDismissed, isBannerRendered, hasConsent, isPremium, minPostsMet, learnMoreLink } = useSelect( ( select ) => { const planner = select( CONTENT_PLANNER_STORE ); return { isNewPost: select( "core/editor" ).isEditedPostNew(), @@ -23,6 +23,7 @@ const FirstBlockWithBanner = ( { BlockListBlock, props } ) => { isPremium: select( STORE_NAME_EDITOR ).getIsPremium(), hasConsent: select( STORE_NAME_AI ).selectHasAiGeneratorConsent(), minPostsMet: select( CONTENT_PLANNER_STORE ).selectIsMinPostsMet(), + learnMoreLink: select( STORE_NAME_EDITOR ).selectLink( "https://yoa.st/content-planner-learn-more" ), }; }, [] ); @@ -105,6 +106,7 @@ const FirstBlockWithBanner = ( { BlockListBlock, props } ) => { isPremium={ isPremium } onDismiss={ handleDismiss } onClick={ handleClick } + learnMoreLink={ learnMoreLink } />
) } diff --git a/packages/js/tests/ai-content-planner/components/with-inline-banner.test.js b/packages/js/tests/ai-content-planner/components/with-inline-banner.test.js index 91af5bdbba4..a3c02b6c77a 100644 --- a/packages/js/tests/ai-content-planner/components/with-inline-banner.test.js +++ b/packages/js/tests/ai-content-planner/components/with-inline-banner.test.js @@ -50,6 +50,7 @@ const setupMocks = ( overrides = {} ) => { hasConsent: false, isPremium: false, minPostsMet: true, + learnMoreLink: "https://yoa.st/content-planner-learn-more", }; const values = { ...defaults, ...overrides }; @@ -68,7 +69,7 @@ const setupMocks = ( overrides = {} ) => { }; } if ( storeName === "yoast-seo/editor" ) { - return { getIsPremium: () => values.isPremium }; + return { getIsPremium: () => values.isPremium, selectLink: () => values.learnMoreLink }; } if ( storeName === "yoast-seo/ai-generator" ) { return { selectHasAiGeneratorConsent: () => values.hasConsent }; From d9026811da0c184f9eabd516b4de3674e9e117a0 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 16:15:56 +0300 Subject: [PATCH 11/15] fix navigation to learn more link --- .../helpers/handle-banner-tab-navigation.js | 8 +++++--- .../helpers/handle-banner-tab-navigation.test.js | 12 ++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index fd4cc10a60b..6d494ebc6af 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -32,9 +32,11 @@ export function handleBannerTabNavigation( bannerEl, event ) { const findSibling = getFindSibling( event ); const next = findSibling( event.target ); - // Intercept only when Tab or Shift+Tab crosses the banner boundary (entering or leaving). - // Intra-banner navigation is already handled by Gutenberg via the data-block attribute. - if ( next && bannerEl.contains( event.target ) !== bannerEl.contains( next ) ) { + // Intercept whenever focus is inside the banner or entering it. + // Gutenberg's WritingFlow Tab handler redirects focus to the next block rather than + // moving between tabbable elements inside the banner wrapper, so we must handle all + // banner-related Tab navigation ourselves — both intra-banner and boundary-crossing. + if ( next && ( bannerEl.contains( event.target ) || bannerEl.contains( next ) ) ) { event.preventDefault(); next.focus(); } diff --git a/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js index 586e96c991d..f8569b9f8f5 100644 --- a/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js +++ b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js @@ -134,7 +134,7 @@ describe( "handleBannerTabNavigation", () => { } ); describe( "Tab within banner", () => { - it( "does not intercept when both current and next tabbable are inside the banner", () => { + it( "intercepts and moves focus when both current and next tabbable are inside the banner", () => { const secondInsideButton = document.createElement( "button" ); bannerEl.appendChild( secondInsideButton ); mockFindNext.mockReturnValue( secondInsideButton ); @@ -143,11 +143,11 @@ describe( "handleBannerTabNavigation", () => { handleBannerTabNavigation( bannerEl, event ); - expect( event.preventDefault ).not.toHaveBeenCalled(); - expect( focusSpy ).not.toHaveBeenCalled(); + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); } ); - it( "does not intercept when both current and previous tabbable are inside the banner (Shift+Tab)", () => { + it( "intercepts and moves focus when both current and previous tabbable are inside the banner (Shift+Tab)", () => { const secondInsideButton = document.createElement( "button" ); bannerEl.appendChild( secondInsideButton ); mockFindPrevious.mockReturnValue( insideButton ); @@ -156,8 +156,8 @@ describe( "handleBannerTabNavigation", () => { handleBannerTabNavigation( bannerEl, event ); - expect( event.preventDefault ).not.toHaveBeenCalled(); - expect( focusSpy ).not.toHaveBeenCalled(); + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); } ); } ); From ddc63fdbfcb3c50b9481c22fa1bca91665b691e3 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 16:23:44 +0300 Subject: [PATCH 12/15] fix: reduce complexity for handleBannerTabNavigation --- .../helpers/handle-banner-tab-navigation.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index 6d494ebc6af..79430891f2d 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -11,6 +11,16 @@ const getFindSibling = ( event ) => { return event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext; }; +/** + * Returns whether a Tab navigation involves the banner — i.e. focus is currently inside + * the banner or is about to enter it. + * + * @param {HTMLElement} bannerEl The banner wrapper element. + * @param {HTMLElement} target The currently focused element. + * @param {HTMLElement} next The next tabbable element. + * @returns {boolean} Whether the banner is involved in the navigation. + */ +const involvesBanner = ( bannerEl, target, next ) => bannerEl.contains( target ) || bannerEl.contains( next ); /** * Keydown handler that keeps the inline banner reachable via Tab inside the Gutenberg * writing flow. Gutenberg's useTabNav hook intercepts Tab in the bubble phase and @@ -32,11 +42,9 @@ export function handleBannerTabNavigation( bannerEl, event ) { const findSibling = getFindSibling( event ); const next = findSibling( event.target ); - // Intercept whenever focus is inside the banner or entering it. - // Gutenberg's WritingFlow Tab handler redirects focus to the next block rather than - // moving between tabbable elements inside the banner wrapper, so we must handle all - // banner-related Tab navigation ourselves — both intra-banner and boundary-crossing. - if ( next && ( bannerEl.contains( event.target ) || bannerEl.contains( next ) ) ) { + // Intercept only when Tab or Shift+Tab crosses the banner boundary (entering or leaving). + // Intra-banner navigation is already handled by Gutenberg via the data-block attribute. + if ( next && involvesBanner( bannerEl, event.target, next ) ) { event.preventDefault(); next.focus(); } From aaf5cbf4514b5d9d7da2a15c975bae58e36ab104 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 16:24:26 +0300 Subject: [PATCH 13/15] fix spaces --- .../ai-content-planner/helpers/handle-banner-tab-navigation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js index 79430891f2d..4c5ca61d90f 100644 --- a/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -1,6 +1,5 @@ import { focus } from "@wordpress/dom"; - /** * Get sibling function. * @@ -21,6 +20,7 @@ const getFindSibling = ( event ) => { * @returns {boolean} Whether the banner is involved in the navigation. */ const involvesBanner = ( bannerEl, target, next ) => bannerEl.contains( target ) || bannerEl.contains( next ); + /** * Keydown handler that keeps the inline banner reachable via Tab inside the Gutenberg * writing flow. Gutenberg's useTabNav hook intercepts Tab in the bubble phase and From 9438322b1cbf8d726e6fe9178993bc6156bd61bc Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 16:35:16 +0300 Subject: [PATCH 14/15] fix: approve modal learn more link This fix enables consistent styling when using Link from ui library, also it avoids translation with placeholder. --- .../components/approve-modal.js | 28 ++++--------------- .../components/inline-banner.js | 2 +- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/js/src/ai-content-planner/components/approve-modal.js b/packages/js/src/ai-content-planner/components/approve-modal.js index 124f94917de..ce2b82d8120 100644 --- a/packages/js/src/ai-content-planner/components/approve-modal.js +++ b/packages/js/src/ai-content-planner/components/approve-modal.js @@ -1,4 +1,4 @@ -import { Button, Modal, GradientSparklesIcon, useSvgAria } from "@yoast/ui-library"; +import { Button, Modal, GradientSparklesIcon, Link, useSvgAria } from "@yoast/ui-library"; import { ArrowNarrowRightIcon } from "@heroicons/react/solid"; import { LockOpenIcon } from "@heroicons/react/outline"; @@ -66,15 +66,6 @@ export const ApproveModal = ( { isEmptyPost, isPremium, isUpsell, onClick, upsel const { title, description } = getModalContent( isEmptyPost, isUpsell ); const svgAriaProps = useSvgAria(); - const learnMoreLinkStructure = { - a: , - ArrowNarrowRightIcon: , - }; - return ( @@ -100,19 +91,10 @@ export const ApproveModal = ( { isEmptyPost, isPremium, isUpsell, onClick, upsel : }
- { safeCreateInterpolateElement( - sprintf( - /* translators: %1$s and %3$s are anchor tags; %2$s is the arrow icon. */ - __( - "%1$sLearn more%2$s%3$s", - "wordpress-seo" - ), - "", - "", - "" - ), - learnMoreLinkStructure - ) } + + { __( "Learn more", "wordpress-seo" ) } + +
diff --git a/packages/js/src/ai-content-planner/components/inline-banner.js b/packages/js/src/ai-content-planner/components/inline-banner.js index 8c0f996aea9..fd196d0592e 100644 --- a/packages/js/src/ai-content-planner/components/inline-banner.js +++ b/packages/js/src/ai-content-planner/components/inline-banner.js @@ -31,7 +31,7 @@ export const InlineBanner = ( { isPremium, onDismiss, onClick, learnMoreLink } )

{ __( "Learn more", "wordpress-seo" ) } - +
{ ! isPremium && <> From 254d515c686df6475d073d3c39c5aa1ba96738b6 Mon Sep 17 00:00:00 2001 From: Vraja Das Date: Tue, 5 May 2026 16:46:02 +0300 Subject: [PATCH 15/15] remove unused class --- packages/js/src/ai-content-planner/components/approve-modal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/js/src/ai-content-planner/components/approve-modal.js b/packages/js/src/ai-content-planner/components/approve-modal.js index ce2b82d8120..206af71b935 100644 --- a/packages/js/src/ai-content-planner/components/approve-modal.js +++ b/packages/js/src/ai-content-planner/components/approve-modal.js @@ -90,7 +90,7 @@ export const ApproveModal = ( { isEmptyPost, isPremium, isUpsell, onClick, upsel : } -
+
{ __( "Learn more", "wordpress-seo" ) }