-
Notifications
You must be signed in to change notification settings - Fork 953
Add content suggestions modal for AI Content Planner feature #23090
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JorPV
merged 31 commits into
feature/content-planner
from
content-planner-content-suggestion-modal
Mar 30, 2026
+601
−71
Merged
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 9c02810
Merge branch '1105-next-post-create-cta-inline-banner-and-approve-mod…
vraja-pro bcaafe0
Apply suggestion from @Copilot
vraja-pro 1fbd507
fix lint, js comments, and adds tests
vraja-pro d64cb2d
add isPremium prop
vraja-pro c9f54ba
adds window object for testing loading state
vraja-pro 27a4ee4
fix js docs
vraja-pro e79b828
fix spacing
vraja-pro 80d0181
update aria attribute
vraja-pro 6457600
fix spacing
vraja-pro c6f9470
fix linting
vraja-pro dbaa7bc
fix aria attribute and adds tests
vraja-pro fc6c177
improve accessibility + tests
vraja-pro 29a6382
fix focus styling
vraja-pro 7bcf720
Adds svgAriaProps in the YoastIcon
vraja-pro 4b882c3
fix linting
vraja-pro 4bae89f
add transition effect when content changes in the modal
vraja-pro 617fbeb
adds svg props
vraja-pro c8e559a
fix modal transition
vraja-pro 0b9fdcf
Add delay to stop flickering
vraja-pro 8960137
Merge branch 'feature/content-planner' into content-planner-content-s…
vraja-pro 1e5caf2
fix tests
vraja-pro 2aeb37c
fix modal title and description for accessibility
vraja-pro 474fa32
fix focus
vraja-pro 9e5a5df
fix review comments
vraja-pro f817b26
fix tests and cleanup
vraja-pro 9b81837
fix screen reader for loading
vraja-pro af150bb
fix lint
vraja-pro 64b165b
add time out for screen reader
vraja-pro 0a09b58
fix tests
vraja-pro f9f4faa
Adds comment
vraja-pro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
231 changes: 231 additions & 0 deletions
231
packages/js/src/ai-content-planner/components/content-suggestions-modal.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
vraja-pro marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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> | ||
| ); | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.