diff --git a/composer.json b/composer.json index 4f56927947e..7eee7b27064 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,7 @@ "Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards" ], "check-cs-thresholds": [ - "@putenv YOASTCS_THRESHOLD_ERRORS=2378", + "@putenv YOASTCS_THRESHOLD_ERRORS=2377", "@putenv YOASTCS_THRESHOLD_WARNINGS=254", "Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds" ], diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index e5d5aadce3e..c16c8ea3b9c 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -6,6 +6,11 @@ padding-left: 0 !important; } + /* Keep the admin menu above the guided-tour spotlight dim so it isn't overlaid. */ + #adminmenuback { + z-index: 11; + } + .yst-mobile-navigation__top { /* Offset the sticky bar below the taller mobile admin bar. */ @media (min-width: 601px) and (max-width: 768px) { @@ -107,3 +112,31 @@ .yst-root .yst-bulk-editor-title-link:focus:not(:focus-visible) { @apply yst-outline-none; } + +/* The guided-tour spotlight overlay is decorative only; a separate backdrop blocks interaction, so it ignores pointer events. */ +.yst-root .yst-tour-spotlight { + pointer-events: none; +} + +/* Prevent the in-app tour popovers title to stack bellow the close button */ +.yst-bulk-editor-tour-card .yst-flex-grow { + min-width: 0; + padding-inline-end: 2rem; +} + +/* In RTL. */ +.yst-bulk-editor-tour-card.yst-popover--right { + margin-inline-start: 0.25rem; +} + +[dir="rtl"] .yst-bulk-editor-tour-card.yst-popover--right { + margin-inline-start: 1rem; +} + +[dir="rtl"] .yst-bulk-editor-tour-card.yst-popover--right::before { + inset-inline-start: auto !important; + inset-inline-end: 100% !important; + transform: translateY(-50%) !important; + border-inline-start: 14px solid transparent !important; + border-inline-end: 14px solid white !important; +} diff --git a/css/src/new-settings.css b/css/src/new-settings.css index ec448d74417..1e4cdd182f7 100644 --- a/css/src/new-settings.css +++ b/css/src/new-settings.css @@ -220,10 +220,6 @@ body.seo_page_wpseo_page_settings { } } - .yst-popover-backdrop-highlight-button button { - z-index:99; - } - /* RTL */ &.rtl { diff --git a/packages/js/src/bulk-editor/components/bulk-action-bar.js b/packages/js/src/bulk-editor/components/bulk-action-bar.js index 94c37cd4658..a6d380b322c 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -80,21 +80,23 @@ export const SelectionToolbar = ( { idSuffix = "", isAllSelected, isIndeterminat return (
- - +
+ + +
{ selectedCount > 0 && ( { sprintf( @@ -250,10 +252,12 @@ const BulkActionsBand = ( { isPremium, isAiEnabled, isActive, selectedIds, activeFieldSet, contentType, hasUnsavedEdits, editCount, onApplyAll, onDiscardAll, isApplyingAll, } ) => (
- { ! isPremium && isAiEnabled && } - { isActive && ( - - ) } +
+ { ! isPremium && isAiEnabled && } + { isActive && ( + + ) } +
{ isActive && hasUnsavedEdits && ( ) } diff --git a/packages/js/src/bulk-editor/components/bulk-editor-content.js b/packages/js/src/bulk-editor/components/bulk-editor-content.js index 043a684ff3b..4d325df4bb7 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-content.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-content.js @@ -8,6 +8,7 @@ import { useInlineEdit } from "../hooks/use-inline-edit"; import { usePosts } from "../services/use-posts"; import { BulkActions, SelectionToolbar } from "./bulk-action-bar"; import { BulkEditorFilters } from "./bulk-editor-filters"; +import { BulkEditorTour } from "./tour/bulk-editor-tour"; import { BulkEditorFooter } from "./bulk-editor-footer"; import { BulkEditorTable } from "./table/bulk-editor-table"; import { BulkEditorTabPanel, BulkEditorTabs } from "./bulk-editor-tabs"; @@ -144,87 +145,90 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy } ), [ selectedIds, toggleRow ] ); return ( -
-
- +
+
+ + +
+ { tabs.map( ( tab ) => ( + + + } + bulkActions={ + + } + // A selection only warrants the band while AI is enabled (the AI affordances are its only + // selection-driven occupant); with AI off the band collapses. Unsaved manual edits are a + // separate, non-AI occupant, so they keep it open regardless of the AI toggle. External + // pending changes (Premium's AI suggestions) also keep it open: a filter, search, or page + // change clears the selection but must leave the pending suggestions actionable. + showBulkActions={ ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges } + filters={ } + isLoading={ isPending } + hasExternalPendingChanges={ hasExternalPendingChanges } + hasExternalGeneration={ hasExternalGeneration } + footer={ total > 0 + ? + : null } + /> + + ) ) } + + -
- { tabs.map( ( tab ) => ( - - - } - bulkActions={ - - } - // A selection only warrants the band while AI is enabled (the AI affordances are its only - // selection-driven occupant); with AI off the band collapses. Unsaved manual edits are a - // separate, non-AI occupant, so they keep it open regardless of the AI toggle. External - // pending changes (Premium's AI suggestions) also keep it open: a filter, search, or page - // change clears the selection but must leave the pending suggestions actionable. - showBulkActions={ ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges } - filters={ } - isLoading={ isPending } - hasExternalPendingChanges={ hasExternalPendingChanges } - hasExternalGeneration={ hasExternalGeneration } - footer={ total > 0 - ? - : null } - /> - - ) ) } - - -
+ + ); }; diff --git a/packages/js/src/bulk-editor/components/bulk-editor-nav.js b/packages/js/src/bulk-editor/components/bulk-editor-nav.js index 269ea59d854..cc26edb6c8f 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-nav.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-nav.js @@ -7,6 +7,9 @@ import { noop } from "lodash"; import { YoastLogo } from "../../shared-admin/components"; import { BackToToolsLink } from "./back-to-tools-link"; +// The WordPress built-in content types, used to scope the guided-tour spotlight to Posts and Pages. +const BUILT_IN_CONTENT_TYPES = [ "post", "page" ]; + /** * One content type entry. * @@ -19,14 +22,15 @@ import { BackToToolsLink } from "./back-to-tools-link"; * One content type item. Selecting a content type changes view state, it does not navigate. * The active state comes from the SidebarNavigation context. * - * @param {Object} props The props. - * @param {BulkEditorContentType} props.contentType The content type. - * @param {boolean} props.disabled Whether the item is disabled. - * @param {Function} props.onChange Called with the content type id when selected. + * @param {Object} props The props. + * @param {BulkEditorContentType} props.contentType The content type. + * @param {boolean} props.disabled Whether the item is disabled. + * @param {Function} props.onChange Called with the content type id when selected. + * @param {boolean} [props.isHighlightEnd] Whether this is the last item the guided-tour spotlight covers. * * @returns {JSX.Element} The item. */ -const ContentTypeItem = ( { contentType, disabled, onChange } ) => { +const ContentTypeItem = ( { contentType, disabled, onChange, isHighlightEnd = false } ) => { const handleClick = useCallback( () => onChange( contentType.id ), [ onChange, contentType.id ] ); return ( @@ -39,6 +43,7 @@ const ContentTypeItem = ( { contentType, disabled, onChange } ) => { disabled={ disabled } onClick={ handleClick } className={ classNames( "yst-w-full", { "yst-opacity-50": disabled } ) } + { ...( isHighlightEnd ? { "data-tour-highlight-end": "true" } : {} ) } /> ); }; @@ -111,6 +116,12 @@ export const BulkEditorNavMenu = ( { } ) => { const hiddenCount = contentTypes.length - visibleLimit; + // The in-app guided tour highlights only the built-in content types (Posts, Pages). + // Post/page are always on top of the registration order. + const highlightEndIndex = contentTypes + .slice( 0, 2 ) + .reduce( ( end, contentType, index ) => ( BUILT_IN_CONTENT_TYPES.includes( contentType.id ) ? index : end ), -1 ); + const showMoreLabel = sprintf( /* translators: %d expands to the number of additional content types. */ _n( "Show %d more", "Show %d more", hiddenCount, "wordpress-seo" ), @@ -121,20 +132,28 @@ export const BulkEditorNavMenu = ( {
- - { contentTypes.map( ( contentType ) => ( - - ) ) } - +
+ + { contentTypes.map( ( contentType, index ) => ( + + ) ) } + +
); }; diff --git a/packages/js/src/bulk-editor/components/bulk-editor-tabs.js b/packages/js/src/bulk-editor/components/bulk-editor-tabs.js index 1fcb98ca74a..a276a5da197 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-tabs.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-tabs.js @@ -122,7 +122,7 @@ export const BulkEditorTabs = ( { tabs, activeTab, disabled = false, onChange, l }, [ tabs, activeTab, onChange, disabled ] ); return ( -
+
{ tabs.map( ( tab ) => ( { + const { isSeen, isAiEnabled } = useSelect( ( select ) => { + const store = select( STORE_NAME ); + return { + isSeen: store.selectIsOptInNotificationSeen( TOUR_OPT_IN_KEY ), + isAiEnabled: store.selectPreference( "isAiEnabled", false ), + }; + }, [] ); + const { setOptInNotificationSeen, hideOptInNotification } = useDispatch( STORE_NAME ); + + // The generate step's target only exists when AI is enabled, so drop it otherwise. + const steps = useMemo( + () => getTourSteps().filter( ( tourStep ) => ! tourStep.requiresSelection || isAiEnabled ), + [ isAiEnabled ] + ); + + const [ stepIndex, setStepIndex ] = useState( 0 ); + const [ isDismissed, setIsDismissed ] = useState( false ); + // Whether the tour created the selection itself, so it can undo it without clearing a selection the user made. + const didAutoSelect = useRef( false ); + + const isActive = ! isSeen && ! isDismissed; + const step = steps[ stepIndex ]; + const isLastStep = stepIndex === steps.length - 1; + + useEffect( () => { + if ( isActive && step.requiresSelection && ! hasSelection ) { + didAutoSelect.current = true; + onSelectAll(); + } + }, [ isActive, step, hasSelection, onSelectAll ] ); + + const { spotlight, targetMissing } = useTourAnchor( `[data-tour-id="${ step.tourId }"]`, isActive, { + endSelector: step.highlightEndSelector, + perChild: step.highlightChildren, + childSelector: step.highlightChildrenSelector, + } ); + + const finish = useCallback( () => { + if ( didAutoSelect.current ) { + onDeselectAll(); + didAutoSelect.current = false; + } + setIsDismissed( true ); + // Persist to the server and update local state, so the tour stays gone this session and on reload. + setOptInNotificationSeen( TOUR_OPT_IN_KEY ); + hideOptInNotification( TOUR_OPT_IN_KEY ); + }, [ onDeselectAll, setOptInNotificationSeen, hideOptInNotification ] ); + + const onNext = useCallback( () => { + // finish() dispatches store updates, so it runs from the event handler, not inside a state updater. + if ( isLastStep ) { + finish(); + return; + } + setStepIndex( ( index ) => index + 1 ); + }, [ isLastStep, finish ] ); + + const onBack = useCallback( () => setStepIndex( ( index ) => Math.max( 0, index - 1 ) ), [] ); + + // If the active step's target never appears (e.g. the generate step when there are no selectable rows), + // advance past it instead of leaving the tour invisible-but-active. + useEffect( () => { + if ( isActive && targetMissing ) { + onNext(); + } + }, [ isActive, targetMissing, onNext ] ); + + if ( ! isActive || ! spotlight ) { + return null; + } + + const { rects, bounds, viewport } = spotlight; + + return ( + <> +
+ +
+ 0 ? onBack : null } + onSkip={ finish } + /> +
+ + ); +}; diff --git a/packages/js/src/bulk-editor/components/tour/tour-card.js b/packages/js/src/bulk-editor/components/tour/tour-card.js new file mode 100644 index 00000000000..2c862e71762 --- /dev/null +++ b/packages/js/src/bulk-editor/components/tour/tour-card.js @@ -0,0 +1,146 @@ +import ArrowNarrowRightIcon from "@heroicons/react/solid/ArrowNarrowRightIcon"; +import { createInterpolateElement, useCallback, useEffect, useRef } from "@wordpress/element"; +import { __, sprintf } from "@wordpress/i18n"; +import { Button, Popover, useSvgAria } from "@yoast/ui-library"; +import { ReactComponent as YoastIcon } from "../../../../images/Yoast_icon_kader.svg"; + +/** + * Keeps Tab focus within a container. + * + * @param {KeyboardEvent} event The Tab keydown event; its `currentTarget` is the container to trap focus inside. + * + * @returns {void} + */ +const trapTab = ( event ) => { + const focusable = [ ...event.currentTarget.querySelectorAll( + "button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])" + ) ].filter( ( element ) => ! element.disabled ); + if ( focusable.length === 0 ) { + return; + } + const edge = event.shiftKey ? focusable[ 0 ] : focusable[ focusable.length - 1 ]; + if ( document.activeElement === edge ) { + event.preventDefault(); + ( event.shiftKey ? focusable[ focusable.length - 1 ] : focusable[ 0 ] ).focus(); + } +}; + +/** + * A single step of the bulk editor guided tour, rendered as a ui-library Popover anchored to a target element. + * + * The card carries the step copy plus the tour navigation (progress, Back, Next/finish). Dismissing the popover ends the whole tour via `onSkip`. + * + * @param {Object} props The component props. + * @param {string} props.id The unique popover id; also derives the title and content ids. + * @param {string} props.title The step title. + * @param {React.ReactNode} props.content The step body copy. + * @param {number} props.currentStep The 1-based index of this step. + * @param {number} props.totalSteps The total number of steps. + * @param {boolean} props.isLastStep Whether this is the final step (shows "Got it!" instead of "Next"). + * @param {Function} props.onNext Advances to the next step, or finishes on the last step. + * @param {Function} [props.onBack] Goes back a step; omitted on the first step. + * @param {Function} props.onSkip Ends the tour without finishing (close button / backdrop / Escape). + * @param {string} [props.position] The popover position relative to its anchor. + * @param {string} [props.className] Optional extra className for the popover. + * + * @returns {JSX.Element} The tour step card. + */ +export const TourCard = ( { + id, + title, + content, + currentStep, + totalSteps, + isLastStep, + onNext, + onBack = null, + onSkip, + position = "right", + className = "", +} ) => { + const svgAriaProps = useSvgAria(); + const nextButtonRef = useRef( null ); + + // Move focus to the primary action each time the step changes, so keyboard and screen reader users follow along. + useEffect( () => { + const timeout = setTimeout( () => nextButtonRef.current?.focus(), 300 ); + return () => clearTimeout( timeout ); + }, [ currentStep ] ); + + // The close button and Escape both resolve to hiding the popover, which ends the tour. + const handleVisibilityChange = useCallback( ( isVisible ) => ! isVisible && onSkip(), [ onSkip ] ); + + // The tour blocks the page, so the card behaves as a modal dialog: Escape closes it, and Tab is trapped within its + // controls, keeping keyboard users out of the page elements behind it. + const handleKeyDown = useCallback( ( event ) => { + if ( event.key === "Escape" ) { + event.preventDefault(); + onSkip(); + } else if ( event.key === "Tab" ) { + trapTab( event ); + } + }, [ onSkip ] ); + + return + <> +
+
+ +
+
+ + { sprintf( + /* translators: %1$d is the step number, %2$s is the step title. */ + __( "Step %1$d: %2$s", "wordpress-seo" ), + currentStep, + title + ) } + +
+ +
+ + { content } + +
+ + + { createInterpolateElement( + sprintf( + /* translators: %1$s is the current step number, %2$s is the total number of steps. */ + __( "%1$s / %2$s", "wordpress-seo" ), + currentStep, + totalSteps + ), + { current: } + ) } + + +
+ { onBack && } + +
+
+ +
; +}; diff --git a/packages/js/src/bulk-editor/components/tour/tour-steps.js b/packages/js/src/bulk-editor/components/tour/tour-steps.js new file mode 100644 index 00000000000..af8858574a9 --- /dev/null +++ b/packages/js/src/bulk-editor/components/tour/tour-steps.js @@ -0,0 +1,46 @@ +import { __ } from "@wordpress/i18n"; + +/** + * The bulk editor guided-tour steps, in order. + * + * Each step points at an element carrying the matching `data-tour-id`. `requiresSelection` marks a step whose + * target only exists once rows are selected, so the tour selects rows before showing it. + * + * @returns {Array} The tour steps. + */ +export const getTourSteps = () => [ + { + id: "bulk-editor-tour-content-type", + tourId: "content-type-nav", + highlightEndSelector: "[data-tour-highlight-end]", + position: "right", + title: __( "Select your content type", "wordpress-seo" ), + content: __( "This will refine the content to what you want to update.", "wordpress-seo" ), + }, + { + id: "bulk-editor-tour-appearance", + tourId: "appearance-tabs", + highlightChildren: true, + position: "right", + title: __( "Select search or social appearance", "wordpress-seo" ), + content: __( "Easily switch between them anytime.", "wordpress-seo" ), + }, + { + id: "bulk-editor-tour-multi-select", + tourId: "selection-toolbar", + highlightChildren: true, + position: "right", + title: __( "Multi-select", "wordpress-seo" ), + content: __( "Choose the rows you want to update. Use the dropdown for additional options.", "wordpress-seo" ), + }, + { + id: "bulk-editor-tour-generate", + tourId: "generate-actions", + highlightChildren: true, + highlightChildrenSelector: "button", + position: "right", + requiresSelection: true, + title: __( "Get SEO-friendly options at scale", "wordpress-seo" ), + content: __( "Generate up to 20 results at a time. Great for website refreshes!", "wordpress-seo" ), + }, +]; diff --git a/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js new file mode 100644 index 00000000000..df8a920bd2f --- /dev/null +++ b/packages/js/src/bulk-editor/components/tour/use-tour-anchor.js @@ -0,0 +1,254 @@ +import { useState, useEffect } from "@wordpress/element"; + +// How long to keep looking for a target that mounts asynchronously (e.g. the generate actions bar that only appears once +// rows are selected), and how often to re-check, before giving up on the step. +const TARGET_WAIT_MS = 2000; +const TARGET_POLL_MS = 100; + +// How many animation frames to keep re-measuring after the tour opens, so a target that shifts while the page settles +// is followed until it stops moving, then left to the scroll/resize/ResizeObserver listeners. +const SETTLE_MAX_FRAMES = 40; + +// Space (px) around a single-region spotlight so it surrounds the target without looking clipped. +const SPOTLIGHT_PADDING = { top: 6, right: 6, bottom: 6, left: 6 }; +// Corner radius (px) for a single-region cut-out, which covers an area rather than one styled control. +const SINGLE_REGION_RADIUS = 8; + +/** + * Reads the corner radius to match a control's cut-out to its rounded corners. Direct children are sometimes plain + * wrappers (radius 0) around the styled control, so it falls back to the first child's radius. + * + * @param {HTMLElement} element The element. + * + * @returns {number} The corner radius in pixels. + */ +const cornerRadius = ( element ) => { + const own = parseFloat( getComputedStyle( element ).borderTopLeftRadius ) || 0; + if ( own > 0 ) { + return own; + } + return element.firstElementChild + ? parseFloat( getComputedStyle( element.firstElementChild ).borderTopLeftRadius ) || 0 + : 0; +}; + +/** + * Finds the visible element for a tour target. + * + * @param {string} selector The `[data-tour-id="…"]` selector. + * + * @returns {HTMLElement|null} The visible matching element, or null. + */ +const findVisibleTarget = ( selector ) => + [ ...document.querySelectorAll( selector ) ].find( ( element ) => element.offsetParent !== null ) ?? null; + +/** + * Whether an element exists and is visible. + * + * @param {HTMLElement} element The element. + * + * @returns {boolean} Whether it is visible. + */ +const isVisible = ( element ) => element.offsetParent !== null && element.getBoundingClientRect().width > 0; + +/** + * Measures the spotlight rectangles in viewport coordinates for a target. + * + * @param {HTMLElement} element The target element. + * @param {boolean} perChild Whether to spotlight per matching child rather than the whole target. + * @param {?string} childSelector With `perChild`, the descendants to spotlight; otherwise every direct child. + * @param {?string} endSelector For a single-region spotlight, the element whose bottom clamps its height. + * + * @returns {Array} The rectangles ({ top, left, width, height, rx }). + */ +const measureRects = ( element, perChild, childSelector, endSelector ) => { + if ( perChild ) { + const children = childSelector ? [ ...element.querySelectorAll( childSelector ) ] : [ ...element.children ]; + return children.filter( isVisible ).map( ( child ) => { + const childRect = child.getBoundingClientRect(); + return { top: childRect.top, left: childRect.left, width: childRect.width, height: childRect.height, rx: cornerRadius( child ) }; + } ); + } + + const rect = element.getBoundingClientRect(); + const endElement = endSelector ? element.querySelector( endSelector ) : null; + const top = rect.top - SPOTLIGHT_PADDING.top; + const bottom = endElement ? endElement.getBoundingClientRect().bottom : rect.bottom + SPOTLIGHT_PADDING.bottom; + return [ { + top, + left: rect.left - SPOTLIGHT_PADDING.left, + width: rect.width + SPOTLIGHT_PADDING.left + SPOTLIGHT_PADDING.right, + height: bottom - top, + rx: SINGLE_REGION_RADIUS, + } ]; +}; + +/** + * Re-bases rects onto the fixed spotlight overlay. Its origin is not always the viewport's (an RTL document scrollbar + * shifts a fixed element's left edge), so subtracting it keeps the cut-outs and card aligned with their targets, which + * were measured in viewport coordinates. A no-op when the overlay sits at the origin (the LTR case). + * + * @param {Array} rects The rectangles in viewport coordinates. + * + * @returns {Array} The rectangles relative to the overlay. + */ +const rebaseToOverlay = ( rects ) => { + const overlay = document.querySelector( ".yst-tour-spotlight" ); + if ( ! overlay ) { + return rects; + } + const { left, top } = overlay.getBoundingClientRect(); + if ( left === 0 && top === 0 ) { + return rects; + } + return rects.map( ( rect ) => ( { ...rect, left: rect.left - left, top: rect.top - top } ) ); +}; + +/** + * The bounding box enclosing every rectangle. + * + * @param {Array} rects The rectangles. + * + * @returns {{top: string, left: string, width: string, height: string}} The bounds. + */ +const boundsOf = ( rects ) => { + const minLeft = Math.min( ...rects.map( ( rect ) => rect.left ) ); + const minTop = Math.min( ...rects.map( ( rect ) => rect.top ) ); + const maxRight = Math.max( ...rects.map( ( rect ) => rect.left + rect.width ) ); + const maxBottom = Math.max( ...rects.map( ( rect ) => rect.top + rect.height ) ); + return { top: `${ minTop }px`, left: `${ minLeft }px`, width: `${ maxRight - minLeft }px`, height: `${ maxBottom - minTop }px` }; +}; + +/** + * Tracks a target element and returns the spotlight rectangles. + * + * @param {string} targetSelector The `[data-tour-id="…"]` selector of the element the step points at. + * @param {boolean} isActive Whether this step is the active one; only then is the target measured. + * @param {Object} [options] Extra options. + * @param {string} [options.endSelector] Selector, resolved within the target, whose bottom clamps a single-region spotlight. + * @param {boolean} [options.perChild] Whether to spotlight per visible direct child instead of one for the target. + * @param {string} [options.childSelector] With `perChild`, spotlight the matching descendants instead of every direct + * child, so slot-filled siblings (e.g. Premium's AI usage counter) stay dimmed. + * + * @returns {{spotlight: ({rects: Array, bounds: Object, viewport: Object}|null), targetMissing: boolean}} The + * spotlight area (or null), and whether the target could not be found before the poll gave up. + */ +export const useTourAnchor = ( targetSelector, isActive, { endSelector = null, perChild = false, childSelector = null } = {} ) => { + const [ spotlight, setSpotlight ] = useState( null ); + const [ targetMissing, setTargetMissing ] = useState( false ); + // Reset the flag synchronously when the step changes, so a stale `true` from the previous step can't make the caller + // skip the new one before the effect below re-runs. + const [ trackedSelector, setTrackedSelector ] = useState( targetSelector ); + if ( targetSelector !== trackedSelector ) { + setTrackedSelector( targetSelector ); + setTargetMissing( false ); + } + + useEffect( () => { + if ( ! isActive ) { + return; + } + + let pollTimer = null; + let cleanup = null; + + const attach = ( element ) => { + // Instant, minimal scroll: only nudge the target into view if it is off-screen. + element.scrollIntoView( { block: "nearest" } ); + + // The JSON of the last rendered spotlight; the frequent re-measures only re-render when it actually changed. + let renderedKey = ""; + + const updatePosition = () => { + const rects = measureRects( element, perChild, childSelector, endSelector ); + + // No visible children yet (e.g. the generate AI actions row before it mounts): render nothing rather than + // an empty, fully-dimmed overlay. + if ( rects.length === 0 ) { + if ( renderedKey !== "none" ) { + renderedKey = "none"; + setSpotlight( null ); + } + return; + } + + const overlaid = rebaseToOverlay( rects ); + const next = { + rects: overlaid, + bounds: boundsOf( overlaid ), + viewport: { width: window.innerWidth, height: window.innerHeight }, + }; + const key = JSON.stringify( next ); + if ( key !== renderedKey ) { + renderedKey = key; + setSpotlight( next ); + } + }; + updatePosition(); + + window.addEventListener( "scroll", updatePosition, true ); + window.addEventListener( "resize", updatePosition ); + const resizeObserver = new ResizeObserver( updatePosition ); + resizeObserver.observe( element ); + + // Premium fills the generate step's slot asynchronously: its AI usage counter arrives after a fetch and, in + // RTL, shifts the buttons sideways without changing the container's own size — which the ResizeObserver would + // miss. Watch the subtree so an added/updated node re-measures the (now moved) buttons. + const mutationObserver = new MutationObserver( updatePosition ); + mutationObserver.observe( element, { childList: true, subtree: true, attributes: true, characterData: true } ); + + // The target can also shift while the page first settles (sidebar/table layout, fonts, the RTL scrollbar), + // before any observer fires. Re-measure each frame for a short, capped window; the signature guard keeps this + // from re-rendering when nothing changed. + let settleFrame = null; + let settleTicks = 0; + const settle = () => { + updatePosition(); + settleTicks += 1; + if ( settleTicks < SETTLE_MAX_FRAMES ) { + settleFrame = requestAnimationFrame( settle ); + } + }; + settleFrame = requestAnimationFrame( settle ); + + cleanup = () => { + if ( settleFrame !== null ) { + cancelAnimationFrame( settleFrame ); + } + window.removeEventListener( "scroll", updatePosition, true ); + window.removeEventListener( "resize", updatePosition ); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }; + + // The target may not be in the DOM yet (e.g. the generate action buttons appear only after step 4 selects rows), + // so poll briefly until it shows up. + const deadline = Date.now() + TARGET_WAIT_MS; + const tryAttach = () => { + const element = findVisibleTarget( targetSelector ); + if ( element ) { + attach( element ); + return; + } + if ( Date.now() < deadline ) { + pollTimer = setTimeout( tryAttach, TARGET_POLL_MS ); + } else { + setTargetMissing( true ); + } + }; + tryAttach(); + + return () => { + if ( pollTimer ) { + clearTimeout( pollTimer ); + } + if ( cleanup ) { + cleanup(); + } + setSpotlight( null ); + }; + }, [ targetSelector, isActive, endSelector, perChild, childSelector ] ); + + return { spotlight, targetMissing }; +}; diff --git a/packages/js/src/bulk-editor/constants.js b/packages/js/src/bulk-editor/constants.js index d3d35066829..28191d1ccd3 100644 --- a/packages/js/src/bulk-editor/constants.js +++ b/packages/js/src/bulk-editor/constants.js @@ -33,6 +33,9 @@ export const BULK_NOTICES_SLOT = "yoast.bulkEditor.bulkNotices"; // The PluginArea scope Premium registers its fills under, so they mount inside this page's React tree. export const PLUGIN_SCOPE = "yoast-seo-bulk-editor"; +// The opt-in-notification key that tracks whether the first-run guided tour has been seen. +export const TOUR_OPT_IN_KEY = "bulk_editor_tour"; + // The filter Premium uses to add items to the Select menu. export const SELECT_MENU_ITEMS_FILTER = "yoast.bulkEditor.selectMenuItems"; diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js index 9b2b05b9e6d..aed1891ce7a 100644 --- a/packages/js/src/bulk-editor/initialize.js +++ b/packages/js/src/bulk-editor/initialize.js @@ -8,7 +8,7 @@ import { get } from "lodash"; import { createHashRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom"; import { GenericAlert } from "../ai-generator/components/errors"; import { fixWordPressMenuScrolling } from "../shared-admin/helpers"; -import { LINK_PARAMS_NAME } from "../shared-admin/store"; +import { LINK_PARAMS_NAME, OPT_IN_NOTIFICATION_NAME } from "../shared-admin/store"; import App from "./app"; import { UpsellModal } from "./components/upsell-modal"; import { PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants"; @@ -33,6 +33,9 @@ domReady( () => { registerStore( { initialState: { [ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ), + [ OPT_IN_NOTIFICATION_NAME ]: { + seen: get( window, "wpseoBulkEditorData.optInNotificationSeen", {} ), + }, }, } ); fixWordPressMenuScrolling(); diff --git a/packages/js/src/bulk-editor/store/index.js b/packages/js/src/bulk-editor/store/index.js index b69d6cf2613..a2da929aa2b 100644 --- a/packages/js/src/bulk-editor/store/index.js +++ b/packages/js/src/bulk-editor/store/index.js @@ -1,6 +1,18 @@ import { combineReducers, createReduxStore, register } from "@wordpress/data"; import { merge } from "lodash"; -import { getInitialLinkParamsState, LINK_PARAMS_NAME, linkParamsActions, linkParamsReducer, linkParamsSelectors } from "../../shared-admin/store"; +import { + getInitialLinkParamsState, + getInitialOptInNotificationState, + LINK_PARAMS_NAME, + linkParamsActions, + linkParamsReducer, + linkParamsSelectors, + OPT_IN_NOTIFICATION_NAME, + optInNotificationActions, + optInNotificationControls, + optInNotificationReducer, + optInNotificationSelectors, +} from "../../shared-admin/store"; import { STORE_NAME } from "../constants"; import activeContentType, { activeContentTypeActions, activeContentTypeSelectors, createInitialActiveContentTypeState } from "./active-content-type"; import activeFieldSet, { activeFieldSetActions, activeFieldSetSelectors, createInitialActiveFieldSetState } from "./active-field-set"; @@ -39,6 +51,7 @@ const createStore = ( { initialState } ) => { ...externalPendingChangesActions, ...externalGenerationActions, ...pendingSwitchActions, + ...optInNotificationActions, }, selectors: { ...linkParamsSelectors, @@ -51,6 +64,7 @@ const createStore = ( { initialState } ) => { ...externalPendingChangesSelectors, ...externalGenerationSelectors, ...pendingSwitchSelectors, + ...optInNotificationSelectors, }, initialState: merge( {}, @@ -65,6 +79,7 @@ const createStore = ( { initialState } ) => { externalPendingChanges: createInitialExternalPendingChangesState(), externalGeneration: createInitialExternalGenerationState(), pendingSwitch: createInitialPendingSwitchState(), + [ OPT_IN_NOTIFICATION_NAME ]: getInitialOptInNotificationState(), }, initialState ), @@ -79,7 +94,11 @@ const createStore = ( { initialState } ) => { externalPendingChanges, externalGeneration, pendingSwitch, + [ OPT_IN_NOTIFICATION_NAME ]: optInNotificationReducer, } ), + controls: { + ...optInNotificationControls, + }, } ); }; diff --git a/packages/js/src/general/app.js b/packages/js/src/general/app.js index e37e2500745..dce57f3436d 100644 --- a/packages/js/src/general/app.js +++ b/packages/js/src/general/app.js @@ -10,7 +10,7 @@ import { addQueryArgs } from "@wordpress/url"; import { Notifications, SidebarNavigation, useSvgAria } from "@yoast/ui-library"; import PropTypes from "prop-types"; import { Link, Outlet, useLocation } from "react-router-dom"; -import { Notices, OptInContainer } from "./components"; +import { BulkEditorTourNotification, Notices } from "./components"; import { STORE_NAME } from "./constants"; import { deleteMigratingNotices } from "../helpers/migrateNotices"; import { useNotificationCountSync, useSelectGeneralPage } from "./hooks"; @@ -145,7 +145,7 @@ const App = () => { - + { + const adminMenuWrap = document.getElementById( "adminmenuwrap" ); + return ! adminMenuWrap || adminMenuWrap.offsetWidth > 100; +}; + +/** + * The Dismiss / Show me buttons for the entry notification. + * + * @param {Object} props The props. + * @param {string} props.bulkEditorUrl The bulk editor URL the "Show me" button opens. + * + * @returns {JSX.Element} The buttons. + */ +const NotificationButtons = ( { bulkEditorUrl } ) => { + const { handleDismiss } = useModalNotificationContext(); + const svgAriaProps = useSvgAria(); + + // A full-page navigation to the bulk editor, where the guided tour runs. + const handleShow = useCallback( () => { + window.location.href = bulkEditorUrl; + }, [ bulkEditorUrl ] ); + + return
+ + +
; +}; + +/** + * The bulk editor guided-tour entry notification, shown on the General page. + * + * It points first-time users to the bulk editor, where the step-by-step tour runs. Dismissing it marks the tour + * seen (so it never returns); "Show me" navigates to the bulk editor page. + * + * @returns {JSX.Element|null} The notification, or nothing when it has been seen or on the first-time configuration. + */ +export const BulkEditorTourNotification = () => { + const { setOptInNotificationSeen, hideOptInNotification } = useDispatch( STORE_NAME ); + const svgAriaProps = useSvgAria(); + const { pathname } = useLocation(); + const isSeen = useSelectGeneralPage( "selectIsOptInNotificationSeen", [], TOUR_OPT_IN_KEY ); + const isRtl = useSelectGeneralPage( "selectPreference", [], "isRtl" ); + const bulkEditorUrl = useSelectGeneralPage( "selectAdminLink", [], BULK_EDITOR_LINK ); + + // Dismissing (close button or Dismiss) ends the tour: persist it seen, then hide for the current session. + const onClose = useCallback( () => { + setOptInNotificationSeen( TOUR_OPT_IN_KEY ); + hideOptInNotification( TOUR_OPT_IN_KEY ); + }, [ setOptInNotificationSeen, hideOptInNotification ] ); + + const isOpen = ! isSeen && pathname !== ROUTES.firstTimeConfiguration; + if ( ! isOpen ) { + return null; + } + + let positionClass; + if ( isAdminSidebarExpanded() ) { + positionClass = "md:yst-start-40 rtl:md:yst-start-44"; + } else if ( isRtl ) { + positionClass = "md:yst-start-[3.25rem]"; + } else { + positionClass = "md:yst-start-10"; + } + + return + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
; +}; diff --git a/packages/js/src/general/components/index.js b/packages/js/src/general/components/index.js index f0012904764..7230391e441 100644 --- a/packages/js/src/general/components/index.js +++ b/packages/js/src/general/components/index.js @@ -6,8 +6,7 @@ export { Notifications } from "./notifications"; export { Problems } from "./problems"; export { RouteErrorFallback } from "./route-error-fallback"; export { RouteLayout } from "./route-layout"; -export { TaskListOptInNotification } from "./task-list-opt-in-notification"; -export { OptInContainer } from "./opt-in-container"; +export { BulkEditorTourNotification } from "./bulk-editor-tour-notification"; export { TaskListUpsellRow } from "./task-list-upsell-row"; export { Task } from "./task"; export { TaskListModal } from "./task-list-modal"; diff --git a/packages/js/src/general/components/opt-in-container.js b/packages/js/src/general/components/opt-in-container.js deleted file mode 100644 index e6148fbe931..00000000000 --- a/packages/js/src/general/components/opt-in-container.js +++ /dev/null @@ -1,31 +0,0 @@ -import { TaskListOptInNotification } from "./task-list-opt-in-notification"; -import { useSelectGeneralPage } from "../hooks"; -import { useLocation } from "react-router-dom"; -import { useCallback } from "@wordpress/element"; -import { useDispatch } from "@wordpress/data"; -import { ROUTES } from "../routes"; -import { STORE_NAME } from "../constants"; - -/** - * The container for the opt-in notification. - * Used to decide whether to show the opt-in notification or not. - * - * @returns {JSX.Element|null} The container component. - */ -export const OptInContainer = () => { - const taskListOptInNotificationSeen = useSelectGeneralPage( "selectIsOptInNotificationSeen", [], "task_list" ); - const { pathname } = useLocation(); - const { hideOptInNotification } = useDispatch( STORE_NAME ); - - const isOpen = pathname !== ROUTES.firstTimeConfiguration && ! taskListOptInNotificationSeen && pathname !== ROUTES.taskList; - - const onClose = useCallback( () => { - hideOptInNotification( "task_list" ); - }, [ hideOptInNotification ] ); - - if ( ! isOpen ) { - return null; - } - - return ; -}; diff --git a/packages/js/src/general/components/task-list-opt-in-notification.js b/packages/js/src/general/components/task-list-opt-in-notification.js deleted file mode 100644 index f79774b43d5..00000000000 --- a/packages/js/src/general/components/task-list-opt-in-notification.js +++ /dev/null @@ -1,117 +0,0 @@ -import { ModalNotification, Button, useSvgAria, useModalNotificationContext } from "@yoast/ui-library"; -import { __ } from "@wordpress/i18n"; -import { ReactComponent as YoastIcon } from "../../../images/Yoast_icon_kader.svg"; -import ArrowNarrowRightIcon from "@heroicons/react/outline/ArrowNarrowRightIcon"; -import classNames from "classnames"; -import { useCallback, useEffect } from "@wordpress/element"; -import { STORE_NAME } from "../constants"; -import { useDispatch } from "@wordpress/data"; -import { useNavigate } from "react-router-dom"; -import { ROUTES } from "../routes"; -import PropTypes from "prop-types"; -import { useSelectGeneralPage } from "../hooks"; - -/** - * Checks whether the WP admin sidebar is expanded (not collapsed). - * When collapsed, the sidebar is ~36px wide; when expanded, ~160px. - * - * @returns {boolean} True if the admin sidebar is expanded or absent. - */ -const isAdminSidebarExpanded = () => { - const adminmenuWrap = document.getElementById( "adminmenuwrap" ); - return ! adminmenuWrap || adminmenuWrap.offsetWidth > 100; -}; - -/** - * The buttons for the task list opt-in notification. - * Uses the ModalNotification context for dismissal. - * - * @returns {JSX.Element} The buttons. - */ -const NotificationButtons = () => { - const { handleDismiss } = useModalNotificationContext(); - const svgAriaProps = useSvgAria(); - const taskListpath = ROUTES.taskList; - const navigate = useNavigate(); - const { hideOptInNotification } = useDispatch( STORE_NAME ); - - const handleShow = useCallback( async() => { - hideOptInNotification( "task_list" ); - handleDismiss(); - navigate( taskListpath ); - }, [ taskListpath, navigate ] ); - - return
- - -
; -}; - -/** - * The task list opt-in notification component. - * Uses ModalNotification for accessible focus management and modal behavior. - * - * @param {Object} props The component props. - * @param {boolean} props.isOpen Whether the notification is open. - * @param {Function} props.onClose Function to call when the notification should close. - * - * @returns {JSX.Element} The task list opt-in notification component. - */ -export const TaskListOptInNotification = ( { isOpen, onClose } ) => { - const { setOptInNotificationSeen, hideOptInNotification } = useDispatch( STORE_NAME ); - const svgAriaProps = useSvgAria(); - - useEffect( () => { - // Mark the notification as seen when mounting. - setOptInNotificationSeen( "task_list" ); - - return () => { - // Hide the notification when unmounting when switching to the FTC tab. - hideOptInNotification( "task_list" ); - }; - }, [] ); - - const isRtl = useSelectGeneralPage( "selectPreference", [], "isRtl" ); - - let notificationPositionClass; - - if ( isAdminSidebarExpanded() ) { - notificationPositionClass = "md:yst-start-40 rtl:md:yst-start-44"; - } else if ( isRtl ) { - notificationPositionClass = "md:yst-start-[3.25rem]"; - } else { - notificationPositionClass = "md:yst-start-10"; - } - - return - -
-
- -
-
- - -
-
- -
-
- -
-
; -}; - -TaskListOptInNotification.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, -}; diff --git a/packages/js/src/general/initialize.js b/packages/js/src/general/initialize.js index 188b76c2b7b..e0542a36562 100644 --- a/packages/js/src/general/initialize.js +++ b/packages/js/src/general/initialize.js @@ -10,7 +10,7 @@ import { Dashboard } from "../dashboard"; import { DataProvider } from "../dashboard/services/data-provider"; import { DataTracker } from "../dashboard/services/data-tracker"; import { WidgetFactory } from "../dashboard/services/widget-factory"; -import { ADMIN_URL_NAME, LINK_PARAMS_NAME } from "../shared-admin/store"; +import { ADMIN_URL_NAME, LINK_PARAMS_NAME, OPT_IN_NOTIFICATION_NAME } from "../shared-admin/store"; import App from "./app"; import { RouteErrorFallback } from "./components"; import { ConnectedPremiumUpsellList } from "./components/connected-premium-upsell-list"; @@ -20,7 +20,6 @@ import { AlertCenter, FirstTimeConfiguration, ROUTES, TaskList } from "./routes" import registerStore from "./store"; import { ADMIN_NOTICES_NAME } from "./store/admin-notices"; import { ALERT_CENTER_NAME } from "./store/alert-center"; -import { OPT_IN_NOTIFICATION_NAME } from "./store/opt-in"; /** * @type {import("../index").ContentType} ContentType @@ -45,7 +44,7 @@ domReady( () => { dismissedAlerts: get( window, "wpseoScriptData.dismissedAlerts", {} ), isPremium: get( window, "wpseoScriptData.preferences.isPremium", false ), [ ADMIN_NOTICES_NAME ]: { resolvedNotices: [] }, - [ OPT_IN_NOTIFICATION_NAME ]: { seen: get( window, "wpseoScriptData.optInNotificationSeen", false ) }, + [ OPT_IN_NOTIFICATION_NAME ]: { seen: get( window, "wpseoScriptData.optInNotificationSeen", {} ) }, [ TASK_LIST_NAME ]: { enabled: get( window, "wpseoScriptData.taskListConfiguration.enabled", false ), endpoints: get( window, "wpseoScriptData.taskListConfiguration.endpoints", {} ), diff --git a/packages/js/src/general/store/index.js b/packages/js/src/general/store/index.js index 1672e50b485..6fc7d298c69 100644 --- a/packages/js/src/general/store/index.js +++ b/packages/js/src/general/store/index.js @@ -9,10 +9,16 @@ import { adminUrlSelectors, getInitialAdminUrlState, getInitialLinkParamsState, + getInitialOptInNotificationState, LINK_PARAMS_NAME, linkParamsActions, linkParamsReducer, linkParamsSelectors, + OPT_IN_NOTIFICATION_NAME, + optInNotificationActions, + optInNotificationControls, + optInNotificationReducer, + optInNotificationSelectors, } from "../../shared-admin/store"; import { STORE_NAME } from "../constants"; import { ADMIN_NOTICES_NAME, adminNoticesActions, adminNoticesReducer, adminNoticesSelectors, getInitialAdminNoticesState } from "./admin-notices"; @@ -25,14 +31,6 @@ import { getInitialAlertCenterState, } from "./alert-center"; import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences"; -import { - OPT_IN_NOTIFICATION_NAME, - optInNotificationActions, - optInNotificationReducer, - optInNotificationSelectors, - optInNotificationControls, - getInitialOptInNotificationState, -} from "./opt-in"; import { TASK_LIST_NAME, taskListActions, diff --git a/packages/js/src/settings/components/index.js b/packages/js/src/settings/components/index.js index ee1c65ef276..05688f670f0 100644 --- a/packages/js/src/settings/components/index.js +++ b/packages/js/src/settings/components/index.js @@ -15,7 +15,6 @@ export { default as RouteLayout } from "./route-layout"; export { default as Search } from "./search"; export { ErrorFallback } from "./error-fallback"; export { LlmsTxtAlert } from "./llms-txt-alert"; -export { LlmTxtPopover } from "./llm-txt-popover"; export { LlmsTxtUnsavedChangesModal } from "./llms-txt-unsaved-changes-modal"; export { AdvancedMenu } from "./advanced-menu"; export { SchemaDisableConfirmationModal } from "./schema-disable-confirmation-modal"; diff --git a/packages/js/src/settings/components/llm-txt-popover.js b/packages/js/src/settings/components/llm-txt-popover.js deleted file mode 100644 index f8ddfe8b365..00000000000 --- a/packages/js/src/settings/components/llm-txt-popover.js +++ /dev/null @@ -1,89 +0,0 @@ -import { __ } from "@wordpress/i18n"; -import { Popover, usePopoverContext, useSvgAria, Button } from "@yoast/ui-library"; -import { ReactComponent as YoastIcon } from "../../../images/Yoast_icon_kader.svg"; -import { useRef, useEffect, useState } from "@wordpress/element"; - -/** - * A button component that dismisses the popover when clicked. - * On mount, it automatically focuses and scrolls itself into view for better accessibility. - * - * @returns {JSX.Element} The dismiss button element. - */ -const DismissButton = () => { - const { handleDismiss } = usePopoverContext(); - const dismissButtonText = __( "Got it!", "wordpress-seo" ); - const dismissButtonRef = useRef( null ); - - // useEffect to focus and scroll to the popover dismiss button - useEffect( () => { - const timeout = setTimeout( () => { - if ( dismissButtonRef.current ) { - dismissButtonRef.current.focus(); - dismissButtonRef.current.scrollIntoView( { behavior: "smooth", block: "center" } ); - } - }, 300 ); - - return () => clearTimeout( timeout ); - }, [] ); - - return ; -}; - - -/** - * The LLM txt popover component for the opt in. - * @returns {JSX.Element} The LLM txt popover element. - */ -export const LlmTxtPopover = () => { - const svgAriaProps = useSvgAria(); - const [ isPopoverVisible, setIsPopoverVisible ] = useState( true ); - - useEffect( () => { - sessionStorage?.removeItem( "yoast-highlight-setting" ); - }, [] ); - - return - <> -
-
- -
-
- - { __( "Enable the llms.txt feature", "wordpress-seo" ) } - -
- - -
- - { __( "Automatically generate an llms.txt file that points LLMs to your site's most relevant content. We also gave you editor access.", "wordpress-seo" ) } - - -
- -
- -
; -}; diff --git a/packages/js/src/settings/routes/llms-txt.js b/packages/js/src/settings/routes/llms-txt.js index bd20f516a73..e6cf02010cc 100644 --- a/packages/js/src/settings/routes/llms-txt.js +++ b/packages/js/src/settings/routes/llms-txt.js @@ -13,7 +13,6 @@ import { FormikIndexablePageSelectField, FormLayout, RouteLayout, - LlmTxtPopover, LlmsTxtAlert, LlmsTxtUnsavedChangesModal, } from "../components"; @@ -143,8 +142,6 @@ const LlmTxt = () => { } }, [ fetchIndexablePages, isLlmsTxtEnabled, llmsTxtSelectionMode ] ); - const isOptIn = useMemo( () => ! isLlmsTxtEnabled && sessionStorage?.getItem( "yoast-highlight-setting" ) === "llm-txt", [ isLlmsTxtEnabled ] ); - const [ openUnsavedFile, , , setOpenUnsavedFile, unsetOpenUnsavedFile ] = useToggleState( false ); return ( @@ -158,7 +155,7 @@ const LlmTxt = () => { { generationFailure && initialIsLlmsTxtEnabled && isLlmsTxtEnabled && } -
+
{ ), LABEL ) } - className={ isOptIn ? "yst-popover-backdrop-highlight-button" : "" } /> - { isOptIn && }
{ ! showUnsavedChangesModal &&