Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
23b62b2
adds the suggestions modal
vraja-pro Mar 23, 2026
9c02810
Merge branch '1105-next-post-create-cta-inline-banner-and-approve-mod…
vraja-pro Mar 23, 2026
bcaafe0
Apply suggestion from @Copilot
vraja-pro Mar 23, 2026
1fbd507
fix lint, js comments, and adds tests
vraja-pro Mar 23, 2026
d64cb2d
add isPremium prop
vraja-pro Mar 24, 2026
c9f54ba
adds window object for testing loading state
vraja-pro Mar 24, 2026
27a4ee4
fix js docs
vraja-pro Mar 24, 2026
e79b828
fix spacing
vraja-pro Mar 25, 2026
80d0181
update aria attribute
vraja-pro Mar 25, 2026
6457600
fix spacing
vraja-pro Mar 25, 2026
c6f9470
fix linting
vraja-pro Mar 25, 2026
dbaa7bc
fix aria attribute and adds tests
vraja-pro Mar 25, 2026
fc6c177
improve accessibility + tests
vraja-pro Mar 25, 2026
29a6382
fix focus styling
vraja-pro Mar 25, 2026
7bcf720
Adds svgAriaProps in the YoastIcon
vraja-pro Mar 26, 2026
4b882c3
fix linting
vraja-pro Mar 26, 2026
4bae89f
add transition effect when content changes in the modal
vraja-pro Mar 27, 2026
617fbeb
adds svg props
vraja-pro Mar 27, 2026
c8e559a
fix modal transition
vraja-pro Mar 27, 2026
0b9fdcf
Add delay to stop flickering
vraja-pro Mar 27, 2026
8960137
Merge branch 'feature/content-planner' into content-planner-content-s…
vraja-pro Mar 27, 2026
1e5caf2
fix tests
vraja-pro Mar 27, 2026
2aeb37c
fix modal title and description for accessibility
vraja-pro Mar 27, 2026
474fa32
fix focus
vraja-pro Mar 27, 2026
9e5a5df
fix review comments
vraja-pro Mar 27, 2026
f817b26
fix tests and cleanup
vraja-pro Mar 27, 2026
9b81837
fix screen reader for loading
vraja-pro Mar 27, 2026
af150bb
fix lint
vraja-pro Mar 28, 2026
64b165b
add time out for screen reader
vraja-pro Mar 30, 2026
0a09b58
fix tests
vraja-pro Mar 30, 2026
f9f4faa
Adds comment
vraja-pro Mar 30, 2026
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
36 changes: 13 additions & 23 deletions packages/js/src/ai-content-planner/components/approve-modal.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Button, Modal, GradientSparklesIcon } from "@yoast/ui-library";
import { Button, Modal, GradientSparklesIcon, useSvgAria } from "@yoast/ui-library";
import { __, sprintf } from "@wordpress/i18n";
import { safeCreateInterpolateElement } from "../../helpers/i18n";
import { OneSparkNote } from "./one-spark-note";
import { useCallback } from "@wordpress/element";

