Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 6 additions & 24 deletions packages/js/src/ai-content-planner/components/approve-modal.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -66,15 +66,6 @@ export const ApproveModal = ( { isEmptyPost, isPremium, isUpsell, onClick, upsel
const { title, description } = getModalContent( isEmptyPost, isUpsell );
const svgAriaProps = useSvgAria();

const learnMoreLinkStructure = {
a: <OutboundLink
href={ learnMoreLink }
className="yst-inline-flex yst-items-center yst-gap-1 yst-no-underline yst-font-medium"
variant="primary"
/>,
ArrowNarrowRightIcon: <ArrowNarrowRightIcon className="yst-w-4 yst-h-4 rtl:yst-rotate-180" />,
};

return (
<Modal isOpen={ isOpen } onClose={ onClose }>
<Modal.Panel className="yst-text-center yst-w-96" closeButtonScreenReaderText={ __( "Close modal", "wordpress-seo" ) }>
Expand All @@ -99,20 +90,11 @@ export const ApproveModal = ( { isEmptyPost, isPremium, isUpsell, onClick, upsel
</span>
</Button>
: <Button onClick={ onClick } variant="ai-primary" className="yst-w-full"> { __( "Get content suggestions", "wordpress-seo" ) } </Button> }
<div className="yst-mt-2 yst-text-slate-600 yst-text-sm">
{ 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"
),
"<a>",
"<ArrowNarrowRightIcon />",
"</a>"
),
learnMoreLinkStructure
) }
<div className="yst-mt-2 yst-text-sm">
<Link as={ OutboundLink } href={ learnMoreLink } variant="primary" className="yst-inline yst-no-underline yst-font-medium">
{ __( "Learn more", "wordpress-seo" ) }
<ArrowNarrowRightIcon className="yst-w-4 yst-h-4 rtl:yst-rotate-180 yst-inline yst-ms-1.5" />
</Link>
</div>
</Modal.Panel>
</Modal>
Expand Down
16 changes: 11 additions & 5 deletions packages/js/src/ai-content-planner/components/inline-banner.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 <Root><div className="yst-z-50 yst-relative yst-p-4 yst-ai-gradient-border yst-rounded-lg yst-max-w">
return <Root><div role="group" aria-label={ __( "Content suggestions banner", "wordpress-seo" ) } className="yst-z-50 yst-relative yst-p-4 yst-ai-gradient-border yst-rounded-lg">
<div className="yst-flex yst-items-center yst-gap-2 yst-mb-1">
<GradientSparklesIcon className="yst-h-4 yst-w-4" { ...ariaProps } />
<p className="yst-grow yst-text-slate-800 yst-font-medium"> { __( "Stuck on what to write next?", "wordpress-seo" ) }</p>
Expand All @@ -24,9 +26,13 @@ export const InlineBanner = ( { isPremium, onDismiss, onClick } ) => {
<span className="yst-sr-only">{ __( "Close", "wordpress-seo" ) }</span>
</button>
</div>
<p className="yst-text-sm yst-text-slate-600">
<p className="yst-text-sm yst-text-slate-600 yst-mb-1">
{ __( "Let Yoast analyze your site and suggest high-impact topics that fill content gaps and strengthen your SEO strategy.", "wordpress-seo" ) }
</p>
<Link as={ OutboundLink } href={ learnMoreLink } variant="primary" className="yst-font-medium yst-no-underline">
{ __( "Learn more", "wordpress-seo" ) }
<ArrowNarrowRightIcon className="yst-w-4 yst-h-4 rtl:yst-rotate-180 yst-inline yst-ms-1.5" />
</Link>
<div className="yst-mt-1 yst-flex yst-justify-end yst-gap-2 yst-items-center">
{ ! isPremium && <>
<OneSparkNote />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(),
Expand All @@ -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" ),
};
}, [] );

Expand Down Expand Up @@ -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 && (
<div ref={ ref } className="wp-block">
<div ref={ ref } className="wp-block" data-block="yoast-content-planner-banner">
<InlineBanner
isPremium={ isPremium }
onDismiss={ handleDismiss }
onClick={ handleClick }
learnMoreLink={ learnMoreLink }
/>
</div>
) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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 };
Expand Down
Loading
Loading