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",
},