/**
* Get the content of the modal based on whether the canvas is empty or not.
*
Expand Down Expand Up @@ -36,32 +34,24 @@ const getModalContent = ( isEmptyCanvas ) => {
/**
* The modal that is shown when the user clicks the "Get content suggestions" button.
*
* @param {boolean} isOpen Whether the modal is open or not.
* @param {function} onClose The function to call when the modal is closed.
* @param {boolean} isEmptyCanvas Whether the post has content or not.
* @param {boolean} isPremium Whether the user has a premium subscription or not.
* @param {boolean} isUpsell Whether the modal is shown as an upsell or not.
* @param {function} onClick The function to call when the user clicks the "Get content suggestions" button in the modal.
* @param {function} onClick The function to call when the user clicks the "Get content suggestions" button.
* @param {string} upsellLink The link to the upsell page.
* @returns {JSX.Element} The Content Planner Approved Modal.
* @returns {JSX.Element} The ApproveModal content.
*/
export const ApproveModal = ( { isOpen, onClose, isEmptyCanvas, isPremium, isUpsell, onClick, upsellLink } ) => {
const { title, description } = getModalContent( isEmptyCanvas, isUpsell );
const handleOnClick = useCallback( () => {
onClick();
onClose();
}, [ onClick, onClose ] );
export const ApproveModal = ( { isEmptyCanvas, isPremium, isUpsell, onClick, upsellLink } ) => {
const { title, description } = getModalContent( isEmptyCanvas );
const svgAriaProps = useSvgAria();

return <Modal
isOpen={ isOpen }
onClose={ onClose }
>
<Modal.Panel className="yst-text-center yst-w-96">
return (
<Modal.Panel className="yst-text-center yst-w-96" closeButtonScreenReaderText={ __( "Close modal", "wordpress-seo" ) }>
<div className="yst-w-12 yst-h-12 yst-rounded-full yst-bg-ai-100 yst-flex yst-items-center yst-justify-center yst-mx-auto yst-mb-4">
<GradientSparklesIcon className="yst-h-6 yst-w-6" />
<GradientSparklesIcon className="yst-h-6 yst-w-6" { ...svgAriaProps } />
</div>
<h3 className="yst-text-slate-900 yst-font-medium yst-text-lg yst-mb-2">{ title }</h3>
<p className="yst-text-slate-600 yst-text-sm yst-mb-6 yst-mx-6">{ description }</p>
<Modal.Title className="yst-text-slate-900 yst-font-medium yst-text-lg yst-mb-2">{ title }</Modal.Title>
<Modal.Description className="yst-text-slate-600 yst-text-sm yst-mb-6 yst-mx-6">{ description }</Modal.Description>
{ isUpsell ? <Button
variant="upsell" as="a" href={ upsellLink } target="_blank" className="yst-w-full" rel="noopener noreferrer"
>
Expand All @@ -75,8 +65,8 @@ export const ApproveModal = ( { isOpen, onClose, isEmptyCanvas, isPremium, isUps
__( "(Opens in a new browser tab)", "wordpress-seo" ) }
</span>
</Button>
: <Button onClick={ handleOnClick } variant="ai-primary" className="yst-w-full"> { __( "Get content suggestions", "wordpress-seo" ) } </Button> }
: <Button onClick={ onClick } variant="ai-primary" className="yst-w-full"> { __( "Get content suggestions", "wordpress-seo" ) } </Button> }
{ ! isPremium && ! isUpsell && <OneSparkNote className="yst-mt-2" /> }
</Modal.Panel>
</Modal>;
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { __ } from "@wordpress/i18n";
import { Button, Root, useToggleState } from "@yoast/ui-library";
import { ApproveModal } from "./approve-modal";
import { FeatureModal } from "./feature-modal";

/**
* The section for the content planner feature in the Yoast sidebar.
Expand All @@ -9,27 +9,24 @@ import { ApproveModal } from "./approve-modal";
* @param {string} props.location The location where the editor item is rendered. Can be "sidebar" or "metabox".
* @param {boolean} props.isPremium Whether the user has a premium add-on activated.
* @param {boolean} props.isEmptyCanvas Whether the editor canvas has no content.
* @param {boolean} props.isUpsell Whether to show the upsell variant of the modal.
* @param {string} props.upsellLink The link to the upsell page for the content planner feature.
* @returns {JSX.Element} The Content Planner section in the sidebar.
*/
export const ContentPlannerEditorItem = ( { location, isPremium, isEmptyCanvas, upsellLink } ) => {
const [ isApproveModalOpen, , , openApproveModal, closeApproveModal ] = useToggleState( false );
const [ , , , openContentSuggestionModal ] = useToggleState( false );
export const ContentPlannerEditorItem = ( { location, isPremium, isEmptyCanvas, isUpsell, upsellLink } ) => {
const [ isFeatureModalOpen, , , openFeatureModal, closeFeatureModal ] = useToggleState( false );

return <Root><div className="yst-p-4">
<Button variant="ai-secondary" onClick={ openApproveModal } className={ location === "sidebar" ? "yst-w-full" : "" }>
<Button variant="ai-secondary" onClick={ openFeatureModal } className={ location === "sidebar" ? "yst-w-full" : "" }>
{ __( "Get content suggestions", "wordpress-seo" ) }
</Button>
<ApproveModal
isOpen={ isApproveModalOpen }
onClose={ closeApproveModal }
<FeatureModal
isOpen={ isFeatureModalOpen }
onClose={ closeFeatureModal }
isEmptyCanvas={ isEmptyCanvas }
isPremium={ isPremium }
onClick={ openContentSuggestionModal }
// Will be addressed in future iterations.
isUpsell={ false }
isUpsell={ isUpsell }
upsellLink={ upsellLink }
/>
</div>
</Root>;
</div></Root>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { Badge, Modal, SkeletonLoader, useSvgAria } from "@yoast/ui-library";
import { __ } from "@wordpress/i18n";
import { ReactComponent as YoastIcon } from "../../../images/Yoast_icon_kader.svg";
import { ReactComponent as Yoast } from "../../../images/yoast.svg";
import { UsageCounter } from "@yoast/ai-frontend";
import { BookOpenIcon, StarIcon, MapIcon } from "@heroicons/react/outline";
import { noop } from "lodash";
import classNames from "classnames";
import { Fragment, useRef, useEffect, useState } from "@wordpress/element";
import { Transition } from "@headlessui/react";

const intentBadge = {
informational: {
classes: "yst-bg-blue-200 yst-text-blue-900",
Icon: BookOpenIcon,
label: __( "Informational", "wordpress-seo" ),
},
navigational: {
classes: "yst-bg-violet-200 yst-text-violet-900",
Icon: MapIcon,
label: __( "Navigational", "wordpress-seo" ),
},
commercial: {
classes: "yst-bg-yellow-200 yst-text-yellow-900",
Icon: StarIcon,
label: __( "Commercial", "wordpress-seo" ),
},
};

// Placeholder suggestions — will be replaced with real API data in a future iteration.
const suggestions = [
{
intent: "informational",
title: "How to train your dog",
description: "Tips and tricks on how to train your dog effectively.",
},
{
intent: "navigational",
title: "Best dog training schools in New York",
description: "A list of the best dog training schools in New York.",
},
{
intent: "commercial",
title: "Top 10 dog training tools",
description: "A review of the top 10 dog training tools on the market.",
},
{
intent: "informational",
title: "How to groom your dog",
description: "Step-by-step guide on how to groom your dog at home.",
},
{
intent: "navigational",
title: "Dog parks in Los Angeles",
description: "Find the best dog parks in Los Angeles for your furry friend.",
},
{
intent: "commercial",
title: "Best dog food brands",
description: "An overview of the best dog food brands for a healthy diet.",
},
];

/**
* Suggestion button component.
*
* @param {object} props The component props.
* @param {string} props.intent The intent of the suggestion.
* @param {string} props.title The title of the suggestion.
* @param {string} props.description The description of the suggestion.
* @param {Function} props.onClick The function to call when the suggestion button is clicked.
*
* @returns {JSX.Element} The SuggestionButton component.
*/
const SuggestionButton = ( { intent, title, description, onClick } ) => {
const svgAriaProps = useSvgAria();
const Icon = intentBadge[ intent ] ? intentBadge[ intent ].Icon : BookOpenIcon;
return (
<button type="button" onClick={ onClick } className="yst-text-start yst-w-full yst-rounded-md yst-border yst-border-slate-200 yst-mb-4 yst-p-4 yst-shadow-sm focus:yst-outline focus:yst-outline-2 focus:yst-outline-offset-2 focus:yst-outline-primary-500">
{ intentBadge[ intent ] ? (
<Badge className={ classNames( "yst-flex yst-items-center yst-gap-1 yst-w-fit yst-mb-2 yst-text-xs", intentBadge[ intent ].classes ) }>
<Icon className={ classNames( "yst-w-3 ", intentBadge[ intent ].classes ) } { ...svgAriaProps } /> { intentBadge[ intent ].label }
</Badge>
) : (
<Badge>{ intent }</Badge>
) }
<div className="yst-font-medium yst-text-sm yst-mb-2 yst-text-slate-800">{ title }</div>
<p className="yst-text-slate-600">{ description }</p>
</button>
);
};

/**
* Loading skeleton for the SuggestionButton component.
*
* @returns {JSX.Element} The SuggestionButtonSkeleton component.
*/
const SuggestionButtonSkeleton = () => (
<div className="yst-w-full yst-rounded-md yst-border yst-border-slate-200 yst-mb-4 yst-p-4 yst-shadow-sm">
<div className="yst-px-2 yst-py-1 yst-bg-white yst-inline-flex yst-gap-1 yst-items-center yst-justify-start yst-mb-2 yst-rounded-3xl yst-border yst-border-slate-300">
<SkeletonLoader className="yst-w-2 yst-h-2 yst-rounded-full" />
<SkeletonLoader className="yst-w-20 yst-h-3 yst-rounded" />
</div>
<SkeletonLoader className="yst-w-64 yst-h-[18px] yst-rounded yst-mb-3" />
<SkeletonLoader className="yst-w-full yst-h-[13px] yst-rounded yst-mb-2" />
<SkeletonLoader className="yst-w-2/3 yst-h-[13px] yst-rounded" />
</div>
);

/**
* The loading content for the ContentSuggestionsModal.
*
* @returns {JSX.Element} The loading content for the ContentSuggestionsModal.
*/
const LoadingModalContent = () => {
const svgAriaProps = useSvgAria();
return (
<>
<div className="yst-flex yst-flex-col yst-items-center yst-pb-8">
<Yoast className="yst-w-24 yst-text-primary-300 yst-mb-2" { ...svgAriaProps } />
<Modal.Description className="yst-italic yst-text-slate-500">
<span className="yst-sr-only"> Yoast </span>
{ __( "Analyzing your site content…", "wordpress-seo" ) }</Modal.Description>
</div>
<div className="yst-relative">
{ [ ...Array( 5 ) ].map( ( _, index ) => <SuggestionButtonSkeleton key={ index } /> ) }
{ /* gradient overlay to create a fade effect at the bottom of the modal content */ }
<div
className="yst-absolute yst-inset-0 yst-bg-gradient-to-t yst-from-white yst-to-transparent yst-transition-opacity"
aria-hidden="true"
/>
</div>
</>
);
};

/**
* @typedef {Object} Suggestion
* @property {string} intent The intent of the suggestion (e.g. "informational", "navigational", "commercial").
* @property {string} title The title of the suggestion.
* @property {string} description The description of the suggestion.
*/

/**
* ContentSuggestionsModal component.
*
* @param {Object} props The component props.
* @param {string} props.status The current status of the modal ("content-suggestions-loading" or "content-suggestions-success").
* @param {boolean} props.isPremium Whether the user has a premium add-on activated or not.
*
* @returns {JSX.Element} The ContentSuggestionsModal component.
*/
export const ContentSuggestionsModal = ( { status, isPremium } ) => {
const svgAriaProps = useSvgAria();
const closeButtonRef = useRef( null );
const [ announceLoading, setAnnounceLoading ] = useState( false );

useEffect( () => {
if ( status === "content-suggestions-loading" ) {
closeButtonRef.current?.focus();
const timer = setTimeout( () => setAnnounceLoading( true ), 100 );
return () => clearTimeout( timer );
}
setAnnounceLoading( false );
}, [ status ] );

return (
<Modal.Panel
className="yst-p-0 yst-max-w-2xl"
hasCloseButton={ false }
>
<Modal.CloseButton ref={ closeButtonRef } screenReaderText={ __( "Close content suggestions modal", "wordpress-seo" ) } />
<Modal.Container>
<Modal.Container.Header className="yst-flex yst-items-center yst-gap-2 yst-pe-12 yst-py-6 yst-ps-6 yst-border-b yst-border-slate-200">
<YoastIcon className="yst-fill-primary-500 yst-w-4" { ...svgAriaProps } />
<Modal.Title size="2" className="yst-flex-grow">{ __( "Content suggestions", "wordpress-seo" ) }</Modal.Title>
<Badge size="small">{ __( "Beta", "wordpress-seo" ) }</Badge>
<UsageCounter
limit={ 10 }
requests={ 1 }
mentionBetaInTooltip={ isPremium }
mentionResetInTooltip={ isPremium }
/>
</Modal.Container.Header>
<Modal.Container.Content className="yst-overflow-y-auto yst-p-6 yst-m-0">
{ /* yst-relative enables absolute positioning of the leaving element to prevent layout stacking during cross-fade. */ }
<div className="yst-relative" aria-live="polite">
<Transition
as={ Fragment }
show={ announceLoading }
enter="yst-transition-opacity yst-duration-300"
enterFrom="yst-opacity-0"
enterTo="yst-opacity-100"
leave="yst-transition-opacity yst-duration-300 yst-absolute yst-top-0 yst-left-0 yst-right-0"
leaveFrom="yst-opacity-100"
leaveTo="yst-opacity-0"
>
<div><LoadingModalContent /></div>
</Transition>
{ /*
* yst-delay-300 matches the approve modal's leave duration (yst-duration-300)
* so the suggestions only fade in after the approve panel has faded out.
*/ }
<Transition
as={ Fragment }
show={ status === "content-suggestions-success" }
enter="yst-transition-opacity yst-duration-300 yst-delay-300"
enterFrom="yst-opacity-0"
enterTo="yst-opacity-100"
leave="yst-transition-opacity yst-duration-300"
leaveFrom="yst-opacity-100"
leaveTo="yst-opacity-0"
>
<div>
<Modal.Description className="yst-mb-4">{ __( "Select a suggestion to generate a structured outline for your post.", "wordpress-seo" ) }</Modal.Description>
{ /* onClick is a placeholder — will be wired to real handler in a future iteration. */ }
{ suggestions.map( ( suggestion ) => (
<SuggestionButton
key={ suggestion.title }
{ ...suggestion }
onClick={ noop }
/>
) ) }
</div>
</Transition>
</div>
</Modal.Container.Content>
</Modal.Container>
</Modal.Panel>
);
};
Loading
Loading