diff --git a/packages/components/src/IconAIFixesButton.js b/packages/components/src/IconAIFixesButton.js index f6b5f9f0c13..b6c497a6c0e 100644 --- a/packages/components/src/IconAIFixesButton.js +++ b/packages/components/src/IconAIFixesButton.js @@ -29,6 +29,7 @@ const IconAIFixesButton = function( props ) { unpressedBackground={ props.unpressedBackground } id={ props.id } aria-label={ props.ariaLabel } + aria-haspopup={ props.ariaHasPopup } aria-pressed={ props.pressed } pressedIconColor={ props.pressedIconColor } className={ props.className } @@ -43,6 +44,7 @@ IconAIFixesButton.propTypes = { children: PropTypes.node, id: PropTypes.string.isRequired, ariaLabel: PropTypes.string, + ariaHasPopup: PropTypes.string, onClick: PropTypes.func, onPointerEnter: PropTypes.func, onPointerLeave: PropTypes.func, diff --git a/packages/js/src/ai-assessment-fixes/components/ai-assessment-fixes-button.js b/packages/js/src/ai-assessment-fixes/components/ai-assessment-fixes-button.js index 8ccce233b31..d1f186c8dcb 100644 --- a/packages/js/src/ai-assessment-fixes/components/ai-assessment-fixes-button.js +++ b/packages/js/src/ai-assessment-fixes/components/ai-assessment-fixes-button.js @@ -13,6 +13,7 @@ import { Paper } from "yoastseo"; import { ModalContent } from "./modal-content"; import { getAllBlocks } from "../../helpers/getAllBlocks"; import { LockClosedIcon } from "@heroicons/react/solid"; +import { setFocusAIFixesButton } from "../../redux/actions"; /** * The AI Assessment Fixes button component. @@ -26,6 +27,7 @@ const AIAssessmentFixesButton = ( { id, isPremium } ) => { const aiFixesId = id + "AIFixes"; const [ isModalOpen, , , setIsModalOpenTrue, setIsModalOpenFalse ] = useToggleState( false ); const activeAIButtonId = useSelect( select => select( "yoast-seo/editor" ).getActiveAIFixesButton(), [] ); + const focusAIButton = useSelect( select => select( "yoast-seo/editor" ).getFocusAIFixesButton(), [] ); const activeMarker = useSelect( select => select( "yoast-seo/editor" ).getActiveMarker(), [] ); const { setActiveAIFixesButton, setActiveMarker, setMarkerPauseStatus, setMarkerStatus } = useDispatch( "yoast-seo/editor" ); const focusElementRef = useRef( null ); @@ -45,11 +47,13 @@ const AIAssessmentFixesButton = ( { id, isPremium } ) => { // (2) the AI button is not disabled. // (3) the editor is in visual mode. // (4) all blocks are in visual mode. - const { isEnabled, ariaLabel } = useSelect( ( select ) => { + const { isEnabled, isFocused, ariaLabel, ariaHasPopup } = useSelect( ( select ) => { if ( activeAIButtonId !== null && ! isButtonPressed ) { return { isEnabled: false, + isFocused: false, ariaLabel: null, + ariaHasPopup: false, }; } @@ -57,14 +61,18 @@ const AIAssessmentFixesButton = ( { id, isPremium } ) => { if ( Object.keys( disabledAIButtons ).includes( aiFixesId ) ) { return { isEnabled: false, + isFocused: false, ariaLabel: disabledAIButtons[ aiFixesId ], + ariaHasPopup: false, }; } if ( editorMode !== "visual" ) { return { isEnabled: false, + isFocused: false, ariaLabel: htmlLabel, + ariaHasPopup: false, }; } @@ -72,7 +80,9 @@ const AIAssessmentFixesButton = ( { id, isPremium } ) => { const allVisual = blocks.every( block => select( "core/block-editor" ).getBlockMode( block.clientId ) === "visual" ); return { isEnabled: allVisual, + isFocused: allVisual && focusAIButton === aiFixesId, ariaLabel: allVisual ? defaultLabel : htmlLabel, + ariaHasPopup: allVisual ? "dialog" : false, }; }, [ isButtonPressed, activeAIButtonId, editorMode ] ); @@ -99,6 +109,17 @@ const AIAssessmentFixesButton = ( { id, isPremium } ) => { }; }, [ editorMode, activeAIButtonId, setMarkerStatus ] ); + const buttonRef = useRef( null ); + + useEffect( () => { + if ( isFocused ) { + setTimeout( () => { + buttonRef.current?.focus(); + }, 1000 ); + setFocusAIFixesButton( false ); + } + }, [ isFocused ] ); + /** * Handles the button press state. * @returns {void} @@ -166,6 +187,8 @@ const AIAssessmentFixesButton = ( { id, isPremium } ) => { className={ `ai-button ${buttonClass}` } pressed={ isButtonPressed } disabled={ ! isEnabled } + ariaHasPopup={ ariaHasPopup } + ref={ buttonRef } > { ! isPremium && } diff --git a/packages/js/src/components/MetaboxCollapsible.js b/packages/js/src/components/MetaboxCollapsible.js index 39650a0a941..26a4fbbf5d0 100644 --- a/packages/js/src/components/MetaboxCollapsible.js +++ b/packages/js/src/components/MetaboxCollapsible.js @@ -1,5 +1,6 @@ import { Collapsible } from "@yoast/components"; import styled from "styled-components"; +import { forwardRef } from "@wordpress/element"; const StyledMetaboxCollapsible = styled( Collapsible )` h2 > button { @@ -26,8 +27,8 @@ const StyledMetaboxCollapsible = styled( Collapsible )` * * @returns {React.Component} A MetaboxCollapsible component */ -const MetaboxCollapsible = ( props ) => { - return ; -}; +const MetaboxCollapsible = forwardRef( ( props, ref ) => { + return ; +} ); export default MetaboxCollapsible; diff --git a/packages/js/src/components/SidebarCollapsible.js b/packages/js/src/components/SidebarCollapsible.js index 8fcef4fb9c7..26bd79590d2 100644 --- a/packages/js/src/components/SidebarCollapsible.js +++ b/packages/js/src/components/SidebarCollapsible.js @@ -1,4 +1,4 @@ -import { useState } from "@wordpress/element"; +import { forwardRef, useState } from "@wordpress/element"; import { BetaBadge, SvgIcon } from "@yoast/components"; import PropTypes from "prop-types"; /* eslint-disable complexity */ @@ -10,7 +10,7 @@ import PropTypes from "prop-types"; * * @returns {wp.Element} The Collapsible component. */ -const SidebarCollapsible = ( props ) => { +const SidebarCollapsible = forwardRef( ( props, ref ) => { const [ isOpen, toggleOpen ] = useState( false ); const { @@ -33,6 +33,7 @@ const SidebarCollapsible = ( props ) => { className="components-button components-panel__body-toggle" type="button" id={ props.buttonId } + ref={ ref } > { { isOpen && props.children } ; -}; +} ); /* eslint-enable complexity */ export default SidebarCollapsible; diff --git a/packages/js/src/components/contentAnalysis/SeoAnalysis.js b/packages/js/src/components/contentAnalysis/SeoAnalysis.js index 5746fd8c427..4a6b6fd6b19 100644 --- a/packages/js/src/components/contentAnalysis/SeoAnalysis.js +++ b/packages/js/src/components/contentAnalysis/SeoAnalysis.js @@ -21,6 +21,7 @@ import SynonymSlot from "../slots/SynonymSlot"; import { getIconForScore } from "./mapResults"; import isBlockEditor from "../../helpers/isBlockEditor"; import AIAssessmentFixesButton from "../../ai-assessment-fixes/components/ai-assessment-fixes-button"; +import React from "react"; const AnalysisHeader = styled.span` font-size: 1em; @@ -33,6 +34,12 @@ const AnalysisHeader = styled.span` * Redux container for the seo analysis. */ class SeoAnalysis extends Component { + constructor( props ) { + super( props ); + + this.collapsibleRef = React.createRef(); + this.aiButtons = []; + } /** * Renders the keyword synonyms upsell modal. * @@ -222,6 +229,18 @@ class SeoAnalysis extends Component { ); }; /* eslint-enable complexity */ + componentDidUpdate() { + const ids = this.props.results.map( r => r.getIdentifier() + "AIFixes" ); + const { focusAIFixesButton } = this.props; + setTimeout( () => { + if ( ids.includes( focusAIFixesButton ) && ! this.aiButtons.includes( focusAIFixesButton ) ) { + console.log( "collapsibleRef", this.collapsibleRef.current ); + this.collapsibleRef.current?.focus(); + } else { + console.log( "focus AI button" ); + } + }, 1000 ); + } /** * Renders the SEO Analysis component. @@ -262,6 +281,7 @@ class SeoAnalysis extends Component { prefixIconCollapsed={ getIconForScore( score.className ) } subTitle={ this.props.keyword } id={ `yoast-seo-analysis-collapsible-${ location }` } + ref={ this.collapsibleRef } > { this.props.shouldUpsell && @@ -300,6 +320,7 @@ SeoAnalysis.propTypes = { results: PropTypes.array, marksButtonStatus: PropTypes.string, keyword: PropTypes.string, + focusAIFixesButton: PropTypes.string, shouldUpsell: PropTypes.bool, shouldUpsellWordFormRecognition: PropTypes.bool, overallScore: PropTypes.number, @@ -332,12 +353,14 @@ export default withSelect( ( select, ownProps ) => { getIsAiFeatureEnabled, } = select( "yoast-seo/editor" ); + const focusAIFixesButton = getFocusAIFixesButton(); const keyword = getFocusKeyphrase(); return { ...getResultsForKeyword( keyword ), marksButtonStatus: ownProps.hideMarksButtons ? "disabled" : getMarksButtonStatus(), keyword, + focusAIFixesButton, isElementor: getIsElementorEditor(), isPremium: getIsPremium(), isAiFeatureEnabled: getIsAiFeatureEnabled(), diff --git a/packages/js/src/redux/actions/AIButton.js b/packages/js/src/redux/actions/AIButton.js index 4dee72e7886..e2d3a88f7e7 100644 --- a/packages/js/src/redux/actions/AIButton.js +++ b/packages/js/src/redux/actions/AIButton.js @@ -1,5 +1,6 @@ export const SET_ACTIVE_AI_FIXES_BUTTON = "SET_ACTIVE_AI_FIXES_BUTTON"; export const SET_DISABLED_AI_FIXES_BUTTONS = "SET_DISABLED_AI_FIXES_BUTTONS"; +export const SET_FOCUS_AI_FIXES_BUTTON = "SET_FOCUS_AI_FIXES_BUTTON"; /** * Updates the active AI fixes button id. @@ -24,3 +25,15 @@ export function setDisabledAIFixesButtons( disabledAIButtons ) { disabledAIButtons, }; } + +/** + * Updates the focused AI button. + * @param {string} focusAIButton The focused AI buttons along with their reasons. + * @returns {Object} An action for redux. + */ +export function setFocusAIFixesButton( focusAIButton ) { + return { + type: SET_FOCUS_AI_FIXES_BUTTON, + focusAIButton, + }; +} diff --git a/packages/js/src/redux/reducers/AIButton.js b/packages/js/src/redux/reducers/AIButton.js index 6e7578d74f2..03d7ed7c0de 100644 --- a/packages/js/src/redux/reducers/AIButton.js +++ b/packages/js/src/redux/reducers/AIButton.js @@ -1,7 +1,8 @@ -import { SET_ACTIVE_AI_FIXES_BUTTON, SET_DISABLED_AI_FIXES_BUTTONS } from "../actions"; +import { SET_ACTIVE_AI_FIXES_BUTTON, SET_DISABLED_AI_FIXES_BUTTONS, SET_FOCUS_AI_FIXES_BUTTON } from "../actions"; const INITIAL_STATE = { activeAIButton: null, + focusAIButton: null, disabledAIButtons: {}, }; @@ -15,10 +16,18 @@ const INITIAL_STATE = { */ export default function AIButton( state = INITIAL_STATE, action ) { switch ( action.type ) { - case SET_ACTIVE_AI_FIXES_BUTTON: + case SET_ACTIVE_AI_FIXES_BUTTON: { + const focusAIButton = action.activeAIButton === null && state.activeAIButton !== null ? state.activeAIButton : state.focusAIButton; return { ...state, activeAIButton: action.activeAIButton, + focusAIButton, + }; + } + case SET_FOCUS_AI_FIXES_BUTTON: + return { + ...state, + focusAIButton: action.focusAIButton, }; case SET_DISABLED_AI_FIXES_BUTTONS: return { diff --git a/packages/js/src/redux/selectors/AIButton.js b/packages/js/src/redux/selectors/AIButton.js index 5fc879d03f4..55a00d1aa6f 100644 --- a/packages/js/src/redux/selectors/AIButton.js +++ b/packages/js/src/redux/selectors/AIButton.js @@ -13,3 +13,11 @@ export const getActiveAIFixesButton = state => get( state, "AIButton.activeAIBut * @returns {object} The disabled buttons along with their reasons. */ export const getDisabledAIFixesButtons = state => get( state, "AIButton.disabledAIButtons", {} ); + + +/** + * Returns the focus to the AI Fixes button. + * @param {object} state The state. + * @returns {string} Focus AI Fixes button id. + */ +export const getFocusAIFixesButton = state => get( state, "AIButton.focusAIButton", "" ); diff --git a/packages/js/tests/ai-assessment-fixes/components/AIAssessmentFixesButton.test.js b/packages/js/tests/ai-assessment-fixes/components/AIAssessmentFixesButton.test.js index a2bc7b3279f..0fe2fb2b6fe 100644 --- a/packages/js/tests/ai-assessment-fixes/components/AIAssessmentFixesButton.test.js +++ b/packages/js/tests/ai-assessment-fixes/components/AIAssessmentFixesButton.test.js @@ -67,6 +67,22 @@ describe( "AIAssessmentFixesButton", () => { expect( labelText ).toBeInTheDocument(); } ); + test( "should find the correct aria-haspopoup in the document to be false when the editor mode is not visual", () => { + mockSelect( "keyphraseDensityAIFixes", "code" ); + render( ); + + const dialogPopup = document.querySelector( 'button[aria-haspopup="false"]' ); + expect( dialogPopup ).toBeInTheDocument(); + } ); + + test( "should find the correct aria-haspopoup in the document to be a dialog when the editor mode is visual", () => { + mockSelect( "keyphraseDensityAIFixes" ); + render( ); + + const dialogPopup = document.querySelector( 'button[aria-haspopup="dialog"]' ); + expect( dialogPopup ).toBeInTheDocument(); + } ); + test( "should find the correct button id", () => { mockSelect( "keyphraseDensityAIFixes" ); render( ); @@ -127,6 +143,7 @@ describe( "AIAssessmentFixesButton", () => { expect( button ).toBeDisabled(); expect( button ).toHaveAttribute( "aria-label", "Please switch to the visual editor to optimize with AI." ); } ); + test( "should disable the highlighting button when the AI button is clicked", () => { mockSelect( null ); render( ); @@ -140,6 +157,7 @@ describe( "AIAssessmentFixesButton", () => { expect( setActiveAIFixesButton ).toHaveBeenCalledWith( "keyphraseDensityAIFixes" ); expect( setMarkerStatus ).toHaveBeenCalledWith( "disabled" ); } ); + test( "should enable back the highlighting button when the AI button is clicked the second time", () => { mockSelect( "keyphraseDensityAIFixes" ); render( ); @@ -153,6 +171,7 @@ describe( "AIAssessmentFixesButton", () => { expect( setActiveAIFixesButton ).toHaveBeenCalledWith( null ); expect( setMarkerStatus ).toHaveBeenCalledWith( "enabled" ); } ); + test( "should remove the active marker if it's available when the AI button is clicked", () => { mockSelect( "keyphraseDensityAIFixes", "visual", [ { clientId: "test" } ], "test", "keyphraseDensity" ); render( ); diff --git a/packages/ui-library/src/components/notifications/index.js b/packages/ui-library/src/components/notifications/index.js index a265a8d2b3f..fc6ef037c98 100644 --- a/packages/ui-library/src/components/notifications/index.js +++ b/packages/ui-library/src/components/notifications/index.js @@ -2,7 +2,7 @@ import classNames from "classnames"; import { keys, noop } from "lodash"; import PropTypes from "prop-types"; -import React, { createContext, useContext, useState } from "react"; +import React, { createContext, useContext, useState, forwardRef } from "react"; import { ValidationIcon } from "../../elements/validation"; import Toast from "../../elements/toast"; const NotificationsContext = createContext( { position: "bottom-left" } ); @@ -113,12 +113,13 @@ const notificationsClassNameMap = { * @param {Object} [props] Additional props. * @returns {JSX.Element} The Notifications element. */ -const Notifications = ( { + +const Notifications = forwardRef( ( { children, className = "", position = "bottom-left", ...props -} ) => ( +}, ref ) => ( -); +) ); +Notifications.displayName = "Notifications"; Notifications.propTypes = { children: PropTypes.node, @@ -139,6 +142,12 @@ Notifications.propTypes = { position: PropTypes.oneOf( keys( notificationsClassNameMap.position ) ), }; +Notifications.defaultProps = { + children: null, + className: "", + position: "bottom-left", +}; + Notifications.Notification = Notification; Notifications.Notification.displayName = "Notifications.Notification"; diff --git a/packages/ui-library/src/elements/toast/index.js b/packages/ui-library/src/elements/toast/index.js index 424dd25ce01..1dab385104d 100644 --- a/packages/ui-library/src/elements/toast/index.js +++ b/packages/ui-library/src/elements/toast/index.js @@ -4,7 +4,7 @@ import { XIcon } from "@heroicons/react/outline"; import classNames from "classnames"; import { isArray, noop } from "lodash"; import PropTypes from "prop-types"; -import React, { createContext, useCallback, useContext, useEffect } from "react"; +import React, { createContext, useCallback, useContext, useEffect, forwardRef } from "react"; const ToastContext = createContext( { handleDismiss: noop } ); @@ -26,13 +26,14 @@ export const toastClassNameMap = { * @param {string} [className] The additional class name. * @returns {JSX.Element} The close button. */ -const Close = ( { +const Close = forwardRef( ( { dismissScreenReaderLabel, -} ) => { - const { handleDismiss } = useToastContext(); +}, ref ) => { + const { handleDismiss } = useContext( ToastContext ); return (
); -}; +} ); + +Close.displayName = "Close"; Close.propTypes = { dismissScreenReaderLabel: PropTypes.string.isRequired, @@ -96,6 +99,7 @@ Title.propTypes = { * @param {Object} props The props object. * @param {JSX.node} children The children. * @param {string} id The toast ID. + * @param {string} role The toast role. * @param {string} [className] The additional class name. * @param {string} position The toast position. * @param {Function} [onDismiss] Function to trigger on dismissal. @@ -104,16 +108,18 @@ Title.propTypes = { * @param {Function} setIsVisible Function to set the visibility of the notification. * @returns {JSX.Element} The toast component. */ -const Toast = ( { +const Toast = forwardRef( ( { children, id, + role, className = "", position = "bottom-left", onDismiss = noop, autoDismiss = null, isVisible, setIsVisible, -} ) => { + ...props +}, ref ) => { const handleDismiss = useCallback( () => { // Disable visibility on dismiss to trigger transition. setIsVisible( false ); @@ -149,17 +155,20 @@ const Toast = ( { "yst-toast", className, ) } - role="alert" + role={ role } + ref={ ref } + { ...props } > { children } ); -}; +} ); Toast.propTypes = { children: PropTypes.node, id: PropTypes.string.isRequired, + role: PropTypes.string.isRequired, className: PropTypes.string, position: PropTypes.string, onDismiss: PropTypes.func, @@ -168,6 +177,15 @@ Toast.propTypes = { setIsVisible: PropTypes.func.isRequired, }; +Toast.defaultProps = { + children: null, + className: "", + position: "bottom-left", + onDismiss: noop, + autoDismiss: null, +}; + +Toast.displayName = "Toast"; Toast.Close = Close; Toast.Description = Description; Toast.Title = Title; diff --git a/packages/ui-library/src/elements/toast/stories.js b/packages/ui-library/src/elements/toast/stories.js index e08d31973b2..914bef48959 100644 --- a/packages/ui-library/src/elements/toast/stories.js +++ b/packages/ui-library/src/elements/toast/stories.js @@ -221,6 +221,7 @@ export default { isVisible: true, setIsVisible: noop, id: "toast", + role: "alert", children: "Hello everyone!", position: "bottom-left", },