Skip to content
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

Fix tab focus for ai optimize toast element #21635

Draft
wants to merge 12 commits into
base: trunk
Choose a base branch
from
Draft
1 change: 1 addition & 0 deletions packages/analysis-report/src/AnalysisList.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function renderRatingToColor( rating ) {
export default function AnalysisList( props ) {
return <AnalysisListBase role="list">
{ props.results.map( ( result ) => {
console.log("AnalysisList result: ", result);
const color = renderRatingToColor( result.rating );
const isMarkButtonPressed = result.markerId === props.marksButtonActivatedResult;

Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/IconAIFixesButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import PropTypes from "prop-types";
import { __ } from "@wordpress/i18n";
import { useCallback, useRef, useState } from "@wordpress/element";
import { useCallback, useRef, useState, useEffect } from "@wordpress/element";
import { doAction } from "@wordpress/hooks";
import { useSelect, useDispatch } from "@wordpress/data";

Expand All @@ -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.
Expand All @@ -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 );
Expand All @@ -42,38 +44,57 @@ 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,
};
}

const disabledAIButtons = select( "yoast-seo/editor" ).getDisabledAIFixesButtons();
if ( Object.keys( disabledAIButtons ).includes( aiFixesId ) ) {
return {
isEnabled: false,
isFocused: false,
ariaLabel: disabledAIButtons[ aiFixesId ],
ariaHasPopup: false,
};
}

const editorMode = select( "core/edit-post" ).getEditorMode();
if ( editorMode !== "visual" ) {
return {
isEnabled: false,
isFocused: false,
ariaLabel: htmlLabel,
ariaHasPopup: false,
};
}

const blocks = getAllBlocks( select( "core/block-editor" ).getBlocks() );
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 ] );

const buttonRef = useRef( null );

useEffect( () => {
if ( isFocused ) {
setTimeout( () => {
buttonRef.current?.focus();
}, 1000 );
setFocusAIFixesButton( false );
}
}, [ isFocused ] );

/**
* Handles the button press state.
* @returns {void}
Expand Down Expand Up @@ -142,6 +163,8 @@ const AIAssessmentFixesButton = ( { id, isPremium } ) => {
className={ `ai-button ${buttonClass}` }
pressed={ isButtonPressed }
disabled={ ! isEnabled }
ariaHasPopup={ ariaHasPopup }
ref={ buttonRef }
>
{ ! isPremium && <LockClosedIcon className="yst-fixes-button__lock-icon yst-text-amber-900" /> }
<SparklesIcon pressed={ isButtonPressed } />
Expand Down
30 changes: 29 additions & 1 deletion packages/js/src/components/contentAnalysis/SeoAnalysis.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* global wpseoAdminL10n */
import { withSelect } from "@wordpress/data";
import {useSelect, withSelect} from "@wordpress/data";
import { Component, Fragment } from "@wordpress/element";
import { __, sprintf } from "@wordpress/i18n";
import { addQueryArgs } from "@wordpress/url";
Expand All @@ -22,6 +22,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 uniqueId from "lodash/uniqueId";

