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/approve-modal.js b/packages/js/src/ai-content-planner/components/approve-modal.js index 124f94917de..206af71b935 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 ( @@ -99,20 +90,11 @@ 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 13bd8abab46..fd196d0592e 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,11 +12,12 @@ 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
+ return

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

@@ -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 6a80c068c4f..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 @@ -5,6 +5,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. @@ -13,7 +14,7 @@ import { useFetchContentSuggestions } from "../hooks/use-fetch-content-suggestio * @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(), @@ -22,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" ), }; }, [] ); @@ -67,14 +69,44 @@ 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; + } + + /** + * Forwards keydown events to the banner Tab navigation helper. + * @param {KeyboardEvent} event The keydown event. + * @returns {void} + */ + function handleTabNavigation( event ) { + handleBannerTabNavigation( ref.current, event ); + } + + ownerDoc.addEventListener( "keydown", handleTabNavigation, { capture: true } ); + return () => ownerDoc.removeEventListener( "keydown", handleTabNavigation, { capture: true } ); + }, [ shouldShow ] ); + return ( <> { shouldShow && ( -
+
) } 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 new file mode 100644 index 00000000000..4c5ca61d90f --- /dev/null +++ b/packages/js/src/ai-content-planner/helpers/handle-banner-tab-navigation.js @@ -0,0 +1,51 @@ +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; +}; + +/** + * 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 + * redirects focus to sentinel divs when the next tabbable element is not inside the + * same [data-block] wrapper. Because the banner sits outside any real block, it is + * normally skipped. This handler is meant to be attached in the capture phase so it + * runs before Gutenberg; once it calls preventDefault(), Gutenberg's early-return guard + * fires and leaves focus alone. + * + * @param {HTMLElement} bannerEl The banner wrapper element. + * @param {KeyboardEvent} event The keydown event. + * @returns {void} + */ +export function handleBannerTabNavigation( bannerEl, event ) { + if ( event.defaultPrevented || event.key !== "Tab" || ! bannerEl ) { + return; + } + + 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 && involvesBanner( bannerEl, event.target, next ) ) { + event.preventDefault(); + next.focus(); + } +} 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 }; 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..f8569b9f8f5 --- /dev/null +++ b/packages/js/tests/ai-content-planner/helpers/handle-banner-tab-navigation.test.js @@ -0,0 +1,191 @@ +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 ), + }, + }, +} ) ); + +/** + * 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, + key: "Tab", + 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(); + mockFindPrevious.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( { key: "Enter" } ); + 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(); + } ); + + 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 forward 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 ); + } ); + + 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 and prevents default when tabbing forward 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 ); + } ); + + 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", () => { + 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 ); + const focusSpy = jest.spyOn( secondInsideButton, "focus" ); + const event = makeEvent( { target: insideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); + } ); + + 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 ); + const focusSpy = jest.spyOn( insideButton, "focus" ); + const event = makeEvent( { shiftKey: true, target: secondInsideButton } ); + + handleBannerTabNavigation( bannerEl, event ); + + expect( event.preventDefault ).toHaveBeenCalledTimes( 1 ); + expect( focusSpy ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + 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(); + } ); + + 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(); + } ); + } ); +} );