const AnalysisHeader = styled.span`
font-size: 1em;
Expand All @@ -34,6 +35,11 @@ const AnalysisHeader = styled.span`
* Redux container for the seo analysis.
*/
class SeoAnalysis extends Component {
constructor( props ) {
super( props );

this.aiButtons = [];
}
/**
* Renders the keyword synonyms upsell modal.
*
Expand Down Expand Up @@ -206,6 +212,8 @@ class SeoAnalysis extends Component {
* @returns {void|JSX.Element} The AI Optimize button, or nothing if the button should not be shown.
*/
renderAIFixesButton = ( hasAIFixes, id ) => {
console.log('renderAIFixesButton id: ', id);
this.aiButtons.push(id);
const isPremium = getL10nObject().isPremium;

// Don't show the button if the AI feature is not enabled for Yoast SEO Premium users.
Expand All @@ -221,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.focusAIFixesButton;
console.log("SeoAnalysis componentDidUpdate ids: ", ids);
console.log("SeoAnalysis componentDidUpdate focusAIFixesButton: ", focusAIFixesButton);

if (ids.includes(focusAIFixesButton) && !this.aiButtons.include(focusAIFixesButton)) {
console.log('do focus on tab');
}

}

/**
* Renders the SEO Analysis component.
*
Expand All @@ -236,6 +256,10 @@ class SeoAnalysis extends Component {
score.screenReaderReadabilityText = __( "Enter a focus keyphrase to calculate the SEO score", "wordpress-seo" );
}

console.log('SeoAnalysis render this.props.results: ', this.props.results);

this.aiButtons = [];

return (
<LocationConsumer>
{ location => {
Expand Down Expand Up @@ -298,6 +322,7 @@ SeoAnalysis.propTypes = {
results: PropTypes.array,
marksButtonStatus: PropTypes.string,
keyword: PropTypes.string,
focusAIFixesButton: PropTypes.string,
shouldUpsell: PropTypes.bool,
shouldUpsellWordFormRecognition: PropTypes.bool,
overallScore: PropTypes.number,
Expand Down Expand Up @@ -325,14 +350,17 @@ export default withSelect( ( select, ownProps ) => {
getResultsForKeyword,
getIsElementorEditor,
getPreference,
getFocusAIFixesButton,
} = select( "yoast-seo/editor" );

const focusAIFixesButton = getFocusAIFixesButton();
const keyword = getFocusKeyphrase();

return {
...getResultsForKeyword( keyword ),
marksButtonStatus: ownProps.hideMarksButtons ? "disabled" : getMarksButtonStatus(),
keyword,
focusAIFixesButton,
isElementor: getIsElementorEditor(),
isAiFeatureEnabled: getPreference( "isAiFeatureActive", false ),
};
Expand Down
13 changes: 13 additions & 0 deletions packages/js/src/redux/actions/AIButton.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
};
}
14 changes: 12 additions & 2 deletions packages/js/src/redux/reducers/AIButton.js
Original file line number Diff line number Diff line change
@@ -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: {},
};

Expand All @@ -15,10 +16,19 @@ 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;
console.log( "focusAIButton", 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 {
Expand Down
8 changes: 8 additions & 0 deletions packages/js/src/redux/selectors/AIButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", "" );
Original file line number Diff line number Diff line change
Expand Up @@ -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( <AIAssessmentFixesButton id="keyphraseDensity" isPremium={ false } /> );

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( <AIAssessmentFixesButton id="keyphraseDensity" isPremium={ false } /> );

const dialogPopup = document.querySelector( 'button[aria-haspopup="dialog"]' );
expect( dialogPopup ).toBeInTheDocument();
} );

test( "should find the correct button id", () => {
mockSelect( "keyphraseDensityAIFixes" );
render( <AIAssessmentFixesButton id="keyphraseDensity" isPremium={ true } /> );
Expand Down Expand Up @@ -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( <AIAssessmentFixesButton id="keyphraseDensity" isPremium={ true } /> );
Expand All @@ -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( <AIAssessmentFixesButton id="keyphraseDensity" isPremium={ true } /> );
Expand All @@ -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( <AIAssessmentFixesButton id="keyphraseDensity" isPremium={ true } /> );
Expand Down
17 changes: 13 additions & 4 deletions packages/ui-library/src/components/notifications/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" } );
Expand Down Expand Up @@ -113,32 +113,41 @@ 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 ) => (
<NotificationsContext.Provider value={ { position } }>
<aside
className={ classNames(
"yst-notifications",
notificationsClassNameMap.position[ position ],
className,
) }
ref={ ref }
{ ...props }
>
{ children }
</aside>
</NotificationsContext.Provider>
);
) );
Notifications.displayName = "Notifications";

Notifications.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
position: PropTypes.oneOf( keys( notificationsClassNameMap.position ) ),
};

Notifications.defaultProps = {
children: null,
className: "",
position: "bottom-left",
};

Notifications.Notification = Notification;
Notifications.Notification.displayName = "Notifications.Notification";

Expand Down
Loading